@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.
- package/README.md +77 -2
- package/dist/components/ChordPaletteOverlay.svelte +99 -0
- package/dist/components/ChordPaletteOverlay.svelte.d.ts +25 -0
- package/dist/components/PaneBase.svelte +159 -0
- package/dist/components/PaneBase.svelte.d.ts +27 -0
- package/dist/components/PanelHeaderSkeleton.svelte +85 -0
- package/dist/components/PanelHeaderSkeleton.svelte.d.ts +19 -0
- package/dist/components/PanelsZoom.svelte +213 -0
- package/dist/components/PanelsZoom.svelte.d.ts +26 -0
- package/dist/components/RailColumn.svelte +91 -0
- package/dist/components/RailColumn.svelte.d.ts +15 -0
- package/dist/components/ResizeGutter.svelte +68 -0
- package/dist/components/ResizeGutter.svelte.d.ts +13 -0
- package/dist/components/SidebarContainer.svelte +174 -0
- package/dist/components/SidebarContainer.svelte.d.ts +42 -0
- package/dist/components/SliderWidth.svelte +80 -0
- package/dist/components/SliderWidth.svelte.d.ts +27 -0
- package/dist/components/SliderZoom.svelte +77 -0
- package/dist/components/SliderZoom.svelte.d.ts +25 -0
- package/dist/i18n/LanguageMenu.svelte +165 -0
- package/dist/i18n/LanguageMenu.svelte.d.ts +25 -0
- package/dist/i18n/locale-prefs.svelte.d.ts +24 -0
- package/dist/i18n/locale-prefs.svelte.js +85 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +35 -0
- package/dist/interaction/.gitkeep +0 -0
- package/dist/interaction/chord-machine.d.ts +108 -0
- package/dist/interaction/chord-machine.js +121 -0
- package/dist/interaction/gutter-resize.d.ts +37 -0
- package/dist/interaction/gutter-resize.js +41 -0
- package/dist/layout/.gitkeep +0 -0
- package/dist/layout/floor-math.d.ts +48 -0
- package/dist/layout/floor-math.js +60 -0
- package/dist/layout/placement.d.ts +18 -0
- package/dist/layout/placement.js +27 -0
- package/dist/registry/.gitkeep +0 -0
- package/dist/registry/action-registry.d.ts +30 -0
- package/dist/registry/action-registry.js +44 -0
- package/dist/storage/create-prefs-store.d.ts +25 -0
- package/dist/storage/create-prefs-store.js +67 -0
- package/dist/storage/namespace.d.ts +27 -0
- package/dist/storage/namespace.js +32 -0
- package/dist/theme/.gitkeep +0 -0
- package/dist/theme/tokens.css +35 -0
- package/dist/theming/ThemeToggle.svelte +170 -0
- package/dist/theming/ThemeToggle.svelte.d.ts +27 -0
- package/dist/theming/applier.d.ts +15 -0
- package/dist/theming/applier.js +29 -0
- package/dist/theming/theme-prefs.svelte.d.ts +21 -0
- package/dist/theming/theme-prefs.svelte.js +91 -0
- package/dist/types/.gitkeep +0 -0
- package/package.json +34 -5
- package/src/components/ChordPaletteOverlay.svelte +99 -0
- package/src/components/PaneBase.svelte +159 -0
- package/src/components/PanelHeaderSkeleton.svelte +85 -0
- package/src/components/PanelsZoom.svelte +213 -0
- package/src/components/RailColumn.svelte +91 -0
- package/src/components/ResizeGutter.svelte +68 -0
- package/src/components/SidebarContainer.svelte +174 -0
- package/src/components/SliderWidth.svelte +80 -0
- package/src/components/SliderZoom.svelte +77 -0
- package/src/i18n/LanguageMenu.svelte +165 -0
- package/src/i18n/locale-prefs.svelte.ts +112 -0
- package/src/index.ts +67 -0
- package/src/interaction/.gitkeep +0 -0
- package/src/interaction/chord-machine.ts +208 -0
- package/src/interaction/gutter-resize.ts +76 -0
- package/src/layout/.gitkeep +0 -0
- package/src/layout/floor-math.ts +94 -0
- package/src/layout/placement.ts +30 -0
- package/src/registry/.gitkeep +0 -0
- package/src/registry/action-registry.ts +62 -0
- package/src/storage/create-prefs-store.ts +87 -0
- package/src/storage/namespace.ts +34 -0
- package/src/theme/.gitkeep +0 -0
- package/src/theme/tokens.css +35 -0
- package/src/theming/ThemeToggle.svelte +170 -0
- package/src/theming/applier.ts +30 -0
- package/src/theming/theme-prefs.svelte.ts +118 -0
- 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>
|