@deepseek-insight/container-base 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +77 -2
  2. package/dist/components/ChordPaletteOverlay.svelte +99 -0
  3. package/dist/components/ChordPaletteOverlay.svelte.d.ts +25 -0
  4. package/dist/components/PaneBase.svelte +159 -0
  5. package/dist/components/PaneBase.svelte.d.ts +27 -0
  6. package/dist/components/PanelHeaderSkeleton.svelte +85 -0
  7. package/dist/components/PanelHeaderSkeleton.svelte.d.ts +19 -0
  8. package/dist/components/PanelsZoom.svelte +213 -0
  9. package/dist/components/PanelsZoom.svelte.d.ts +26 -0
  10. package/dist/components/RailColumn.svelte +91 -0
  11. package/dist/components/RailColumn.svelte.d.ts +15 -0
  12. package/dist/components/ResizeGutter.svelte +68 -0
  13. package/dist/components/ResizeGutter.svelte.d.ts +13 -0
  14. package/dist/components/SidebarContainer.svelte +174 -0
  15. package/dist/components/SidebarContainer.svelte.d.ts +42 -0
  16. package/dist/components/SliderWidth.svelte +80 -0
  17. package/dist/components/SliderWidth.svelte.d.ts +27 -0
  18. package/dist/components/SliderZoom.svelte +77 -0
  19. package/dist/components/SliderZoom.svelte.d.ts +25 -0
  20. package/dist/i18n/LanguageMenu.svelte +165 -0
  21. package/dist/i18n/LanguageMenu.svelte.d.ts +25 -0
  22. package/dist/i18n/locale-prefs.svelte.d.ts +24 -0
  23. package/dist/i18n/locale-prefs.svelte.js +85 -0
  24. package/dist/index.d.ts +34 -0
  25. package/dist/index.js +35 -0
  26. package/dist/interaction/.gitkeep +0 -0
  27. package/dist/interaction/chord-machine.d.ts +108 -0
  28. package/dist/interaction/chord-machine.js +121 -0
  29. package/dist/interaction/gutter-resize.d.ts +37 -0
  30. package/dist/interaction/gutter-resize.js +41 -0
  31. package/dist/layout/.gitkeep +0 -0
  32. package/dist/layout/floor-math.d.ts +48 -0
  33. package/dist/layout/floor-math.js +60 -0
  34. package/dist/layout/placement.d.ts +18 -0
  35. package/dist/layout/placement.js +27 -0
  36. package/dist/registry/.gitkeep +0 -0
  37. package/dist/registry/action-registry.d.ts +30 -0
  38. package/dist/registry/action-registry.js +44 -0
  39. package/dist/storage/create-prefs-store.d.ts +25 -0
  40. package/dist/storage/create-prefs-store.js +67 -0
  41. package/dist/storage/namespace.d.ts +27 -0
  42. package/dist/storage/namespace.js +32 -0
  43. package/dist/theme/.gitkeep +0 -0
  44. package/dist/theme/tokens.css +35 -0
  45. package/dist/theming/ThemeToggle.svelte +170 -0
  46. package/dist/theming/ThemeToggle.svelte.d.ts +27 -0
  47. package/dist/theming/applier.d.ts +15 -0
  48. package/dist/theming/applier.js +29 -0
  49. package/dist/theming/theme-prefs.svelte.d.ts +21 -0
  50. package/dist/theming/theme-prefs.svelte.js +91 -0
  51. package/dist/types/.gitkeep +0 -0
  52. package/package.json +34 -5
  53. package/src/components/ChordPaletteOverlay.svelte +99 -0
  54. package/src/components/PaneBase.svelte +159 -0
  55. package/src/components/PanelHeaderSkeleton.svelte +85 -0
  56. package/src/components/PanelsZoom.svelte +213 -0
  57. package/src/components/RailColumn.svelte +91 -0
  58. package/src/components/ResizeGutter.svelte +68 -0
  59. package/src/components/SidebarContainer.svelte +174 -0
  60. package/src/components/SliderWidth.svelte +80 -0
  61. package/src/components/SliderZoom.svelte +77 -0
  62. package/src/i18n/LanguageMenu.svelte +165 -0
  63. package/src/i18n/locale-prefs.svelte.ts +112 -0
  64. package/src/index.ts +67 -0
  65. package/src/interaction/.gitkeep +0 -0
  66. package/src/interaction/chord-machine.ts +208 -0
  67. package/src/interaction/gutter-resize.ts +76 -0
  68. package/src/layout/.gitkeep +0 -0
  69. package/src/layout/floor-math.ts +94 -0
  70. package/src/layout/placement.ts +30 -0
  71. package/src/registry/.gitkeep +0 -0
  72. package/src/registry/action-registry.ts +62 -0
  73. package/src/storage/create-prefs-store.ts +87 -0
  74. package/src/storage/namespace.ts +34 -0
  75. package/src/theme/.gitkeep +0 -0
  76. package/src/theme/tokens.css +35 -0
  77. package/src/theming/ThemeToggle.svelte +170 -0
  78. package/src/theming/applier.ts +30 -0
  79. package/src/theming/theme-prefs.svelte.ts +118 -0
  80. package/src/types/.gitkeep +0 -0
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ /**
4
+ * PaneBase — one pane's fixed-width column shell (split from DSI's
5
+ * PanelColumn, ADR-0062 W4, D8): the GENERIC chrome — the inline
6
+ * triple width lock, the selection ring, the narrow-container query,
7
+ * the activate/loupe gestures — lives here; the consumer supplies its
8
+ * own vocabulary as SNIPPETS (header / toolbar / content / footer).
9
+ * The package never reads a consumer store, type, or translation.
10
+ *
11
+ * Gestures (owner contract, carried verbatim from the split):
12
+ * - pointerdown/focusin anywhere INSIDE the column fire onactivate
13
+ * (interacting with a pane focuses its floor slot).
14
+ * - click with Alt held, target outside a control, fires onloupe —
15
+ * evaluated on the CLICK phase (never pointerdown: a dialog under
16
+ * an in-flight press eats its own compatibility mousedown), with
17
+ * ONE closest() exclusion instead of per-control stopPropagation.
18
+ */
19
+ let {
20
+ width,
21
+ paneId,
22
+ sessionTag = null,
23
+ selected = false,
24
+ onactivate,
25
+ onloupe,
26
+ header,
27
+ toolbar,
28
+ children,
29
+ footer
30
+ }: {
31
+ /** Column width in px — triple-locked inline (width/min/max). */
32
+ width: number;
33
+ /** The pane's id — data-panel-id (the zoom's selection-follow hook). */
34
+ paneId: string;
35
+ /** Opaque session-ish tag — data-session-id (null when the pane
36
+ * carries no session; the value is never interpreted here). */
37
+ sessionTag?: string | null;
38
+ /** Selection cue — the ring on the selected column. */
39
+ selected?: boolean;
40
+ /** Activate (select) this pane — pointerdown/focusin inside. */
41
+ onactivate?: () => void;
42
+ /** Loupe trigger — Alt+Click release outside a control. */
43
+ onloupe?: () => void;
44
+ /** Header strip slot (the consumer's floor chrome). */
45
+ header: Snippet;
46
+ /** Toolbar slot under the header (badges, controls). */
47
+ toolbar?: Snippet;
48
+ /** Content slot — the pane body. */
49
+ children: Snippet;
50
+ /** Footer slot (after the content). */
51
+ footer?: Snippet;
52
+ } = $props();
53
+
54
+ /** The loupe trigger: Alt held, target not inside a control —
55
+ * evaluated on click release (see the props doc). */
56
+ function handleLoupeTrigger(event: MouseEvent): void {
57
+ if (!event.altKey) return;
58
+ const target = event.target;
59
+ if (
60
+ target instanceof Element &&
61
+ target.closest('button, a, input, textarea, [role="button"]') !== null
62
+ ) {
63
+ return;
64
+ }
65
+ onloupe?.();
66
+ }
67
+ </script>
68
+
69
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events — the column is a
70
+ LAYOUT container, not a control: pointerdown is a passive
71
+ selection-follow, focusin is the keyboard parity for the same
72
+ selection, every real control inside stays itself, and the
73
+ click-phase Alt+Click is the loupe trigger. -->
74
+ <div
75
+ class="column"
76
+ class:selected
77
+ data-testid="panel-column"
78
+ data-panel-id={paneId}
79
+ data-session-id={sessionTag}
80
+ style="width: {width}px; min-width: {width}px; max-width: {width}px;"
81
+ onpointerdown={() => onactivate?.()}
82
+ onfocusin={() => onactivate?.()}
83
+ onclick={handleLoupeTrigger}
84
+ >
85
+ {@render header()}
86
+ {#if toolbar}
87
+ {@render toolbar()}
88
+ {/if}
89
+ <div class="body">
90
+ {@render children()}
91
+ </div>
92
+ {#if footer}
93
+ {@render footer()}
94
+ {/if}
95
+ </div>
96
+
97
+ <style>
98
+ .column {
99
+ position: relative; /* the width badge's absolute anchor */
100
+ /* Size container: the column's inline size is the narrow-header
101
+ trigger — header children query it instead of receiving width
102
+ as props. */
103
+ container-type: inline-size;
104
+ display: flex;
105
+ flex-direction: column;
106
+ height: 100%;
107
+ background: var(--color-surface-primary, var(--color-surface-elevated));
108
+ border-right: 1px solid var(--color-surface-border);
109
+ flex-shrink: 0;
110
+ }
111
+
112
+ /* Selection ring — a rounded gradient drawn OUTSIDE the column (same
113
+ geometry the flat outline had: the band sits in the resize seam, so
114
+ it never paints over any child component; the floor row carries
115
+ matching breathing room so the ring's segments are not clipped by
116
+ the viewport's overflow; both live inside the zoom transform, so
117
+ they scale in lockstep at any zoom). The ring is a masked
118
+ pseudo-element, not an outline: outlines cannot take a gradient or
119
+ a radius. The content-box + exclude mask carves the band; the
120
+ corners round without touching the column's own square seams. */
121
+ .column.selected::after {
122
+ content: '';
123
+ position: absolute;
124
+ inset: -3px;
125
+ z-index: 5;
126
+ pointer-events: none; /* the gutter and header own their pointers */
127
+ border-radius: 0.75rem;
128
+ padding: 5px;
129
+ /* Theme vars only (D7): the consumer's theme re-points them. */
130
+ background: linear-gradient(
131
+ 135deg,
132
+ var(--color-accent-blue) 0%,
133
+ var(--color-accent-purple) 45%,
134
+ var(--color-accent-rose) 100%
135
+ );
136
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
137
+ -webkit-mask-composite: xor;
138
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
139
+ mask-composite: exclude;
140
+ }
141
+
142
+ /* Narrow columns (<530px): header text hides, icons carry identity.
143
+ :global because the text spans live in child components — Svelte
144
+ scoping stops at this component's own elements, and the container
145
+ query must reach the descendants. */
146
+ @container (max-width: 529px) {
147
+ .column :global(.narrow-hide) {
148
+ display: none;
149
+ }
150
+ }
151
+
152
+ .body {
153
+ flex: 1;
154
+ min-height: 0;
155
+ display: flex;
156
+ flex-direction: column;
157
+ overflow: hidden;
158
+ }
159
+ </style>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ /**
4
+ * PanelHeaderSkeleton — a pane column's header-strip LAYOUT skeleton
5
+ * (split from DSI's PanelHeader, ADR-0062 W4, D8): the strip carries
6
+ * the geometry — flex row, label side + actions side, the bottom
7
+ * border, the selected wash — and the consumer renders its own
8
+ * controls INTO the slots (labels, copy buttons, chevrons, close).
9
+ * No consumer vocabulary: no stores, no icons, no translation.
10
+ *
11
+ * The element ref is BINDABLE: a consumer whose verbs need the strip's
12
+ * geometry (e.g. a canvas-capture button aiming at the strip's
13
+ * PARENT — the column) binds it back.
14
+ */
15
+ let {
16
+ title,
17
+ selected = false,
18
+ labelTestId,
19
+ headerEl = $bindable(undefined),
20
+ leading,
21
+ actions
22
+ }: {
23
+ /** Tooltip for the whole strip (the consumer's label text). */
24
+ title?: string;
25
+ /** Selection cue — the selected column's header carries the wash. */
26
+ selected?: boolean;
27
+ /** The label span's testid — the consumer's DOM hook (its own
28
+ * variants override; the default hook is the consumer's choice). */
29
+ labelTestId?: string;
30
+ /** The strip element — bind back for geometry-aimed verbs. */
31
+ headerEl?: HTMLDivElement | undefined;
32
+ /** Leading slot: the identity (label, copy control). */
33
+ leading: Snippet;
34
+ /** Actions slot: the verbs (chevrons, capture, close). */
35
+ actions: Snippet;
36
+ } = $props();
37
+ </script>
38
+
39
+ <div class="panel-header" class:selected data-testid="panel-header" {title} bind:this={headerEl}>
40
+ <span class="label" data-testid={labelTestId ?? 'panel-header-label'}>
41
+ {@render leading()}
42
+ </span>
43
+ <span class="actions">
44
+ {@render actions()}
45
+ </span>
46
+ </div>
47
+
48
+ <style>
49
+ .panel-header {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ gap: 0.5rem;
54
+ padding: 0.25rem 0.5rem;
55
+ min-height: 1.75rem;
56
+ font-size: 0.6875rem;
57
+ color: var(--color-text-secondary);
58
+ border-bottom: 1px solid var(--color-surface-border);
59
+ background: var(--color-surface-primary, var(--color-surface-elevated));
60
+ flex-shrink: 0;
61
+ }
62
+
63
+ /* Focus tint: the selected column's floor strip carries the same tint
64
+ as the conversation header/footer below it — the column you would
65
+ type into reads as one tinted frame. */
66
+ .panel-header.selected {
67
+ background: var(--color-header-wash);
68
+ }
69
+
70
+ .label {
71
+ min-width: 0;
72
+ display: inline-flex;
73
+ align-items: center;
74
+ gap: 0.25rem;
75
+ overflow: hidden;
76
+ white-space: nowrap;
77
+ }
78
+
79
+ .actions {
80
+ flex-shrink: 0;
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 0.125rem;
84
+ }
85
+ </style>
@@ -0,0 +1,213 @@
1
+ <script lang="ts">
2
+ /**
3
+ * PanelsZoom — the floor's zoom frame (Panel Floor W3 task 3.2,
4
+ * ADR-0006 R9; OCI PanelsZoom port; container-base W4, ADR-0062).
5
+ *
6
+ * transform: scale(zoom) does NOT change layout width — scrollWidth
7
+ * ignores transforms — so a scaled-down row that visually fits still
8
+ * reports its unscaled width and the browser draws a phantom
9
+ * horizontal scrollbar. The fix is a two-layer frame:
10
+ *
11
+ * 1. THIS component's viewport div carries the scrollable overflow
12
+ * and the grey workspace paint.
13
+ * 2. The zoom-frame div inside it carries the LAYOUT width, set in
14
+ * JS to `row.scrollWidth × zoom` — the visual truth at every
15
+ * zoom. Layout width is what scrolling measures, so the
16
+ * scrollbar appears iff the scaled row truly overflows.
17
+ * 3. The row inside the frame carries scale(zoom), origin
18
+ * top-left, width max-content (so it measures its natural
19
+ * width), and a counter-scaled height (100/zoom % — a
20
+ * scaled-down row still fills the frame vertically).
21
+ *
22
+ * The measurement effect re-runs on a GEOMETRY FINGERPRINT — the
23
+ * string of all panel widths + count — because scrollWidth itself is
24
+ * not reactive; the fingerprint is.
25
+ *
26
+ * Presentational + mechanical only: zoom, panels, and the selected
27
+ * panel's id arrive as props (the id + the floor's id sequence drive
28
+ * the selection-follow — selection change or reshuffle — the one
29
+ * scroll this component writes itself); the row's children arrive as
30
+ * a snippet.
31
+ */
32
+ import ResizeGutter from './ResizeGutter.svelte';
33
+
34
+ let {
35
+ zoom = 1,
36
+ panels,
37
+ railWidth = null,
38
+ selectedPanelId = null,
39
+ children
40
+ }: {
41
+ /** Floor zoom multiplier (clamped by the consumer's prefs — one clamp site). */
42
+ zoom?: number;
43
+ /** Open panels — the geometry fingerprint source (widths + count)
44
+ * and the reorder fingerprint source (the id sequence). */
45
+ panels: Array<{ id: string; width: number }>;
46
+ /** Leading sticky-column width (px) — the embedded rail column;
47
+ * null/undefined = no rail column.
48
+ * RIDES THE FINGERPRINT (OCI `leading ? railWidth : ''`): mounting,
49
+ * unmounting, or resizing the rail changes row.scrollWidth, and
50
+ * scrollWidth itself is not reactive — the fingerprint is. */
51
+ railWidth?: number | null;
52
+ /** The focused panel's id — the selection-follow target: when it
53
+ * changes, a column the viewport cannot see is scrolled into view
54
+ * (the floor can be several viewports wide; the selection ring
55
+ * must never sit off-screen behind the scroll edge). */
56
+ selectedPanelId?: string | null;
57
+ /** Snippet: one rendered child per panel. */
58
+ children: import('svelte').Snippet;
59
+ } = $props();
60
+
61
+ // ResizeGutter is re-exported for hosts that compose the row the way
62
+ // the OCI floor does; the gutter itself stays a separate public face.
63
+ void ResizeGutter;
64
+
65
+ /** Scroll viewport (overflow-x lives here, grey workspace paint). */
66
+ let viewport = $state<HTMLDivElement | undefined>(undefined);
67
+ /** Zoom-frame div — JS-measured layout width lives here. */
68
+ let frame = $state<HTMLDivElement | undefined>(undefined);
69
+ /** Row div — transform-scaled, natural-width content. */
70
+ let row = $state<HTMLDivElement | undefined>(undefined);
71
+
72
+ /**
73
+ * Geometry fingerprint: every panel width + the count + the leading
74
+ * rail column's width (empty when absent — OCI `leading ? railWidth :
75
+ * ''`). Any width change, add, remove, or rail mount/unmount/resize
76
+ * changes the string and re-runs the measure effect below.
77
+ * (scrollWidth is not reactive; the fingerprint is.)
78
+ */
79
+ const geometryFingerprint = $derived(
80
+ panels.map((p) => p.width).join(',') + `#${panels.length}` + `|${railWidth ?? ''}`
81
+ );
82
+
83
+ /**
84
+ * Reorder fingerprint: the panel-ID SEQUENCE. A move reshuffles the
85
+ * columns — it changes this string while leaving the geometry
86
+ * fingerprint untouched, and the reshuffle can push the focused
87
+ * column past a viewport edge. Width-only edits never change it, so
88
+ * a gutter drag or zoom still never steals the scroll.
89
+ */
90
+ const orderFingerprint = $derived(panels.map((p) => p.id).join(','));
91
+
92
+ $effect(() => {
93
+ // The row's transform reads --zoom (a var, so the counter-scaled
94
+ // height calc can share it); keep it in lockstep with the prop.
95
+ if (row) row.style.setProperty('--zoom', String(zoom));
96
+ });
97
+
98
+ $effect(() => {
99
+ // Depend on the fingerprint (and zoom) — the re-measure triggers.
100
+ void geometryFingerprint;
101
+ void zoom;
102
+ if (!frame || !row) return;
103
+ // Measure in a rAF so the browser has laid the (possibly new)
104
+ // geometry out before scrollWidth is read. The frame carries BOTH
105
+ // layout dimensions (width AND height scaled) — a 1px height stub
106
+ // let overflow-y:hidden clip the row's real content under the
107
+ // viewport, breaking pointer events on panel children.
108
+ requestAnimationFrame(() => {
109
+ if (!frame || !row) return;
110
+ // Layout WIDTH only: scrollWidth is transform-blind, so the frame
111
+ // carries the scaled truth for horizontal scrolling. HEIGHT is NOT
112
+ // JS-set — the frame fills the viewport (height:100%) and the row
113
+ // counter-scales (100%/zoom) inside it; a JS height here would be
114
+ // circular (scrollHeight of a percentage-height child).
115
+ frame.style.width = `${Math.ceil(row.scrollWidth * zoom)}px`;
116
+ });
117
+ });
118
+
119
+ $effect(() => {
120
+ // Selection-follow: a NEWLY selected panel — or the focused panel
121
+ // after a RESHUFFLE — is scrolled into view: nearest, horizontal
122
+ // only (the viewport never scrolls vertically). Fires on the
123
+ // id-sequence change or the selection change ONLY: geometry edits
124
+ // never steal the scroll. Placed after the measure effect so, in
125
+ // the add+select flush, the frame's rAF runs before this one.
126
+ const target = selectedPanelId;
127
+ void orderFingerprint;
128
+ if (target === null) return;
129
+ requestAnimationFrame(() => {
130
+ if (!viewport) return;
131
+ const col = viewport.querySelector<HTMLElement>(
132
+ `[data-panel-id="${CSS.escape(target)}"]`
133
+ );
134
+ if (!col) return;
135
+ // getBoundingClientRect is transform-true (zoom included), and
136
+ // the frame carries the scaled layout width — visual coordinates
137
+ // ARE scrollLeft's coordinate space. The selection ring extends
138
+ // 4px × zoom outward, so it joins the reveal.
139
+ const vpRect = viewport.getBoundingClientRect();
140
+ const colRect = col.getBoundingClientRect();
141
+ const ring = 4 * zoom;
142
+ const left = colRect.left - vpRect.left + viewport.scrollLeft - ring;
143
+ const right = left + colRect.width + ring * 2;
144
+ let reveal: number | null = null;
145
+ if (left < viewport.scrollLeft) {
146
+ reveal = Math.max(0, left);
147
+ } else if (right > viewport.scrollLeft + viewport.clientWidth) {
148
+ reveal = right - viewport.clientWidth;
149
+ }
150
+ if (reveal === null) return; // fully visible (ring included) — hands off
151
+ // Inertial: the reveal GLIDES; prefers-reduced-motion collapses
152
+ // it to the instant jump.
153
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
154
+ viewport.scrollTo({ left: reveal, behavior: reduceMotion ? 'auto' : 'smooth' });
155
+ });
156
+ });
157
+ </script>
158
+
159
+ <div class="panels-viewport" bind:this={viewport} data-testid="panels-viewport">
160
+ <div class="zoom-frame" bind:this={frame} data-testid="panels-zoom-frame">
161
+ <div class="panels-row" bind:this={row} data-testid="panels-row">
162
+ {@render children()}
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ <style>
168
+ .panels-viewport {
169
+ flex: 1;
170
+ min-width: 0;
171
+ overflow-x: auto;
172
+ overflow-y: hidden;
173
+ /* Grey workspace paint — the floor the panels sit on. */
174
+ background: var(--color-surface-secondary);
175
+ }
176
+
177
+ .zoom-frame {
178
+ /* Fills the viewport vertically — the row counter-scales inside. */
179
+ height: 100%;
180
+ }
181
+
182
+
183
+ .panels-row {
184
+ display: flex;
185
+ align-items: stretch;
186
+ /* SSC#2 G8 (2026-11-14/15): border-box so the 4px ring-breathing
187
+ * padding sits INSIDE the counter-scaled height — content-box let
188
+ * children resolve height:100% to the full calc'd value and overflow
189
+ * the viewport bottom by the scaled padding whenever the row fits
190
+ * horizontally (no scrollbar shrinking the viewport, i.e. any
191
+ * zoomed-out desk). Matches DSI's resolved geometry.
192
+ * The -8px in the calc reserves the selected ring's protrusion (3px
193
+ * out + 5px band, PaneBase ::after) OUTSIDE the scaled fill: with the
194
+ * row filling the viewport EXACTLY, the ring's bottom band landed
195
+ * flush on the viewport edge at every zoom and read as cut off (the
196
+ * operator's 2026-11-15 report). The reservation scales back up with
197
+ * the row, so the slack stays proportional, not a fixed gap. */
198
+ box-sizing: border-box;
199
+ /* Natural width so the frame can measure the true row width. */
200
+ width: max-content;
201
+ /* The zoom transform — visual only, layout-blind by CSS design. */
202
+ transform: scale(var(--zoom, 1));
203
+ transform-origin: top left;
204
+ height: calc((100% - 8px) / var(--zoom, 1));
205
+ /* Counter-scaled height: a scaled-down row still fills the frame
206
+ vertically, with the ring reservation above leaving the selected
207
+ ring's bottom band visible at every zoom. */
208
+ /* Breathing room for the selected column's OUTWARD ring (4px
209
+ extent beyond the border box). The padding lives INSIDE this
210
+ transform, so it scales with zoom in lockstep with the ring. */
211
+ padding: 4px;
212
+ }
213
+ </style>
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ /**
4
+ * RailColumn — a fixed-width LEADING column inside the zoom row
5
+ * (generalized from DSI's StickyColumnContainer, OCI ControlRail port;
6
+ * ADR-0062 W4, D6: the i18n gutter label arrives as a PROP — the full
7
+ * sidebar container is an R2 exception, the rail ships in R1).
8
+ *
9
+ * "Rail" = the width is TRIPLE-LOCKED (width/min/max all the same
10
+ * px), unlike pane columns whose widths live in [MIN, MAX]: the rail
11
+ * never flexes when the row measures itself at max-content. The
12
+ * column's background paints the full column height — the rail inside
13
+ * fills it (align-items: stretch).
14
+ *
15
+ * The trailing resize gutter is included BY DEFAULT (OCI parity) but
16
+ * only when pane columns actually follow (showGutter) — a rail alone
17
+ * on the floor has nothing to resize against. The gutter is the
18
+ * column's flex SIBLING (the rail's overflow must never clip its hit
19
+ * area) and reports mousedown through onResizeStart — the host owns
20
+ * the drag math and the single clamp.
21
+ */
22
+ let {
23
+ width,
24
+ label,
25
+ showGutter = true,
26
+ onResizeStart,
27
+ children
28
+ }: {
29
+ /** Sticky (fixed) column width in px — triple-locked, never flexes. */
30
+ width: number;
31
+ /** Accessible label for the trailing gutter (consumer-localized). */
32
+ label: string;
33
+ /** Trailing resize gutter — only when pane columns follow (OCI). */
34
+ showGutter?: boolean;
35
+ /** Gutter mousedown — the host runs the drag and owns the clamp. */
36
+ onResizeStart?: (e: MouseEvent) => void;
37
+ children: Snippet;
38
+ } = $props();
39
+ </script>
40
+
41
+ <div
42
+ class="rail-column"
43
+ data-testid="sidebar-column"
44
+ style="width: {width}px; min-width: {width}px; max-width: {width}px;"
45
+ >
46
+ {@render children()}
47
+ </div>
48
+
49
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions — mouse-drag
50
+ resize handle; keyboard-driven resize is a known gap, not a silent
51
+ omission (filed per ADR-0062 §7) -->
52
+ {#if showGutter}
53
+ <div
54
+ class="rail-gutter"
55
+ data-testid="sidebar-gutter"
56
+ role="separator"
57
+ aria-orientation="vertical"
58
+ aria-label={label}
59
+ title={label}
60
+ onmousedown={(e) => onResizeStart?.(e)}
61
+ ></div>
62
+ {/if}
63
+
64
+ <style>
65
+ /* Column-geometry essentials (OCI StickyColumnContainer .rail-column):
66
+ the triple-locked width arrives INLINE (width/min/max); this rule
67
+ carries the flex-column shell + the surface paint. */
68
+ .rail-column {
69
+ flex-shrink: 0;
70
+ display: flex;
71
+ flex-direction: column;
72
+ background: var(--color-surface-elevated, var(--color-surface-elevated));
73
+ position: relative;
74
+ overflow: visible;
75
+ }
76
+
77
+ /* Resize gutter — 6px column right of the locked column (same seam
78
+ as the app sidebar's own gutter in the out-of-zoom placement: one
79
+ visual contract, two host sites). */
80
+ .rail-gutter {
81
+ width: 6px;
82
+ flex-shrink: 0;
83
+ cursor: col-resize;
84
+ transition: background-color 0.15s ease;
85
+ }
86
+
87
+ .rail-gutter:hover {
88
+ background: var(--color-accent-blue);
89
+ opacity: 0.25;
90
+ }
91
+ </style>
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ResizeGutter — the 6px draggable seam after every pane (ADR-0006 R8
4
+ * port, generalized for the container base, ADR-0062 W4 / D6: the i18n
5
+ * labels arrive as PROPS — the package never learns a consumer's
6
+ * translation system).
7
+ *
8
+ * The gutter does not resize anything itself — mousedown reports the
9
+ * event upward through the injected onGutterDown hook, and the HOST
10
+ * (the drag owner) snapshots widths and runs the resize math. No drag
11
+ * state lives here.
12
+ */
13
+ let {
14
+ index,
15
+ ariaLabel,
16
+ title,
17
+ onGutterDown
18
+ }: {
19
+ /** The pane index this seam trails — the host's drag payload. */
20
+ index: number;
21
+ /** Accessible name for the seam (consumer-localized). */
22
+ ariaLabel: string;
23
+ /** Hover tooltip (consumer-localized). */
24
+ title: string;
25
+ /** Mousedown — the HOST owns the drag and the clamp. */
26
+ onGutterDown?: (event: MouseEvent) => void;
27
+ } = $props();
28
+ </script>
29
+
30
+ <!-- A real button (a11y): the gutter is an interactive separator; drag
31
+ reports upward — the host owns the drag math. Button styling resets
32
+ to the 6px seam. Keyboard-driven resize is a known gap (filed, not
33
+ hidden — ADR-0062 §7). -->
34
+ <button
35
+ type="button"
36
+ class="gutter"
37
+ data-testid="panel-gutter-{index}"
38
+ aria-label={ariaLabel}
39
+ {title}
40
+ onmousedown={(e) => {
41
+ e.preventDefault();
42
+ onGutterDown?.(e);
43
+ }}
44
+ ></button>
45
+
46
+ <style>
47
+ .gutter {
48
+ display: block;
49
+ padding: 0;
50
+ border: none;
51
+ flex: 0 0 6px;
52
+ width: 6px;
53
+ align-self: stretch;
54
+ cursor: col-resize;
55
+ background: transparent;
56
+ transition: background-color 0.15s ease;
57
+ }
58
+
59
+ .gutter:hover,
60
+ .gutter:active {
61
+ background: var(--color-accent-blue);
62
+ }
63
+
64
+ .gutter:focus-visible {
65
+ outline: 2px solid var(--color-accent-blue);
66
+ outline-offset: -2px;
67
+ }
68
+ </style>