diff --git a/.prettierignore b/.prettierignore
index 2df23c2..50111d4 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,3 +5,8 @@ yarn.lock
bun.lock
bun.lockb
src/lib/config/vendorIconManifest.ts
+
+# Design handoff bundles (prototypes, not code)
+new-design/
+claude-design/
+homelab-hub-main/
diff --git a/README.md b/README.md
index d146d97..ad13924 100644
--- a/README.md
+++ b/README.md
@@ -85,8 +85,8 @@ docker rm open-network-diagram
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|  |  |  |
-| IPAM panel with subnet utilization | Rack view with shelf items |
-| ------------------------------------------------------------------ | -------------------------------------------------------- |
+| IPAM panel with subnet utilization | Rack view with shelf items |
+| --------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|  |  |
## Docker Compose Option
diff --git a/docker-compose.yml b/docker-compose.yml
index 280ab4b..2400de4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,10 +5,10 @@ services:
dockerfile: Dockerfile
image: open-network-diagram:local
ports:
- - "8080:3000"
+ - '8080:3000'
volumes:
- ./data:/app/data
environment:
- NETWORK_DATA_FILE: "/app/data/network.json"
- NETWORK_BACKUP_DIR: "/app/data/.backups"
+ NETWORK_DATA_FILE: '/app/data/network.json'
+ NETWORK_BACKUP_DIR: '/app/data/.backups'
restart: unless-stopped
diff --git a/package.json b/package.json
index 4fbd7d5..5050675 100644
--- a/package.json
+++ b/package.json
@@ -38,8 +38,8 @@
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.8.1",
- "prettier-plugin-svelte": "^3.3.3",
- "prettier-plugin-tailwindcss": "^0.6.11",
+ "prettier-plugin-svelte": "^4.1.1",
+ "prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
@@ -55,6 +55,7 @@
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"dependencies": {
"cytoscape": "^3.31.2",
- "cytoscape-dagre": "^2.5.0"
+ "cytoscape-dagre": "^2.5.0",
+ "html-to-image": "^1.11.13"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f1cb2b..ae92314 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
cytoscape-dagre:
specifier: ^2.5.0
version: 2.5.0(cytoscape@3.33.1)
+ html-to-image:
+ specifier: ^1.11.13
+ version: 1.11.13
devDependencies:
'@eslint/compat':
specifier: ^1.2.5
@@ -76,11 +79,11 @@ importers:
specifier: ^3.8.1
version: 3.8.1
prettier-plugin-svelte:
- specifier: ^3.3.3
- version: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
+ specifier: ^4.1.1
+ version: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
prettier-plugin-tailwindcss:
- specifier: ^0.6.11
- version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
+ specifier: ^0.8.0
+ version: 0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1)
svelte:
specifier: ^5.0.0
version: 5.25.8
@@ -1447,6 +1450,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ html-to-image@1.11.13:
+ resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1881,17 +1887,20 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
- prettier-plugin-svelte@3.3.3:
- resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
+ prettier-plugin-svelte@4.1.1:
+ resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==}
+ engines: {node: '>=20'}
peerDependencies:
prettier: ^3.0.0
- svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
+ svelte: ^5.0.0
- prettier-plugin-tailwindcss@0.6.11:
- resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
- engines: {node: '>=14.21.3'}
+ prettier-plugin-tailwindcss@0.8.0:
+ resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
+ engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
+ '@prettier/plugin-hermes': '*'
+ '@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
@@ -1899,18 +1908,20 @@ packages:
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
- prettier-plugin-import-sort: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
- prettier-plugin-style-order: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
+ '@prettier/plugin-hermes':
+ optional: true
+ '@prettier/plugin-oxc':
+ optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
@@ -1923,8 +1934,6 @@ packages:
optional: true
prettier-plugin-css-order:
optional: true
- prettier-plugin-import-sort:
- optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
@@ -1937,8 +1946,6 @@ packages:
optional: true
prettier-plugin-sort-imports:
optional: true
- prettier-plugin-style-order:
- optional: true
prettier-plugin-svelte:
optional: true
@@ -3631,6 +3638,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ html-to-image@1.11.13: {}
+
ignore@5.3.2: {}
import-fresh@3.3.1:
@@ -4022,16 +4031,16 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8):
+ prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8):
dependencies:
prettier: 3.8.1
svelte: 5.25.8
- prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
+ prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.1(prettier@3.8.1)(svelte@5.25.8))(prettier@3.8.1):
dependencies:
prettier: 3.8.1
optionalDependencies:
- prettier-plugin-svelte: 3.3.3(prettier@3.8.1)(svelte@5.25.8)
+ prettier-plugin-svelte: 4.1.1(prettier@3.8.1)(svelte@5.25.8)
prettier@3.8.1: {}
diff --git a/scripts/generate-vendor-icon-manifest.mjs b/scripts/generate-vendor-icon-manifest.mjs
index 4f31db9..e951159 100644
--- a/scripts/generate-vendor-icon-manifest.mjs
+++ b/scripts/generate-vendor-icon-manifest.mjs
@@ -12,181 +12,186 @@ const prettierIgnorePath = path.join(projectRoot, '.prettierignore');
const allowedExtensions = new Set(['.svg', '.png', '.webp']);
const extensionPriority = {
- '.svg': 0,
- '.png': 1,
- '.webp': 2
+ '.svg': 0,
+ '.png': 1,
+ '.webp': 2
};
/** @param {string} input */
function toPosixPath(input) {
- return input.split(path.sep).join('/');
+ return input.split(path.sep).join('/');
}
/** @param {string} filename */
function toSlug(filename) {
- return filename
- .toLowerCase()
- .replace(/[^a-z0-9._-]/g, '-')
- .replace(/[._]+/g, '-')
- .replace(/-+/g, '-')
- .replace(/^-|-$/g, '');
+ return filename
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]/g, '-')
+ .replace(/[._]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
}
/** @param {string} slug */
function toLabel(slug) {
- return slug
- .split('-')
- .filter(Boolean)
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join(' ');
+ return slug
+ .split('-')
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
}
/** @param {string} dir */
async function walk(dir) {
- const entries = await fs.readdir(dir, { withFileTypes: true });
- const files = entries
- .filter((entry) => entry.isFile())
- .map((entry) => path.join(dir, entry.name));
- const directories = entries
- .filter((entry) => entry.isDirectory())
- .map((entry) => path.join(dir, entry.name));
- const nestedFileLists = await Promise.all(directories.map((childDirectory) => walk(childDirectory)));
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ const files = entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => path.join(dir, entry.name));
+ const directories = entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => path.join(dir, entry.name));
+ const nestedFileLists = await Promise.all(
+ directories.map((childDirectory) => walk(childDirectory))
+ );
- for (const nestedFiles of nestedFileLists) {
- files.push(...nestedFiles);
- }
- return files;
+ for (const nestedFiles of nestedFileLists) {
+ files.push(...nestedFiles);
+ }
+ return files;
}
function compareIconRecords(a, b) {
- const labelCompare = a.label.localeCompare(b.label);
- if (labelCompare !== 0) {
- return labelCompare;
- }
- return a.key.localeCompare(b.key);
+ const labelCompare = a.label.localeCompare(b.label);
+ if (labelCompare !== 0) {
+ return labelCompare;
+ }
+ return a.key.localeCompare(b.key);
}
/** @param {string} filePath @param {string} entry */
async function ensureIgnoreEntry(filePath, entry) {
- const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
- let contents = '';
- try {
- contents = await fs.readFile(filePath, 'utf8');
- } catch (error) {
- if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
- throw error;
- }
- }
+ const normalizedEntry = toPosixPath(entry).replace(/^\.?\//, '');
+ let contents = '';
+ try {
+ contents = await fs.readFile(filePath, 'utf8');
+ } catch (error) {
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
- const eol = contents.includes('\r\n') ? '\r\n' : '\n';
- const existingEntries = contents
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter((line) => line && !line.startsWith('#'))
- .map((line) => toPosixPath(line).replace(/^\.?\//, ''));
- if (existingEntries.includes(normalizedEntry)) {
- return;
- }
+ const eol = contents.includes('\r\n') ? '\r\n' : '\n';
+ const existingEntries = contents
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'))
+ .map((line) => toPosixPath(line).replace(/^\.?\//, ''));
+ if (existingEntries.includes(normalizedEntry)) {
+ return;
+ }
- const suffix = contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
- const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
- await fs.writeFile(filePath, nextContents, 'utf8');
+ const suffix =
+ contents.length > 0 && !contents.endsWith('\n') && !contents.endsWith('\r\n') ? eol : '';
+ const nextContents = `${contents}${suffix}${normalizedEntry}${eol}`;
+ await fs.writeFile(filePath, nextContents, 'utf8');
}
async function main() {
- const vendorExists = await fs
- .access(vendorRoot)
- .then(() => true)
- .catch(() => false);
+ const vendorExists = await fs
+ .access(vendorRoot)
+ .then(() => true)
+ .catch(() => false);
- if (!vendorExists) {
- throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
- }
+ if (!vendorExists) {
+ throw new Error(`Vendor icon directory not found: ${vendorRoot}`);
+ }
- const allFiles = await walk(vendorRoot);
- const dedupedBySlug = new Map();
+ const allFiles = await walk(vendorRoot);
+ const dedupedBySlug = new Map();
- for (const absoluteFilePath of allFiles) {
- const extension = path.extname(absoluteFilePath).toLowerCase();
- if (!allowedExtensions.has(extension)) {
- continue;
- }
+ for (const absoluteFilePath of allFiles) {
+ const extension = path.extname(absoluteFilePath).toLowerCase();
+ if (!allowedExtensions.has(extension)) {
+ continue;
+ }
- const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
- const basename = path.basename(relativePath, extension);
- const slug = toSlug(basename);
- if (!slug) {
- continue;
- }
+ const relativePath = toPosixPath(path.relative(vendorRoot, absoluteFilePath));
+ const basename = path.basename(relativePath, extension);
+ const slug = toSlug(basename);
+ if (!slug) {
+ continue;
+ }
- const key = `homarr:${slug}`;
- const candidate = {
- key,
- label: toLabel(slug),
- path: `/icons/vendor/homarr/${relativePath}`,
- source: 'homarr-dashboard-icons',
- extension,
- priority: extensionPriority[extension] ?? 99,
- relativePath
- };
+ const key = `homarr:${slug}`;
+ const candidate = {
+ key,
+ label: toLabel(slug),
+ path: `/icons/vendor/homarr/${relativePath}`,
+ source: 'homarr-dashboard-icons',
+ extension,
+ priority: extensionPriority[extension] ?? 99,
+ relativePath
+ };
- const existing = dedupedBySlug.get(slug);
- if (!existing) {
- dedupedBySlug.set(slug, candidate);
- continue;
- }
+ const existing = dedupedBySlug.get(slug);
+ if (!existing) {
+ dedupedBySlug.set(slug, candidate);
+ continue;
+ }
- if (candidate.priority < existing.priority) {
- dedupedBySlug.set(slug, candidate);
- continue;
- }
+ if (candidate.priority < existing.priority) {
+ dedupedBySlug.set(slug, candidate);
+ continue;
+ }
- if (candidate.priority === existing.priority && candidate.relativePath < existing.relativePath) {
- dedupedBySlug.set(slug, candidate);
- }
- }
+ if (
+ candidate.priority === existing.priority &&
+ candidate.relativePath < existing.relativePath
+ ) {
+ dedupedBySlug.set(slug, candidate);
+ }
+ }
- const records = Array.from(dedupedBySlug.values())
- .sort(compareIconRecords)
- .map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
+ const records = Array.from(dedupedBySlug.values())
+ .sort(compareIconRecords)
+ .map(({ key, label, path: iconPath, source }) => ({ key, label, path: iconPath, source }));
- const header = [
- '/* eslint-disable */',
- '// prettier-ignore',
- '// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
- '// Do not edit manually.',
- '',
- 'export interface VendorIconDefinition {',
- ' key: string;',
- ' label: string;',
- ' path: string;',
- " source: 'homarr-dashboard-icons';",
- '}',
- '',
- 'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
- ].join('\n');
+ const header = [
+ '/* eslint-disable */',
+ '// prettier-ignore',
+ '// Auto-generated by scripts/generate-vendor-icon-manifest.mjs',
+ '// Do not edit manually.',
+ '',
+ 'export interface VendorIconDefinition {',
+ ' key: string;',
+ ' label: string;',
+ ' path: string;',
+ " source: 'homarr-dashboard-icons';",
+ '}',
+ '',
+ 'export const VENDOR_ICON_DEFINITIONS: VendorIconDefinition[] = ['
+ ].join('\n');
- const body = records
- .map((record) => {
- const key = JSON.stringify(record.key);
- const label = JSON.stringify(record.label);
- const iconPath = JSON.stringify(record.path);
- return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
- })
- .join('\n');
+ const body = records
+ .map((record) => {
+ const key = JSON.stringify(record.key);
+ const label = JSON.stringify(record.label);
+ const iconPath = JSON.stringify(record.path);
+ return ` { key: ${key}, label: ${label}, path: ${iconPath}, source: 'homarr-dashboard-icons' },`;
+ })
+ .join('\n');
- const footer = ['];', ''].join('\n');
- const output = `${header}\n${body}\n${footer}`;
- const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
-
- await fs.mkdir(path.dirname(outputPath), { recursive: true });
- await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
- await fs.writeFile(outputPath, output, 'utf8');
+ const footer = ['];', ''].join('\n');
+ const output = `${header}\n${body}\n${footer}`;
+ const outputIgnoreEntry = toPosixPath(path.relative(projectRoot, outputPath));
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await ensureIgnoreEntry(prettierIgnorePath, outputIgnoreEntry);
+ await fs.writeFile(outputPath, output, 'utf8');
}
main().catch((error) => {
- const message = error instanceof Error ? error.stack ?? error.message : String(error);
- process.stderr.write(`${message}\n`);
- process.exitCode = 1;
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
+ process.stderr.write(`${message}\n`);
+ process.exitCode = 1;
});
diff --git a/src/app.css b/src/app.css
index a6f2d93..768fdfe 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,30 +1,99 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
+/* Design tokens from the UX review (new-design/project/UX Review.dc.html, panel 1b).
+ Principle: calm chrome, loud data. */
:root {
- --app-bg: #e2e8f0;
- --panel-bg: #f8fafc;
- --panel-border: #cbd5e1;
- --panel-contrast: #0f172a;
- --muted-text: #334155;
- --chip-bg: #e2e8f0;
- --chip-text: #0f172a;
- --modal-overlay: rgba(15, 23, 42, 0.58);
+ /* Core colours */
+ --bg-canvas: #eef0f3;
+ --surface: #ffffff;
+ --surface-2: #f2f4f7;
+ --border: #dce0e6;
+ --text: #1b212b;
+ --text-2: #616a78;
+ --accent: #3565c4;
+ --danger: #c03d38;
+
+ /* Graph canvas tokens (plain hex — read via getComputedStyle for Cytoscape) */
+ --graph-edge: #8b94a3;
+ --graph-node-border: #c6ccd4;
+ --graph-infra-fill: #3a4250;
+ --graph-infra-border: #2b323d;
+ --graph-dumb-border: #b3bac4;
+ --graph-edge-chip-bg: #ffffff;
+ --graph-edge-chip-text: #3a4250;
+
+ /* VLAN categorical set — equal lightness/chroma so no VLAN shouts louder */
+ --vlan-0: oklch(0.55 0.13 290);
+ --vlan-1: oklch(0.55 0.13 15);
+ --vlan-2: oklch(0.55 0.13 175);
+ --vlan-3: oklch(0.55 0.13 75);
+ --vlan-4: oklch(0.55 0.13 240);
+ --vlan-5: oklch(0.55 0.13 125);
+
+ /* Type */
+ --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+
+ /* Shape & elevation */
+ --radius-control: 6px;
+ --radius-panel: 10px;
+ --radius-modal: 14px;
+ --control-h: 32px;
+ --shadow-panel: 0 4px 16px rgb(0 0 0 / 0.08);
+ --shadow-popover: 0 8px 24px rgb(0 0 0 / 0.12);
+ --modal-overlay: rgb(15 18 24 / 0.45);
+
+ /* Status */
+ --status-ok: #2e7d4f;
+ --status-warn: #b0782f;
+
+ /* Legacy aliases (older selectors still reference these) */
+ --app-bg: var(--bg-canvas);
+ --panel-bg: var(--surface);
+ --panel-border: var(--border);
+ --panel-contrast: var(--text);
+ --muted-text: var(--text-2);
+ --chip-bg: var(--surface-2);
+ --chip-text: var(--text);
}
:root[data-theme='dark'] {
- --app-bg: #0b1220;
- --panel-bg: #111827;
- --panel-border: #334155;
- --panel-contrast: #e2e8f0;
- --muted-text: #94a3b8;
- --chip-bg: #1e293b;
- --chip-text: #e2e8f0;
- --modal-overlay: rgba(2, 6, 23, 0.78);
+ --bg-canvas: #14171c;
+ --surface: #1d222a;
+ --surface-2: #262d38;
+ --border: #363f4d;
+ --text: #e7ebf2;
+ --text-2: #9aa4b2;
+ --accent: #7da5e8;
+ --danger: #e0716c;
+
+ --graph-edge: #5b6575;
+ --graph-node-border: #363f4d;
+ --graph-infra-fill: #2c3440;
+ --graph-infra-border: #414c5d;
+ --graph-dumb-border: #414c5d;
+ --graph-edge-chip-bg: #262d38;
+ --graph-edge-chip-text: #c3cad4;
+
+ --vlan-0: oklch(0.68 0.13 290);
+ --vlan-1: oklch(0.68 0.13 15);
+ --vlan-2: oklch(0.68 0.13 175);
+ --vlan-3: oklch(0.68 0.13 75);
+ --vlan-4: oklch(0.68 0.13 240);
+ --vlan-5: oklch(0.68 0.13 125);
+
+ --shadow-panel: 0 4px 16px rgb(0 0 0 / 0.35);
+ --shadow-popover: 0 8px 24px rgb(0 0 0 / 0.45);
+ --modal-overlay: rgb(8 10 14 / 0.6);
+
+ --status-ok: #5cb884;
+ --status-warn: #f0b429;
}
html,
body {
background: var(--app-bg);
color: var(--panel-contrast);
+ font-family: var(--font-sans);
}
diff --git a/src/lib/actions/clickOutside.ts b/src/lib/actions/clickOutside.ts
new file mode 100644
index 0000000..a8d26b6
--- /dev/null
+++ b/src/lib/actions/clickOutside.ts
@@ -0,0 +1,20 @@
+// Svelte action: invoke the callback when a pointer goes down outside the
+// node. Listens in the capture phase so clicks swallowed by the Cytoscape
+// canvas still dismiss open popovers.
+export default function clickOutside(node: HTMLElement, onOutside: () => void) {
+ let callback = onOutside;
+ const handler = (event: PointerEvent) => {
+ if (!node.contains(event.target as Node)) {
+ callback();
+ }
+ };
+ document.addEventListener('pointerdown', handler, true);
+ return {
+ update(next: () => void) {
+ callback = next;
+ },
+ destroy() {
+ document.removeEventListener('pointerdown', handler, true);
+ }
+ };
+}
diff --git a/src/lib/components/AppBar.svelte b/src/lib/components/AppBar.svelte
new file mode 100644
index 0000000..fe21cab
--- /dev/null
+++ b/src/lib/components/AppBar.svelte
@@ -0,0 +1,815 @@
+
+
+
This is sample data — replace it with your own machines.
+ {/if} ++ None declared; subnets above are inferred as /24 from the IPs in use. +
+ {/each} +{selectedTargetIconKey}
- {selectedTargetIconKey}
- {icon.key}
- No icons match your search.
- {/if} -{addModalIconKey}
{addModalIconKey}
- {icon.key}
- No icons match your search.
- {/if}This action will remove the selected item and clean up related links. Continue?
- ++ This will remove the selected item and clean up related links. Continue? +
+- {layout.unrackedCount} - {layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted. -
+ {#if layout.unracked.length > 0} +