# Files changed · PR \#135 · flambo/atlantis

[View on GitCafe](https://git.cafe/flambo/atlantis/pull/135/diffs)

Repository: [flambo/atlantis](https://git.cafe/flambo/atlantis)

Visibility: public

[feat\(web\): redesign analysis as an Ops console](https://git.cafe/flambo/atlantis/pull/135.md)

Version: 0

Head commit: 1fe1fe79d88f28c0bc5b82c1a6c57f59beb2283d

Base commit: f75e6899d148b4f318c7c654876cf58cedb1450e

Comparison base: f75e6899d148b4f318c7c654876cf58cedb1450e

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/landing/src/styles/global.css

Status: modified; +3; −3

```diff
@@ -8,9 +8,9 @@
   --font-body: "IBM Plex Sans", system-ui, sans-serif;
 
   --color-brand-blue: #2563eb;
-  --color-dark-bg: #0a0a0a;
-  --color-dark-border: #2d2d2d;
-  --color-muted: #9ca3af;
+  --color-dark-bg: #101722;
+  --color-dark-border: #303d50;
+  --color-muted: #a3b2c7;
 }
 
 @layer base {
```

## apps/web/src/app.css

Status: modified; +582; −92

```diff
@@ -48,102 +48,104 @@
 }
 
 :root {
-	--chart-text-color: #374151;
-	--chart-grid-color: rgba(0, 0, 0, 0.08);
-	--chart-grid-highlight-color: rgba(0, 0, 0, 0.04);
+	--neutral-0: #ffffff;
+	--neutral-50: #f5f7fa;
+	--neutral-100: #edf1f6;
-	--chart-tooltip-bg: rgba(17, 24, 39, 0.9);
-	--chart-tooltip-text-color: #f9fafb;
-	--chart-tooltip-border-color: rgba(107, 114, 128, 0.7);
-	--chart-series-1: #2a78d6;
-	--chart-series-2: #eb6834;
+	--neutral-200: #dce3ec;
+	--neutral-300: #bac6d5;
+	--neutral-500: #627086;
+	--neutral-700: #334155;
+	--neutral-900: #182233;
+	--background: var(--neutral-50);
+	--foreground: var(--neutral-900);
+	--card: var(--neutral-0);
+	--card-foreground: var(--foreground);
+	--popover: var(--card);
+	--popover-foreground: var(--foreground);
-	--chart-series-3: #1baf7a;
-	--chart-series-4: #eda100;
-	--chart-series-5: #e87ba4;
-	--chart-series-6: #008300;
-	--chart-series-7: #4a3aa7;
-	--chart-series-8: #e34948;
-	--chart-series-other: #9ca3af;
-	--background: #ffffff;
-	--foreground: #111827;
-	--card: #ffffff;
-	--card-foreground: #111827;
-	--popover: #ffffff;
-	--popover-foreground: #111827;
 	--primary: #2563eb;
 	--primary-foreground: #ffffff;
+	--secondary: var(--neutral-100);
+	--secondary-foreground: var(--neutral-700);
+	--muted: var(--neutral-100);
+	--muted-foreground: #56657a;
+	--accent: #eaf0ff;
+	--accent-foreground: #1d4ed8;
+	--destructive: #bd303d;
+	--success: #087f6a;
+	--warning: #956500;
+	--info: #2563eb;
+	--border: var(--neutral-200);
+	--input: var(--neutral-300);
+	--ring: var(--primary);
+	--radius: 0.375rem;
+	--space-panel: 1rem;
+	--type-label: 0.75rem;
+	--type-body: 0.875rem;
+	--type-title: 1.5rem;
+	--elevation-popover: 0 6px 24px #18223318;
+	--chart-text-color: #56657a;
+	--chart-grid-color: #dce3ec;
+	--chart-grid-highlight-color: #edf1f6;
+	--chart-tooltip-bg: var(--popover);
+	--chart-tooltip-text-color: var(--foreground);
+	--chart-tooltip-border-color: var(--border);
+	--chart-series-1: #2563eb;
+	--chart-series-2: #a66d00;
+	--chart-series-3: #007f78;
+	--chart-series-4: #a85185;
+	--chart-series-5: #c45114;
+	--chart-series-6: #587aa5;
+	--chart-series-7: #7659b1;
+	--chart-series-8: #54616e;
+	--chart-series-other: #7d8999;
+	--chart-spectrum-low-hue: 270;
+	--chart-spectrum-high-hue: 60;
+	--chart-spectrum-saturation: 70;
+	--chart-spectrum-lightness: 50;
+	--chart-1: var(--chart-series-1);
+	--chart-2: var(--chart-series-2);
+	--chart-3: var(--chart-series-3);
+	--chart-4: var(--chart-series-4);
+	--chart-5: var(--chart-series-5);
+	--sidebar: var(--card);
+	--sidebar-foreground: var(--foreground);
+	--sidebar-primary: var(--primary);
+	--sidebar-primary-foreground: var(--primary-foreground);
+	--sidebar-accent: var(--accent);
+	--sidebar-accent-foreground: var(--accent-foreground);
+	--sidebar-border: var(--border);
-	--secondary: oklch(0.97 0 0);
-	--secondary-foreground: oklch(0.205 0 0);
-	--muted: #f3f4f6;
-	--muted-foreground: #6b7280;
-	--accent: #f3f4f6;
-	--accent-foreground: #111827;
-	--destructive: #dc2626;
-	--border: #e5e7eb;
-	--input: #e5e7eb;
-	--ring: #2563eb;
-	--chart-1: oklch(0.87 0 0);
-	--chart-2: oklch(0.556 0 0);
-	--chart-3: oklch(0.439 0 0);
-	--chart-4: oklch(0.371 0 0);
-	--chart-5: oklch(0.269 0 0);
-	--radius: 0.625rem;
-	--sidebar: oklch(0.985 0 0);
-	--sidebar-foreground: oklch(0.145 0 0);
-	--sidebar-primary: oklch(0.205 0 0);
-	--sidebar-primary-foreground: oklch(0.985 0 0);
-	--sidebar-accent: oklch(0.97 0 0);
-	--sidebar-accent-foreground: oklch(0.205 0 0);
-	--sidebar-border: oklch(0.922 0 0);
-	--sidebar-ring: oklch(0.708 0 0);
+	--sidebar-ring: var(--ring);
 }
-
 .dark {
-	--chart-text-color: #9ca3af;
-	--chart-grid-color: rgba(255, 255, 255, 0.08);
-	--chart-grid-highlight-color: rgba(255, 255, 255, 0.04);
-	--chart-tooltip-bg: rgba(3, 7, 18, 0.92);
-	--chart-tooltip-text-color: #f3f4f6;
-	--chart-tooltip-border-color: rgba(75, 85, 99, 0.85);
-	--chart-series-1: #3987e5;
-	--chart-series-2: #d95926;
-	--chart-series-3: #199e70;
-	--chart-series-4: #c98500;
-	--chart-series-5: #d55181;
-	--chart-series-6: #008300;
-	--chart-series-7: #9085e9;
-	--chart-series-8: #e66767;
-	--chart-series-other: #6b7280;
-	--background: #0a0a0a;
+	--neutral-0: #171f2c;
+	--neutral-50: #101722;
+	--neutral-100: #202b3b;
+	--neutral-200: #303d50;
+	--neutral-300: #45556c;
+	--neutral-500: #a3b2c7;
+	--neutral-700: #cfdae9;
+	--neutral-900: #eaf0f8;
-	--foreground: #fafafa;
-	--card: #111111;
-	--card-foreground: #fafafa;
-	--popover: #111111;
-	--popover-foreground: #fafafa;
-	--primary: #2563eb;
-	--primary-foreground: #ffffff;
-	--secondary: #1a1a1a;
-	--secondary-foreground: oklch(0.985 0 0);
-	--muted: #1a1a1a;
-	--muted-foreground: #a3a3a3;
-	--accent: #1a1a1a;
-	--accent-foreground: #fafafa;
+	--muted-foreground: #a3b2c7;
+	--accent: #203359;
+	--accent-foreground: #a8c5ff;
+	--destructive: #f18d96;
+	--success: #62c6b1;
+	--warning: #e8b951;
+	--chart-spectrum-lightness: 60;
+	--chart-text-color: #a3b2c7;
+	--chart-grid-color: #303d50;
+	--chart-grid-highlight-color: #202b3b;
+	--chart-series-1: #689bff;
+	--chart-series-2: #e5b44c;
+	--chart-series-3: #56c8bb;
+	--chart-series-4: #d992bf;
+	--chart-series-5: #ef9867;
+	--chart-series-6: #8badce;
+	--chart-series-7: #b3a0e3;
+	--chart-series-8: #b4bdc9;
+	--chart-series-other: #8a9aaf;
+	--elevation-popover: 0 6px 24px #00000040;
+	color-scheme: dark;
-	--destructive: #dc2626;
-	--border: #2d2d2d;
-	--input: #2d2d2d;
-	--ring: #2563eb;
-	--chart-1: oklch(0.87 0 0);
-	--chart-2: oklch(0.556 0 0);
-	--chart-3: oklch(0.439 0 0);
-	--chart-4: oklch(0.371 0 0);
-	--chart-5: oklch(0.269 0 0);
-	--sidebar: oklch(0.205 0 0);
-	--sidebar-foreground: oklch(0.985 0 0);
-	--sidebar-primary: oklch(0.488 0.243 264.376);
-	--sidebar-primary-foreground: oklch(0.985 0 0);
-	--sidebar-accent: oklch(0.269 0 0);
-	--sidebar-accent-foreground: oklch(0.985 0 0);
-	--sidebar-border: oklch(1 0 0 / 10%);
-	--sidebar-ring: oklch(0.556 0 0);
 }
 
 html {
@@ -171,3 +173,491 @@
 		@apply font-sans;
 	}
 }
+
+.page-layout,
+.shell-width {
+	width: 100%;
+	max-width: 1920px;
+	margin-inline: auto;
+	padding-inline: clamp(1rem, 2.5vw, 3rem);
+}
+.page-layout {
+	padding-block: 1.5rem;
+}
+.page-heading {
+	font-size: var(--type-title);
+	font-weight: 600;
+	letter-spacing: -0.035em;
+	line-height: 1.3;
+}
+.page-eyebrow {
+	color: var(--muted-foreground);
+	font-size: var(--type-label);
+	margin-bottom: 0.375rem;
+}
+body {
+	font-size: var(--type-body);
+	font-variant-numeric: tabular-nums;
+}
+:focus-visible {
+	outline: 2px solid var(--ring);
+	outline-offset: 3px;
+}
+::selection {
+	background: var(--accent);
+	color: var(--accent-foreground);
+}
+[data-slot='card'] {
+	box-shadow: none;
+}
+[data-slot='card-title'] {
+	line-height: 1.4;
+}
+[data-drag-handle] {
+	padding-block: 0.875rem;
+}
+[data-drag-handle] h2 {
+	font-size: 0.875rem;
+	font-weight: 600;
+	letter-spacing: -0.01em;
+	padding-right: 5rem;
+}
+.chart-frame {
+	background: var(--card);
+	border: 0;
+	border-radius: 0;
+	height: var(--chart-user-height, var(--chart-height, 320px));
+}
+.chart-frame > div:has(> .text-destructive),
+.chart-frame > .text-destructive {
+	padding: 1.5rem;
+	text-align: center;
+}
+[data-state='error'] .text-destructive {
+	overflow-wrap: anywhere;
+}
+.ts-chart-host {
+	font-variant-numeric: tabular-nums;
+}
+.ts-chart__interactive-legend button {
+	border: 0 !important;
+	border-radius: 3px !important;
+	background: transparent !important;
+	color: var(--chart-text-color) !important;
+	font:
+		500 12px/1.25 system-ui,
+		sans-serif !important;
+}
+.ts-chart__interactive-legend button:hover {
+	background: var(--muted) !important;
+}
+.ts-chart__interactive-legend button:focus-visible {
+	outline: 2px solid var(--ring);
+	outline-offset: -2px;
+}
+table {
+	font-variant-numeric: tabular-nums;
+}
+th {
+	color: var(--muted-foreground);
+	font-size: var(--type-label);
+	font-weight: 500;
+}
+@media (max-width: 639px) {
+	.page-layout {
+		padding-block: 1rem;
+	}
+	.chart-frame.traffic-frame {
+		--chart-height: 380px;
+	}
+	.file-filters > div {
+		width: 100%;
+		flex-direction: column;
+		align-items: stretch;
+	}
+}
+@media (prefers-reduced-motion: reduce) {
+	*,
+	*::before,
+	*::after {
+		animation-duration: 0.01ms !important;
+		transition-duration: 0.01ms !important;
+	}
+}
+
+.ts-chart__interactive-legend {
+	pointer-events: none !important;
+}
+.ts-chart__interactive-legend button {
+	pointer-events: auto !important;
+}
+
+.app-topbar .shell-width {
+	padding-inline: clamp(0.75rem, 1.5vw, 1.5rem);
+}
+.console-page {
+	width: 100%;
+	max-width: 1920px;
+	margin-inline: auto;
+	padding: 1rem clamp(0.75rem, 1.5vw, 1.5rem) 1.5rem;
+}
+.console-heading {
+	display: flex;
+	align-items: end;
+	justify-content: space-between;
+	gap: 1.5rem;
+	margin-bottom: 1rem;
+}
+.console-heading .page-heading {
+	font-size: 1.25rem;
+}
+.console-heading .dataset-tabs {
+	border-bottom: 0;
+}
+.console-body {
+	display: grid;
+	grid-template-columns: 12.5rem minmax(0, 1fr);
+	gap: 1rem;
+	align-items: start;
+}
+.console-body.rail-collapsed {
+	grid-template-columns: minmax(0, 1fr);
+}
+.console-rail {
+	position: sticky;
+	top: 1rem;
+	min-width: 0;
+	max-height: calc(100dvh - 7rem);
+	overflow-y: auto;
+	padding: 0.75rem;
+	background: var(--card);
+	border: 1px solid var(--border);
+	border-top: 2px solid var(--primary);
+}
+.console-toolbar {
+	position: sticky;
+	top: 0;
+	z-index: 20;
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 0.5rem;
+	margin-bottom: 1rem;
+	padding: 0.75rem;
+	background: var(--card);
+	border: 1px solid var(--border);
+}
+.console-toolbar .date-range-filter {
+	flex: 1 1 28rem;
+}
+.console-toolbar .rail-toggle,
+.console-toolbar .reset-view,
+.console-toolbar input[type='date'] {
+	height: 2.25rem;
+}
+.console-toolbar .date-range-filter {
+	align-items: center;
+	grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+	gap: 0.5rem;
+}
+.console-toolbar .date-range-filter > div {
+	display: flex;
+	align-items: center;
+	gap: 0.5rem;
+}
+.console-toolbar .date-range-filter label {
+	margin: 0;
+	flex-shrink: 0;
+}
+.console-toolbar .date-range-filter > span {
+	padding: 0;
+}
+.filter-summary {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 0.375rem;
+	flex-basis: 100%;
+	order: 4;
+	padding-top: 0.5rem;
+	border-top: 1px solid var(--border);
+	font-size: 0.6875rem;
+	color: var(--muted-foreground);
+}
+.filter-summary span {
+	white-space: nowrap;
+	padding: 0.125rem 0.5rem;
+	border: 1px solid var(--border);
+	border-radius: 0.25rem;
+	background: var(--muted);
+}
+.rail-heading {
+	font-size: 0.75rem;
+	font-weight: 600;
+	margin-bottom: 0.75rem;
+}
+.rail-section {
+	padding-block: 1rem;
+	border-bottom: 1px solid var(--border);
+}
+.rail-section:first-child {
+	padding-top: 0;
+}
+.primary-filters .router-filter {
+	display: block;
+}
+.primary-filters .router-filter > span {
+	display: block;
+	font-size: 0.75rem;
+	color: var(--muted-foreground);
+	margin-bottom: 0.5rem;
+}
+.primary-filters .router-filter > div {
+	flex-direction: column;
+	align-items: stretch;
+	gap: 0.25rem;
+}
+.primary-filters .router-filter label span {
+	overflow-wrap: anywhere;
+	min-width: 0;
+	font-size: 0.8125rem;
+}
+.primary-filters [role='group']:not([aria-label='MAAD options']) {
+	width: 100%;
+}
+.primary-filters [aria-label='Traffic direction'],
+.primary-filters [aria-label='Granularity'],
+.primary-filters [aria-label='MAAD measure'] {
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.primary-filters [aria-label='Granularity'] > :last-child,
+.primary-filters [aria-label='Traffic direction'] > :first-child,
+.primary-filters [aria-label='MAAD measure'] > :first-child {
+	grid-column: 1 / -1;
+}
+.primary-filters [role='group'] button,
+.primary-filters .router-filter label {
+	min-height: 2.75rem;
+}
+.primary-filters .router-filter label {
+	display: flex;
+	align-items: center;
+}
+
+@media (pointer: fine) {
+	.console-rail .primary-filters [role='group'] button {
+		height: 2rem;
+		min-height: 2rem;
+	}
+	.console-rail .primary-filters .router-filter label {
+		min-height: 2rem;
+	}
+	.console-rail .rail-section {
+		padding-block: 0.25rem;
+	}
+	.console-rail .rail-heading {
+		margin-bottom: 0.5rem;
+	}
+	.console-rail .rail-section:first-child > div {
+		margin-bottom: 0.25rem;
+	}
+	.console-rail .rail-section:first-child .rail-heading {
+		margin-bottom: 0;
+	}
+	.console-rail .rail-section:first-child {
+		padding-top: 0;
+	}
+}
+
+.card-actions {
+	position: absolute;
+	top: 0.25rem;
+	right: 0.5rem;
+	z-index: 10;
+	display: flex;
+	align-items: center;
+	gap: 0.125rem;
+}
+.card-actions button {
+	height: 2.25rem;
+	width: 2.25rem;
+}
+.chart-panels [data-drag-handle] {
+	min-height: 2.75rem;
+	padding-right: 10.5rem;
+}
+.chart-panels [data-drag-handle] h2 {
+	padding-right: 0;
+}
+.chart-panels > section > [data-slot='card'] {
+	border-radius: 0;
+}
+.chart-panels [data-drag-handle] {
+	background: var(--card);
+	padding-block: 0.75rem;
+}
+.chart-panels [data-slot='card-content'] {
+	padding: 0;
+}
+.chart-panels .chart-frame {
+	padding: 0.75rem;
+}
+.chart-panels .metric-selector {
+	padding: 0.5rem 0.75rem;
+	border-bottom: 1px solid var(--border);
+	background: var(--muted);
+}
+.metric-toolbar > [role='group'] {
+	width: auto;
+	margin-left: auto;
+}
+.metric-quickselect [role='group'] {
+	width: auto;
+}
+.metric-toolbar [role='group'] {
+	background: var(--card);
+}
+.chart-panels .traffic-frame {
+	--chart-height: 520px;
+}
+.chart-panels > [data-chart-id='dashboard'][data-chart-activated='false'] {
+	min-height: 740px !important;
+}
+.chart-panels [data-slot='card-content'] > [role='group'],
+.chart-panels [data-slot='card-content'] > .flex,
+.chart-panels [data-slot='card-content'] > .grid {
+	padding: 0.75rem;
+	margin: 0;
+	border-bottom: 1px solid var(--border);
+}
+.control-popover {
+	max-height: calc(100dvh - 2rem);
+	overflow-y: auto;
+}
+.file-summary {
+	padding: 1rem;
+}
+.file-router-card {
+	display: grid;
+	grid-template-columns: minmax(16rem, 0.8fr) minmax(0, 2fr);
+}
+.file-router-card > .file-summary {
+	border-right: 1px solid var(--border);
+	border-bottom: 0;
+}
+.file-router-card .file-metrics {
+	grid-template-columns: minmax(0, 1fr);
+}
+@media (min-width: 1600px) {
+	.file-router-card .file-metrics {
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+	}
+}
+.catalog {
+	border: 1px solid var(--border);
+	background: var(--card);
+}
+.catalog-row {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+	align-items: center;
+	gap: 1rem;
+	padding: 1rem;
+	border-bottom: 1px solid var(--border);
+}
+.catalog-row:last-child {
+	border-bottom: 0;
+}
+.catalog-row:hover {
+	background: var(--accent);
+}
+.catalog-header {
+	color: var(--muted-foreground);
+	font-size: 0.75rem;
+	padding-block: 0.625rem;
+	background: var(--muted);
+}
+.file-context p {
+	margin-bottom: 0.5rem;
+	overflow-wrap: anywhere;
+}
+@media (max-width: 1279px) {
+	.file-router-card {
+		grid-template-columns: minmax(0, 1fr);
+	}
+	.file-router-card > .file-summary {
+		border-right: 0;
+		border-bottom: 1px solid var(--border);
+	}
+}
+@media (max-width: 1023px) {
+	.console-body {
+		grid-template-columns: minmax(0, 1fr);
+	}
+	.console-heading {
+		align-items: start;
+	}
+	.console-toolbar .date-range-filter {
+		flex: 1 1 28rem;
+	}
+	.filter-sheet .primary-filters {
+		width: 100%;
+	}
+}
+@media (max-width: 639px) {
+	.console-heading {
+		flex-direction: column;
+		gap: 0.25rem;
+	}
+	.console-heading .dataset-tabs {
+		width: 100%;
+	}
+	.console-toolbar {
+		gap: 0.5rem;
+		padding: 0.625rem;
+	}
+	.console-toolbar .date-range-filter {
+		order: 3;
+		flex-basis: 100%;
+	}
+	.console-toolbar .date-range-filter > div {
+		display: block;
+	}
+	.console-toolbar .date-range-filter label {
+		margin-bottom: 0.25rem;
+	}
+	.console-toolbar .date-range-filter {
+		align-items: end;
+	}
+	.console-toolbar .date-range-filter > span {
+		padding-bottom: 0.5rem;
+	}
+	.filter-summary {
+		flex-basis: 100%;
+		order: 4;
+	}
+	.metric-toolbar > [role='group'] {
+		margin-left: 0;
+	}
+	.metric-quickselect {
+		width: 100%;
+	}
+	.metric-quickselect [role='group'] {
+		width: 100%;
+	}
+	.chart-panels .traffic-frame {
+		--chart-height: 400px;
+	}
+	.chart-panels > [data-chart-id='dashboard'][data-chart-activated='false'] {
+		min-height: 560px !important;
+	}
+	.catalog-row {
+		grid-template-columns: minmax(0, 1fr) auto;
+	}
+	.catalog-row .catalog-mode {
+		grid-column: 1;
+		grid-row: 2;
+	}
+	.catalog-header {
+		display: none;
+	}
+}
```

## apps/web/src/lib/components/charts/BreakdownChart.svelte

Status: modified; +12; −3

```diff
@@ -23,6 +23,8 @@
 	import { Skeleton } from '#lib/components/ui/skeleton/index.ts';
 	import SegmentedControl from '#lib/components/common/SegmentedControl.svelte';
 	import ChartCard from './ChartCard.svelte';
+	import { getSourceLineDash } from './flow-characteristics';
+	import { sourceSeriesColor, categoricalSeriesColor } from './chart-colors';
 	import ChartPlot from './ChartPlot.svelte';
 	import { plotBounds, finitePoint, type PlotSeries, type PlotOptions } from './chart-registry';
 	import { finiteSpectrumPoints, paddedSpectrumBounds } from './spectrum-points';
@@ -235,7 +237,7 @@
 			return [
 				{
 					label: 'Spectrum',
-					color: '#36A2EB',
+					color: 'var(--chart-series-1)',
 					data: buckets.flatMap((bucket) =>
 						pointsForBucket(bucket).map((point) => ({
 							x: bucket.bucketStart,
@@ -266,8 +268,15 @@
 					return {
 						label: config.seriesByRouter ? router : `${router} · ${metric.seriesLabel}`,
 						color: config.seriesByRouter
+							? categoricalSeriesColor(slot > 0 ? slot - 1 : routerIndex)
+							: sourceSeriesColor(
+									color ?? 'var(--chart-series-1)',
+									slot > 0 ? slot - 1 : routerIndex,
+									config.routerHueStep
+								),
+						dash: config.seriesByRouter
+							? undefined
-							? `var(--chart-series-${slot > 0 && slot <= 8 ? slot : 'other'})`
+							: getSourceLineDash(routerIndex, routers.length > 1).join(' '),
-							: `hsl(${((color?.hue ?? 0) + routerIndex * config.routerHueStep) % 360}, ${color?.saturation ?? 70}%, ${color?.lightness ?? 50}%)`,
 						data: bucketStarts.map((start) => {
 							const bucket = records.get(`${router}-${start}`);
 							return {
```

## apps/web/src/lib/components/charts/ChartCard.svelte

Status: modified; +5; −6

```diff
@@ -1,4 +1,5 @@
 <script lang="ts">
+	import ChartLoading from '#lib/components/charts/ChartLoading.svelte';
 	import DragGrip from '#lib/components/common/DragGrip.svelte';
 	import * as Card from '#lib/components/ui/card/index.ts';
 	import type { Snippet } from 'svelte';
@@ -79,10 +80,10 @@
 
 			<div
 				class={size === 'spectrum'
+					? 'chart-frame relative h-[400px] min-h-[300px] resize-y overflow-hidden rounded-md border [--chart-height:400px]'
-					? 'border-border bg-background/60 relative h-[400px] min-h-[300px] resize-y overflow-hidden rounded-md border'
 					: size === 'split'
+						? 'chart-frame relative h-[640px] min-h-[520px] resize-y overflow-hidden rounded-md border [--chart-height:640px] xl:h-[320px] xl:min-h-[240px] xl:[--chart-height:320px]'
+						: 'chart-frame relative h-[320px] min-h-[240px] resize-y overflow-hidden rounded-md border'}
-						? 'border-border bg-background/60 relative h-[640px] min-h-[520px] resize-y overflow-hidden rounded-md border xl:h-[320px] xl:min-h-[240px]'
-						: 'border-border bg-background/60 relative h-[320px] min-h-[240px] resize-y overflow-hidden rounded-md border'}
 				role="presentation"
 			>
 				{#if selectionUnavailableCopy}
@@ -93,9 +94,7 @@
 						{selectionUnavailableCopy}
 					</div>
 				{:else if loading}
+					<ChartLoading label={loadingCopy} />
-					<div class="text-muted-foreground flex h-full items-center justify-center">
-						{loadingCopy}
-					</div>
 				{:else if error}
 					<div class="text-destructive flex h-full items-center justify-center">{error}</div>
 				{:else if noMetrics}
```

## apps/web/src/lib/components/charts/ChartContainer.svelte

Status: modified; +2; −16

```diff
@@ -35,27 +35,13 @@
 		onDrillDown?: (groupBy: GroupByOption, startDate: string, endDate: string) => void;
 		onNavigateToFile?: (slug: string) => void;
 	} = $props();
-	const colors = [
-		'rgb(75, 192, 192)',
-		'rgb(255, 99, 132)',
-		'rgb(54, 162, 235)',
-		'rgb(255, 206, 86)',
-		'rgb(153, 102, 255)',
-		'rgb(255, 159, 64)',
-		'rgb(255, 99, 71)',
-		'rgb(0, 206, 209)',
-		'rgb(60, 179, 113)',
-		'rgb(218, 112, 214)',
-		'rgb(255, 215, 0)',
-		'rgb(128, 0, 128)'
-	];
 	const labels = $derived(formatLabels(results, groupBy));
 	const series = $derived<PlotSeries[]>(
 		dataOptions
 			.filter((option) => option.checked)
-			.map((option, index) => ({
+			.map((option) => ({
 				label: option.label,
-				color: colors[index % colors.length],
+				color: `var(--chart-series-${(dataOptions.findIndex((item) => item.index === option.index) % 8) + 1})`,
 				data: results.map((result, index) => ({
 					x: result.bucketStart,
 					y: result.data?.[NETFLOW_DATA_OPTION_FIELDS[option.index]] ?? null,
```

## apps/web/src/lib/components/charts/ChartLoading.svelte

Status: added; +14; −0

```diff
@@ -1,0 +1,14 @@
+<script lang="ts">
+	let { label }: { label: string } = $props();
+</script>
+
+<div class="relative flex h-full w-full items-center justify-center overflow-hidden" role="status">
+	<div
+		class="pointer-events-none absolute inset-6 flex flex-col justify-between opacity-60"
+		aria-hidden="true"
+	>
+		{#each [0, 1, 2, 3] as line (line)}<div class="border-border w-full border-t"></div>{/each}
+		<div class="bg-muted absolute bottom-0 h-1/3 w-full animate-pulse rounded-sm"></div>
+	</div>
+	<span class="bg-card text-muted-foreground relative rounded px-3 py-2 text-sm">{label}</span>
+</div>
```

## apps/web/src/lib/components/charts/ChartPlot.svelte

Status: modified; +42; −3

```diff
@@ -13,6 +13,8 @@
 		type PlotSeries
 	} from './chart-registry';
 	import { paintLegendSwatch } from './legend-swatch';
+	import { theme } from '#lib/stores/theme.svelte.ts';
+	import { readSpectrumColors } from './chart-colors';
 	import { paintSpectrumCloud } from './spectrum-cloud';
 	import { renderCoverageSvg } from './coverage-marks';
 	import { plotObservations, positionedScenePoints } from './chart-observations';
@@ -60,7 +62,16 @@
 	let observations: ReturnType<typeof plotObservations> | null = null;
 	const definition = $derived.by(() => {
 		const currentSeries = series;
+		const currentOptions =
+			options.colorDomain && typeof document !== 'undefined'
+				? {
+						...options,
+						spectrumColors: readSpectrumColors(
+							getComputedStyle(document.documentElement),
+							theme.dark
+						)
+					}
+				: options;
-		const currentOptions = options;
 		const currentHidden = new Set(hidden);
 		const formatter = formatTooltip;
 		const normal = buildChartDefinition(
@@ -101,16 +112,32 @@
 			onSelect: select
 		});
 		let host: ReturnType<typeof mountChart<PlotPoint, number, number>> | null = null;
+		let size: { width: number; height: number } | null = null;
+		let currentOptions: ReturnType<typeof hostOptions>;
+		const render = () => {
+			if (!size) return;
+			const current = { ...currentOptions, ...size };
+			if (host) host.update(current);
+			else host = mountChart(node, current);
+		};
 		$effect(() => {
 			const current = hostOptions();
 			const currentObservations = observationData;
 			untrack(() => {
 				observations = currentObservations;
+				currentOptions = current;
+				render();
-				if (host) host.update(current);
-				else host = mountChart(node, current);
 			});
 		});
+		const observer = new ResizeObserver(([entry]) => {
+			const { width, height } = entry.contentRect;
+			if (width <= 0 || height <= 0 || (size?.width === width && size.height === height)) return;
+			size = { width, height };
+			render();
+		});
+		observer.observe(node);
 		return () => {
+			observer.disconnect();
 			cleanupRender();
 			contract?.destroy();
 			host?.destroy();
@@ -175,9 +202,21 @@
 				const button = buttons.find((button) => button.dataset.chartLegendValue === item.label);
 				const box = button?.getBoundingClientRect();
 				const target = points[0];
+				const mark = next.svg.querySelector<SVGGraphicsElement>(
+					`path[data-ts-key^="series-${index}-"], circle[data-ts-key^="series-${index}-"], circle[data-ts-key^="series-${index}:"]`
+				);
+				const appearance = mark ? getComputedStyle(mark) : null;
+				const stroked = appearance?.stroke !== 'none';
+				const color = appearance ? (stroked ? appearance.stroke : appearance.fill) : target?.color;
+				const opacity = appearance
+					? Number(appearance.opacity) *
+						Number(stroked ? appearance.strokeOpacity : appearance.fillOpacity)
+					: 1;
 				return {
 					label: item.label,
 					visible,
+					color,
+					opacity: visible ? opacity : 0,
 					count: finite.length,
 					min: finite.length ? finite.reduce((min, value) => Math.min(min, value), Infinity) : null,
 					max: finite.length
```

## apps/web/src/lib/components/charts/CoverageStrip.svelte

Status: modified; +1; −1

```diff
@@ -89,7 +89,7 @@
 	const series = $derived<PlotSeries[]>(
 		visibleTimelines.map((timeline, index) => ({
 			label: timeline.sourceId,
-			color: 'rgb(16,185,129)',
+			color: 'var(--success)',
 			data: timeline.buckets.map((bucket) => ({
 				x: bucket.bucketStart,
 				x2: bucket.bucketEnd,
```

## apps/web/src/lib/components/charts/FlowCharacteristicsChart.svelte

Status: modified; +3; −3

```diff
@@ -57,7 +57,7 @@
 				observationFamily,
 				'averageDurationMs'
 			),
-			color: '#2563eb',
+			color: 'var(--chart-series-1)',
 			coverage: observationCoverage
 		}
 	]);
@@ -70,7 +70,7 @@
 				observationFamily,
 				'averageMinTtl'
 			),
-			color: '#7c3aed',
+			color: 'var(--chart-series-7)',
 			coverage: observationCoverage
 		},
 		{
@@ -81,7 +81,7 @@
 				observationFamily,
 				'averageMaxTtl'
 			),
+			color: 'var(--chart-series-4)',
-			color: '#db2777',
 			coverage: observationCoverage
 		}
 	]);
```

## apps/web/src/lib/components/charts/PortCardinalityChart.svelte

Status: modified; +6; −5

```diff
@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { SvelteSet } from 'svelte/reactivity';
 	import { Checkbox } from '#lib/components/ui/checkbox/index.ts';
+	import { sourceSeriesColor } from './chart-colors';
 	import ChartCard from './ChartCard.svelte';
 	import MetricLinePanel, { type MetricLineSeries } from './MetricLinePanel.svelte';
 	import {
@@ -36,10 +37,10 @@
 		'5min': '5m'
 	};
 	const PORT_COLORS: Record<`${PortSide}-${PortRange}`, string> = {
+		'source-low': 'var(--chart-series-1)',
-		'source-low': '#2563eb',
+		'source-high': 'var(--chart-series-3)',
+		'destination-low': 'var(--chart-series-2)',
+		'destination-high': 'var(--chart-series-4)'
-		'source-high': '#0891b2',
-		'destination-low': '#d97706',
-		'destination-high': '#dc2626'
 	};
 	const PORT_OPTIONS: Array<{ side: PortSide; range: PortRange; label: string }> = [
 		{ side: 'source', range: 'low', label: 'Source ports 0-1023' },
@@ -62,7 +63,7 @@
 					return {
 						label: multipleSources ? `${sourceId} · ${label}` : label,
 						values: portValuesByStart(timeline, portStarts, portFamily, side, range),
-						color: PORT_COLORS[`${side}-${range}`],
+						color: sourceSeriesColor(PORT_COLORS[`${side}-${range}`], sourceIndex),
 						dash: getSourceLineDash(sourceIndex, multipleSources),
 						coverage: portStarts.map(
 							(start) =>
```

## apps/web/src/lib/components/charts/SpectrumChart.svelte

Status: modified; +3; −3

```diff
@@ -16,9 +16,9 @@
 	const series = $derived([
 		{
 			label: 'f(alpha)',
-			color: 'rgb(147, 51, 234)',
+			color: 'var(--chart-series-7)',
 			strokeWidth: 2,
-			pointFill: 'rgb(147, 51, 234)',
+			pointFill: 'var(--chart-series-7)',
 			pointStroke: 'var(--card)',
 			pointStrokeWidth: 1,
 			radius: 3,
@@ -50,7 +50,7 @@
 				zero: false,
 				annotations: [
 					{
+						color: 'var(--chart-text-color)',
-						color: 'rgb(128,128,128)',
 						opacity: 0.5,
 						strokeWidth: 1,
 						dash: '5 5',
```

## apps/web/src/lib/components/charts/StructureFunctionChart.svelte

Status: modified; +4; −4

```diff
@@ -9,7 +9,7 @@
 	const series = $derived([
 		{
 			label: 'tau(q)',
-			color: 'rgb(59,130,246)',
+			color: 'var(--chart-series-1)',
 			strokeWidth: 2,
 			data: points.map((point) => ({ x: point.q, y: point.tau, sd: point.sd }))
 		}
@@ -19,7 +19,7 @@
 			.filter((point) => Number.isFinite(point.sd))
 			.flatMap((point) => [
 				{
-					color: 'rgb(128,128,128)',
+					color: 'var(--chart-text-color)',
 					opacity: 0.7,
 					data: [
 						{ x: point.q, y: point.tau - point.sd },
@@ -27,7 +27,7 @@
 					]
 				},
 				{
-					color: 'rgb(128,128,128)',
+					color: 'var(--chart-text-color)',
 					opacity: 0.7,
 					data: [
 						{ x: point.q - 0.02, y: point.tau - point.sd },
@@ -35,7 +35,7 @@
 					]
 				},
 				{
-					color: 'rgb(128,128,128)',
+					color: 'var(--chart-text-color)',
 					opacity: 0.7,
 					data: [
 						{ x: point.q - 0.02, y: point.tau + point.sd },
```

## apps/web/src/lib/components/charts/breakdown-chart-config.ts

Status: modified; +13; −17

```diff
@@ -16,11 +16,7 @@
 	key: BreakdownMetricKey;
 	label: string;
 	seriesLabel: string;
-	color?: {
-		hue: number;
-		saturation: number;
-		lightness: number;
-	};
+	color?: string;
 }
 
 export interface BreakdownChartConfig {
@@ -30,10 +26,10 @@
 	endpoint: string;
 	usesMaad: boolean;
 	seriesByRouter: boolean;
+	routerHueStep: number;
 	defaultGranularity: IpGranularity;
 	defaultMetrics: BreakdownMetricKey[];
 	metrics: LineMetricConfig[];
-	routerHueStep: number;
 	fillAlpha: number;
 	yAxisTitle: string;
 	formatYAxisTicks: boolean;
@@ -54,11 +50,11 @@
 	daIpv6Count: 'Dst IPv6'
 };
 
-const ipColors: Record<IpMetricKey, { hue: number; saturation: number; lightness: number }> = {
-	saIpv4Count: { hue: 130, saturation: 65, lightness: 58 },
+const ipColors: Record<IpMetricKey, string> = {
+	saIpv4Count: 'var(--chart-series-1)',
-	daIpv4Count: { hue: 130, saturation: 65, lightness: 38 },
-	saIpv6Count: { hue: 25, saturation: 72, lightness: 60 },
-	daIpv6Count: { hue: 25, saturation: 72, lightness: 40 }
+	daIpv4Count: 'var(--chart-series-2)',
+	saIpv6Count: 'var(--chart-series-3)',
+	daIpv6Count: 'var(--chart-series-4)'
 };
 
 const IP_CONFIG: BreakdownChartConfig = {
@@ -68,6 +64,7 @@
 	endpoint: '/api/ip/stats',
 	usesMaad: false,
 	seriesByRouter: false,
+	routerHueStep: 70,
 	defaultGranularity: '1d',
 	defaultMetrics: ['saIpv4Count', 'daIpv4Count'],
 	metrics: IP_METRIC_OPTIONS.map((option) => ({
@@ -76,7 +73,6 @@
 		seriesLabel: ipMetricLabels[option.key],
 		color: ipColors[option.key]
 	})),
-	routerHueStep: 70,
 	fillAlpha: 0.18,
 	yAxisTitle: 'Unique IPs',
 	formatYAxisTicks: true,
@@ -97,6 +93,7 @@
 	endpoint: '/api/protocol/stats',
 	usesMaad: false,
 	seriesByRouter: false,
+	routerHueStep: 110,
 	defaultGranularity: '1h',
 	defaultMetrics: ['uniqueProtocolsIpv4', 'uniqueProtocolsIpv6'],
 	metrics: [
@@ -104,16 +101,15 @@
 			key: 'uniqueProtocolsIpv4',
 			label: 'Unique Protocols IPv4',
 			seriesLabel: 'IPv4',
-			color: { hue: 210, saturation: 70, lightness: 50 }
+			color: 'var(--chart-series-1)'
 		},
 		{
 			key: 'uniqueProtocolsIpv6',
 			label: 'Unique Protocols IPv6',
 			seriesLabel: 'IPv6',
-			color: { hue: 35, saturation: 75, lightness: 48 }
+			color: 'var(--chart-series-2)'
 		}
 	],
-	routerHueStep: 110,
 	fillAlpha: 0.2,
 	yAxisTitle: 'Unique Protocols',
 	formatYAxisTicks: false,
@@ -163,6 +159,7 @@
 	endpoint: '/api/netflow/dimension-stats',
 	usesMaad: true,
 	seriesByRouter: true,
+	routerHueStep: 0,
 	defaultGranularity: '1h',
 	defaultMetrics: ['saD1'],
 	metrics: DIMENSION_SIDE_OPTIONS.flatMap((side) =>
@@ -175,7 +172,6 @@
 			};
 		})
 	),
-	routerHueStep: 0,
 	fillAlpha: 0,
 	yAxisTitle: 'Dimension',
 	formatYAxisTicks: false,
@@ -196,10 +192,10 @@
 	endpoint: '/api/netflow/spectrum-stats',
 	usesMaad: true,
 	seriesByRouter: false,
+	routerHueStep: 0,
 	defaultGranularity: '1h',
 	defaultMetrics: [],
 	metrics: [],
-	routerHueStep: 0,
 	fillAlpha: 0,
 	yAxisTitle: 'alpha',
 	formatYAxisTicks: false,
```

## apps/web/src/lib/components/charts/chart-colors.ts

Status: added; +39; −0

```diff
@@ -1,0 +1,39 @@
+export type SpectrumColors = {
+	lowHue: number;
+	highHue: number;
+	saturation: number;
+	lightness: number;
+};
+
+export function spectrumInterpolator(
+	colors: SpectrumColors = {
+		lowHue: 270,
+		highHue: 60,
+		saturation: 70,
+		lightness: 50
+	}
+) {
+	return (position: number) => {
+		const value = Math.max(0, Math.min(1, position));
+		return `hsl(${colors.lowHue + value * (colors.highHue - colors.lowHue)}, ${colors.saturation}%, ${colors.lightness}%)`;
+	};
+}
+
+export function readSpectrumColors(style: CSSStyleDeclaration, dark: boolean): SpectrumColors {
+	return {
+		lowHue: Number(style.getPropertyValue('--chart-spectrum-low-hue') || 270),
+		highHue: Number(style.getPropertyValue('--chart-spectrum-high-hue') || 60),
+		saturation: Number(style.getPropertyValue('--chart-spectrum-saturation') || 70),
+		lightness: Number(style.getPropertyValue('--chart-spectrum-lightness') || (dark ? 60 : 50))
+	};
+}
+
+export function sourceSeriesColor(color: string, sourceIndex: number, hueStep = 80): string {
+	return sourceIndex === 0
+		? color
+		: `hsl(from ${color} calc(h + ${(sourceIndex * hueStep) % 360}) s l)`;
+}
+
+export function categoricalSeriesColor(index: number): string {
+	return sourceSeriesColor(`var(--chart-series-${(index % 8) + 1})`, Math.floor(index / 8), 20);
+}
```

## apps/web/src/lib/components/charts/chart-contract.ts

Status: modified; +4; −0

```diff
@@ -1,6 +1,8 @@
 export type RenderedSeries = {
 	label: string;
 	visible: boolean;
+	color?: string;
+	opacity?: number;
 	count: number;
 	min: number | null;
 	max: number | null;
@@ -60,6 +62,8 @@
 				const entry = document.createElement('span');
 				entry.dataset.testid = 'chart-series';
 				entry.dataset.visible = String(series.visible);
+				if (series.color) entry.dataset.color = series.color;
+				if (series.opacity !== undefined) entry.dataset.opacity = String(series.opacity);
 				entry.dataset.count = String(series.count);
 				entry.dataset.min = String(series.min);
 				entry.dataset.max = String(series.max);
```

## apps/web/src/lib/components/charts/chart-registry.ts

Status: modified; +7; −5

```diff
@@ -14,6 +14,7 @@
 import type { ChartMark, ChartPoint } from '@tanstack/charts';
 import { temporalTicks } from './temporal-ticks';
 import { temporalGrid } from './temporal-grid';
+import { spectrumInterpolator, type SpectrumColors } from './chart-colors';
 import { spectrumCloud } from './spectrum-cloud';
 import { coverageLineRuns } from './coverage-line-style';
 import type { ChartCoverage } from './chart-utils';
@@ -123,6 +124,7 @@
 	compact?: boolean;
 	annotations?: PlotAnnotation[];
 	colorDomain?: readonly [number, number];
+	spectrumColors?: SpectrumColors;
 };
 
 export function finitePoint(point: PlotPoint): boolean {
@@ -217,7 +219,7 @@
 						y1: (point) => (point.y ?? 0) - 0.055,
 						y2: (point) => (point.y ?? 0) + 0.055,
 						inset: 0,
-						fill: state === 'complete' ? 'rgb(16,185,129)' : 'transparent'
+						fill: state === 'complete' ? 'var(--success)' : 'transparent'
 					})
 				);
 				if (state === 'partial')
@@ -228,7 +230,7 @@
 									id: `${id}-coverage-dash-${position}`,
 									x: 'x',
 									y: 'y',
+									stroke: 'var(--warning)',
-									stroke: 'rgb(245,158,11)',
 									strokeWidth: 2,
 									strokeDasharray: '5 4'
 								})
@@ -419,9 +421,9 @@
 		},
 		color: options.colorDomain
 			? {
-					scale: scaleSequential((value: number) => `hsl(${270 - value * 210}, 70%, 50%)`).domain(
-						options.colorDomain
-					)
+					scale: scaleSequential(spectrumInterpolator(options.spectrumColors))
+						.clamp(true)
+						.domain(options.colorDomain)
 				}
 			: {
 					domain: labels,
```

## apps/web/src/lib/components/charts/chart-utils.ts

Status: modified; +4; −22

```diff
@@ -121,28 +121,10 @@
 }
 
 export function generateColors(count: number): string[] {
-	const colors = [
-		'#FF6384',
-		'#36A2EB',
-		'#FFCE56',
-		'#4BC0C0',
-		'#9966FF',
-		'#FF9F40',
-		'#FF6384',
-		'#C9CBCF',
-		'#4BC0C0',
-		'#FF6384',
-		'#36A2EB',
-		'#FFCE56',
+	const colors = Array.from(
+		{ length: count },
+		(_, index) => `var(--chart-series-${(index % 8) + 1})`
+	);
-		'#4BC0C0',
-		'#9966FF',
-		'#FF9F40',
-		'#FF6384',
-		'#C9CBCF',
-		'#4BC0C0',
-		'#FF6384',
-		'#36A2EB'
-	];
 	return colors.slice(0, count);
 }
 
```

## apps/web/src/lib/components/common/AnalysisLayout.svelte

Status: added; +102; −0

```diff
@@ -1,0 +1,102 @@
+<script lang="ts">
+	import { MediaQuery } from 'svelte/reactivity';
+	import { PanelLeftClose, PanelLeftOpen, SlidersHorizontal } from '@lucide/svelte';
+	import { Button } from '#lib/components/ui/button/index.ts';
+	import type { Snippet } from 'svelte';
+
+	let {
+		title,
+		eyebrow,
+		railLabel = 'Filters',
+		rail,
+		navigation,
+		toolbar,
+		children
+	}: {
+		title: string;
+		eyebrow: string;
+		railLabel?: string;
+		rail: Snippet;
+		navigation?: Snippet;
+		toolbar?: Snippet;
+		children: Snippet;
+	} = $props();
+	const id = $props.id();
+	const desktop = new MediaQuery('(min-width: 1024px)', true);
+	let collapsed = $state(false);
+	let sheetOpen = $state(false);
+	let sheetModule = $state<Promise<typeof import('./FilterSheet.svelte')> | null>(null);
+
+	function openSheet() {
+		sheetModule ??= import('./FilterSheet.svelte');
+		sheetOpen = true;
+	}
+</script>
+
+<svelte:window
+	onkeydown={(event) => {
+		if (sheetOpen && event.key === 'Escape' && !event.defaultPrevented) sheetOpen = false;
+	}}
+	onresize={() => {
+		if (desktop.current) sheetOpen = false;
+	}}
+/>
+
+<div class="console-page">
+	<div class="console-heading">
+		<div class="min-w-0">
+			<p class="page-eyebrow">{eyebrow}</p>
+			<h1 class="page-heading break-words">{title}</h1>
+		</div>
+		{@render navigation?.()}
+	</div>
+	<div class="console-body" class:rail-collapsed={collapsed}>
+		{#if desktop.current && !collapsed}
+			<aside class="console-rail" aria-label={railLabel}>
+				{@render rail()}
+			</aside>
+		{/if}
+		<div class="console-workarea min-w-0">
+			<div class="console-toolbar">
+				{#if desktop.current}
+					<Button
+						variant="outline"
+						size="icon"
+						class="rail-toggle"
+						aria-label={collapsed
+							? `Show ${railLabel.toLowerCase()}`
+							: `Hide ${railLabel.toLowerCase()}`}
+						aria-expanded={!collapsed}
+						onclick={() => (collapsed = !collapsed)}
+					>
+						{#if collapsed}<PanelLeftOpen size={17} />{:else}<PanelLeftClose size={17} />{/if}
+					</Button>
+				{:else}
+					<Button
+						variant="outline"
+						size="sm"
+						class="rail-toggle"
+						aria-label={`Open ${railLabel.toLowerCase()}`}
+						aria-haspopup="dialog"
+						data-filter-trigger={id}
+						aria-expanded={sheetOpen}
+						onclick={openSheet}><SlidersHorizontal size={15} />{railLabel}</Button
+					>
+				{/if}
+				{@render toolbar?.()}
+			</div>
+			<div class="console-results">{@render children()}</div>
+		</div>
+	</div>
+</div>
+
+{#if !desktop.current && sheetModule}
+	{#await sheetModule then { default: FilterSheet }}
+		<FilterSheet
+			bind:open={sheetOpen}
+			title={railLabel}
+			onClose={() => document.querySelector<HTMLElement>(`[data-filter-trigger="${id}"]`)?.focus()}
+			>{@render rail()}</FilterSheet
+		>
+	{/await}
+{/if}
```

## apps/web/src/lib/components/common/CardLayoutControls.svelte

Status: added; +66; −0

```diff
@@ -1,0 +1,66 @@
+<script lang="ts">
+	import { tick } from 'svelte';
+	import { ArrowUp, ArrowDown, Minus, Plus } from '@lucide/svelte';
+	import { Button } from '#lib/components/ui/button/index.ts';
+	let {
+		title,
+		first,
+		last,
+		onMove,
+		onResize,
+		resizable = true
+	}: {
+		title: string;
+		first: boolean;
+		last: boolean;
+		resizable?: boolean;
+		onMove: (offset: number) => void;
+		onResize: (offset: number) => void;
+	} = $props();
+
+	async function move(button: HTMLElement, offset: number) {
+		if (!(button instanceof HTMLButtonElement)) return;
+		const controls = button.parentElement;
+		onMove(offset);
+		await tick();
+		if (!button.isConnected) return;
+		if (button.disabled)
+			controls?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
+		else button.focus();
+	}
+</script>
+
+<div class="card-actions" role="group" aria-label={`Arrange ${title}`}>
+	<Button
+		variant="ghost"
+		size="icon-sm"
+		disabled={first}
+		onclick={(event) => void move(event.currentTarget, -1)}
+		aria-label={`Move ${title} up`}
+		title="Move up"><ArrowUp size={15} /></Button
+	>
+	<Button
+		variant="ghost"
+		size="icon-sm"
+		disabled={last}
+		onclick={(event) => void move(event.currentTarget, 1)}
+		aria-label={`Move ${title} down`}
+		title="Move down"><ArrowDown size={15} /></Button
+	>
+	{#if resizable}
+		<Button
+			variant="ghost"
+			size="icon-sm"
+			onclick={() => onResize(-80)}
+			aria-label={`Make ${title} shorter`}
+			title="Shorter plot"><Minus size={15} /></Button
+		>
+		<Button
+			variant="ghost"
+			size="icon-sm"
+			onclick={() => onResize(80)}
+			aria-label={`Make ${title} taller`}
+			title="Taller plot"><Plus size={15} /></Button
+		>
+	{/if}
+</div>
```

## apps/web/src/lib/components/common/ControlPopover.svelte

Status: added; +42; −0

```diff
@@ -1,0 +1,42 @@
+<script lang="ts">
+	import { Popover } from 'bits-ui';
+	import { ChevronDown, X } from '@lucide/svelte';
+	import { Button } from '#lib/components/ui/button/index.ts';
+	import type { Snippet } from 'svelte';
+
+	let { label, summary, children }: { label: string; summary: string; children: Snippet } =
+		$props();
+</script>
+
+<Popover.Root>
+	<Popover.Trigger>
+		{#snippet child({ props })}
+			<Button {...props} variant="outline" size="sm" aria-label={label}
+				>{summary}<ChevronDown size={13} /></Button
+			>
+		{/snippet}
+	</Popover.Trigger>
+	<Popover.Content
+		side="bottom"
+		align="start"
+		sideOffset={6}
+		class="control-popover bg-popover text-popover-foreground z-40 w-[min(24rem,calc(100vw-2rem))] rounded-md border p-3 shadow-lg"
+		role="dialog"
+		aria-label={label}
+	>
+		<div class="mb-3 flex items-center justify-between gap-2">
+			<p class="text-sm font-semibold">{label}</p>
+			<Popover.Close>
+				{#snippet child({ props })}
+					<Button
+						{...props}
+						variant="ghost"
+						size="icon-xs"
+						aria-label={`Close ${label.toLowerCase()}`}><X size={14} /></Button
+					>
+				{/snippet}
+			</Popover.Close>
+		</div>
+		{@render children()}
+	</Popover.Content>
+</Popover.Root>
```

[Next page](https://git.cafe/flambo/atlantis/pull/135/diffs?after=ImFwcHMvd2ViL3NyYy9saWIvY29tcG9uZW50cy9jb21tb24vQ29udHJvbFBvcG92ZXIuc3ZlbHRlIg&expectedVersion=0&format=markdown)
