@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,108 @@
1
+ /**
2
+ * chord-machine — the armed two-stroke keyboard chord reducer (ADR-0006a
3
+ * port; container-base W5, ADR-0062 D5: the MACHINE moves, the PREFIX
4
+ * TABLES stay consumer config).
5
+ *
6
+ * A PURE reducer: every transition is (state, event, facts) -> { state,
7
+ * action }. No DOM, no stores, no timers — the caller owns the clock
8
+ * (now) and the wiring (which element heard the key).
9
+ *
10
+ * Grammar: press-and-release the arming key within CHORD_WINDOW_MS; a
11
+ * digit fires { apply, n }; a verb letter fires { verb } through the
12
+ * consumer's table; any other key or the expiring window disarms.
13
+ *
14
+ * D5 (the consumer-table rule): the machine never learns WHICH letters
15
+ * are verbs or what a terminal prefix maps to — the consumer passes
16
+ * ChordTables. The arming prefixes (Alt+first / Alt+second) and the
17
+ * digit lanes are machine semantics; "the arming key names the prefix;
18
+ * the prefix owns the follow-up table" is the ADR-0052 D1 rule the
19
+ * tables express.
20
+ */
21
+ export declare const CHORD_WINDOW_MS = 2000;
22
+ export type ChordPrefix = 's' | 't';
23
+ export interface ChordState {
24
+ /** True between the arming release and the digit / disarm. */
25
+ armed: boolean;
26
+ /** Date.now() of the arming keyup — the window clock. */
27
+ since: number;
28
+ /** Which prefix owns this chord — the prefix names the follow-up table. */
29
+ prefix: ChordPrefix;
30
+ }
31
+ export declare const IDLE_CHORD: ChordState;
32
+ export interface ChordOptionRow {
33
+ key: string;
34
+ label: string;
35
+ }
36
+ export type ChordAction = {
37
+ type: 'none';
38
+ } | {
39
+ type: 'disarm';
40
+ } | {
41
+ type: 'apply';
42
+ n: number;
43
+ } | {
44
+ type: 'verb';
45
+ verb: string;
46
+ prefix: ChordPrefix;
47
+ };
48
+ /** Minimal key shape the wiring feeds in (a real KeyboardEvent fits). */
49
+ export interface ChordKey {
50
+ key: string;
51
+ /** PHYSICAL key identity — the layout-independent match site. Holding
52
+ * Alt remaps letters on macOS (Option+S types key='ß'), so e.key
53
+ * matching never arms on a real keyboard. Match e.code; key stays as
54
+ * the no-code fallback. */
55
+ code?: string;
56
+ altKey: boolean;
57
+ ctrlKey: boolean;
58
+ metaKey: boolean;
59
+ /** string, not the union — a real KeyboardEvent.type IS a string and
60
+ * the wiring passes the event whole (no lossy adapter). */
61
+ type: string;
62
+ }
63
+ /** The consumer's chord tables (D5): WHICH letters are verbs under the
64
+ * plain prefix, and what the terminal prefix's letters map to. Absence
65
+ * of a letter in the terminal map disarms; a letter outside `verbs`
66
+ * disarms. An empty default table keeps the machine neutral — the
67
+ * consumer's config module owns the real table. */
68
+ export interface ChordTables {
69
+ /** Letters that fire `{ type: 'verb', verb: letter }` under the
70
+ * plain (first) prefix. */
71
+ verbs: string;
72
+ /** The terminal prefix's letter → verb-name map (a verb name the
73
+ * host dispatches; the machine never interprets it). */
74
+ terminal?: Record<string, string>;
75
+ /** Which prefix is the terminal one (default 't'). */
76
+ terminalPrefix?: ChordPrefix;
77
+ }
78
+ /** True when the chord can arm at all — any preset slot on any lane. */
79
+ export declare function hasAnyPreset(p: {
80
+ agent: Record<number, unknown>;
81
+ glass: Record<number, unknown>;
82
+ sidebar: Record<number, unknown>;
83
+ zoom?: Record<number, unknown>;
84
+ }): boolean;
85
+ /** Is the event target an editable surface? The wiring calls this FIRST —
86
+ * typing "1" in a composer must never fire a preset. */
87
+ export declare function isEditableTarget(t: unknown): boolean;
88
+ /** One key event through the machine. now is injectable for tests.
89
+ *
90
+ * ARM ON KEYDOWN — keyup is unreachable through window keydown wiring,
91
+ * and an arm-on-keyup rule would eat the chord it just armed: keyups
92
+ * pass through untouched. */
93
+ export declare function chordKey(state: ChordState, ev: ChordKey, canArm: boolean, now?: number, tables?: ChordTables): {
94
+ state: ChordState;
95
+ action: ChordAction;
96
+ };
97
+ /** The capture-first decision as a PURE function so the route adapter
98
+ * stays three lines and the capture rules unit-test without DOM.
99
+ * Ordering IS the contract: (1) a clean arming keydown captures
100
+ * everywhere; (2) an armed chord captures the next keydown everywhere;
101
+ * (3) anything else — including an un-armed digit on an editable
102
+ * target — passes. preventDefault is the swallowing the composer must
103
+ * never see. */
104
+ export declare function decideChordKeydown(state: ChordState, ev: ChordKey, canArm: boolean, now?: number, tables?: ChordTables): {
105
+ state: ChordState;
106
+ action: ChordAction;
107
+ preventDefault: boolean;
108
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * chord-machine — the armed two-stroke keyboard chord reducer (ADR-0006a
3
+ * port; container-base W5, ADR-0062 D5: the MACHINE moves, the PREFIX
4
+ * TABLES stay consumer config).
5
+ *
6
+ * A PURE reducer: every transition is (state, event, facts) -> { state,
7
+ * action }. No DOM, no stores, no timers — the caller owns the clock
8
+ * (now) and the wiring (which element heard the key).
9
+ *
10
+ * Grammar: press-and-release the arming key within CHORD_WINDOW_MS; a
11
+ * digit fires { apply, n }; a verb letter fires { verb } through the
12
+ * consumer's table; any other key or the expiring window disarms.
13
+ *
14
+ * D5 (the consumer-table rule): the machine never learns WHICH letters
15
+ * are verbs or what a terminal prefix maps to — the consumer passes
16
+ * ChordTables. The arming prefixes (Alt+first / Alt+second) and the
17
+ * digit lanes are machine semantics; "the arming key names the prefix;
18
+ * the prefix owns the follow-up table" is the ADR-0052 D1 rule the
19
+ * tables express.
20
+ */
21
+ export const CHORD_WINDOW_MS = 2_000;
22
+ export const IDLE_CHORD = { armed: false, since: 0, prefix: 's' };
23
+ const NO_TABLES = { verbs: '' };
24
+ /** True when the chord can arm at all — any preset slot on any lane. */
25
+ export function hasAnyPreset(p) {
26
+ return (Object.keys(p.agent).length > 0 ||
27
+ Object.keys(p.glass).length > 0 ||
28
+ Object.keys(p.sidebar).length > 0 ||
29
+ Object.keys(p.zoom ?? {}).length > 0);
30
+ }
31
+ /** Is the event target an editable surface? The wiring calls this FIRST —
32
+ * typing "1" in a composer must never fire a preset. */
33
+ export function isEditableTarget(t) {
34
+ const el = t;
35
+ if (!el || typeof el.closest !== 'function')
36
+ return false;
37
+ return el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') != null;
38
+ }
39
+ /** One key event through the machine. now is injectable for tests.
40
+ *
41
+ * ARM ON KEYDOWN — keyup is unreachable through window keydown wiring,
42
+ * and an arm-on-keyup rule would eat the chord it just armed: keyups
43
+ * pass through untouched. */
44
+ export function chordKey(state, ev, canArm, now = Date.now(), tables = NO_TABLES) {
45
+ const key = ev.key.toLowerCase();
46
+ if (ev.type === 'keyup')
47
+ return { state, action: { type: 'none' } };
48
+ // PHYSICAL identity: code='KeyS'/'KeyT' survives the macOS Alt remap;
49
+ // key==='s'/'t' stays as the fallback for code-less events. The
50
+ // arming key NAMES the prefix; the prefix owns the follow-up table.
51
+ const terminalPrefix = tables.terminalPrefix ?? 't';
52
+ const prefix = ev.code === 'KeyS' || (key === 's' && !ev.code)
53
+ ? 's'
54
+ : ev.code === 'KeyT' || (key === 't' && !ev.code)
55
+ ? 't'
56
+ : null;
57
+ // Digits: Digit1..9 first (matches even with Alt still held), key
58
+ // fallback. Digits are the FIRST prefix's lanes — under the terminal
59
+ // prefix they disarm (the table is truthful).
60
+ const digit = state.prefix === 's'
61
+ ? (/^Digit([1-9])$/.exec(ev.code ?? '')?.[1] ?? (/^[1-9]$/.test(ev.key) ? ev.key : null))
62
+ : null;
63
+ // Arming: a clean Alt+<prefix key> keydown (no ctrl/meta — browser verbs).
64
+ if (prefix && ev.altKey && !ev.ctrlKey && !ev.metaKey) {
65
+ if (!canArm)
66
+ return { state, action: { type: 'none' } };
67
+ // last prefix wins — an armed chord re-arms cleanly.
68
+ return { state: { armed: true, since: now, prefix }, action: { type: 'none' } };
69
+ }
70
+ if (!state.armed)
71
+ return { state, action: { type: 'none' } };
72
+ // The window expired on this keypress: disarm first, key never counts.
73
+ if (now - state.since > CHORD_WINDOW_MS) {
74
+ return { state: IDLE_CHORD, action: { type: 'disarm' } };
75
+ }
76
+ // A digit fires the preset (the operator taps; we don't grade the phase).
77
+ if (digit) {
78
+ return { state: IDLE_CHORD, action: { type: 'apply', n: Number(digit) } };
79
+ }
80
+ // A verb letter fires its table action — code first (the same
81
+ // physical-identity rule as arming), key fallback after. The LETTER
82
+ // SETS are the consumer's tables (D5), never this module's.
83
+ const letter = (/^[a-z]$/.test(ev.key) ? ev.key : undefined) ??
84
+ (/^Key([A-Z])$/.exec(ev.code ?? '')?.[1]?.toLowerCase());
85
+ if (letter) {
86
+ if (state.prefix === terminalPrefix && tables.terminal) {
87
+ const verb = tables.terminal[letter];
88
+ if (verb === undefined)
89
+ return { state: IDLE_CHORD, action: { type: 'disarm' } };
90
+ return { state: IDLE_CHORD, action: { type: 'verb', verb, prefix: state.prefix } };
91
+ }
92
+ if (tables.verbs.includes(letter)) {
93
+ return { state: IDLE_CHORD, action: { type: 'verb', verb: letter, prefix: state.prefix } };
94
+ }
95
+ return { state: IDLE_CHORD, action: { type: 'disarm' } };
96
+ }
97
+ // Any other key disarms.
98
+ return { state: IDLE_CHORD, action: { type: 'disarm' } };
99
+ }
100
+ /** The capture-first decision as a PURE function so the route adapter
101
+ * stays three lines and the capture rules unit-test without DOM.
102
+ * Ordering IS the contract: (1) a clean arming keydown captures
103
+ * everywhere; (2) an armed chord captures the next keydown everywhere;
104
+ * (3) anything else — including an un-armed digit on an editable
105
+ * target — passes. preventDefault is the swallowing the composer must
106
+ * never see. */
107
+ export function decideChordKeydown(state, ev, canArm, now = Date.now(), tables = NO_TABLES) {
108
+ const k = ev.key.toLowerCase();
109
+ const isArm = ((ev.code === 'KeyS' || (k === 's' && !ev.code)) ||
110
+ (ev.code === 'KeyT' || (k === 't' && !ev.code))) &&
111
+ ev.altKey &&
112
+ !ev.ctrlKey &&
113
+ !ev.metaKey;
114
+ if (ev.type === 'keydown' && isArm) {
115
+ return { ...chordKey(state, ev, canArm, now, tables), preventDefault: true };
116
+ }
117
+ if (state.armed && ev.type === 'keydown') {
118
+ return { ...chordKey(state, ev, canArm, now, tables), preventDefault: true };
119
+ }
120
+ return { state, action: { type: 'none' }, preventDefault: false };
121
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Gutter resize — the SINGLE sidebar-style drag machine (SSC#2 extraction,
3
+ * 2026-11-14): one width, snapshot on gutter mousedown, clamped delta per
4
+ * window mousemove, settle on window mouseup. Both consumers DSI has run
5
+ * byte-similar copies of this skeleton (route `startSidebarResize` +
6
+ * playground page drag vars); this module is now its only home.
7
+ *
8
+ * The machine OWNS: the window-listener lifecycle (attached on a
9
+ * successful start, released on settle), the delta arithmetic, the
10
+ * candidate width (snapshot + delta), and the clamp pass. The host OWNS:
11
+ * the bounds (clamp fn — DSI's are config-driven), the apply (write the
12
+ * width wherever it lives), and the settle (persistence, afterglow).
13
+ *
14
+ * A mousedown that returns null from `onStart` arms nothing — a host with
15
+ * collapsed/ignored states uses that instead of pre-gating the gutter.
16
+ */
17
+ export interface GutterResizeMachine {
18
+ /** Gutter mousedown — arms the machine and attaches the window
19
+ * listeners when `onStart` returns a snapshot width (null = ignore). */
20
+ start: (e: MouseEvent) => void;
21
+ /** True while dragging (start succeeded, settle not yet fired). */
22
+ readonly active: boolean;
23
+ /** Release the window listeners without settling (page teardown). */
24
+ destroy: () => void;
25
+ }
26
+ export declare function createGutterResize(handlers: {
27
+ /** Gutter mousedown: return the SNAPSHOT width (the width the drag
28
+ * moves away from), or null to ignore this gesture. */
29
+ onStart: (e: MouseEvent) => number | null;
30
+ /** The host's bounds — a pure width -> width clamp. */
31
+ clamp: (width: number) => number;
32
+ /** Apply the clamped width — called per window mousemove while armed. */
33
+ onResize: (width: number, e: MouseEvent) => void;
34
+ /** Settle on window mouseup: persist, arm afterglow, etc. The final
35
+ * clamped width is the one the last resize produced. */
36
+ onSettle?: (width: number) => void;
37
+ }): GutterResizeMachine;
@@ -0,0 +1,41 @@
1
+ export function createGutterResize(handlers) {
2
+ let armed = false;
3
+ let snapshot = 0;
4
+ let lastWidth = 0;
5
+ function onMove(e) {
6
+ if (!armed)
7
+ return;
8
+ lastWidth = handlers.clamp(snapshot + (e.clientX - lastStartX));
9
+ handlers.onResize(lastWidth, e);
10
+ }
11
+ function onUp(e) {
12
+ if (!armed)
13
+ return;
14
+ armed = false;
15
+ window.removeEventListener('mousemove', onMove);
16
+ window.removeEventListener('mouseup', onUp);
17
+ handlers.onSettle?.(lastWidth);
18
+ }
19
+ let lastStartX = 0;
20
+ return {
21
+ start(e) {
22
+ const snap = handlers.onStart(e);
23
+ if (snap === null)
24
+ return;
25
+ snapshot = snap;
26
+ lastStartX = e.clientX;
27
+ lastWidth = handlers.clamp(snap);
28
+ armed = true;
29
+ window.addEventListener('mousemove', onMove);
30
+ window.addEventListener('mouseup', onUp);
31
+ },
32
+ get active() {
33
+ return armed;
34
+ },
35
+ destroy() {
36
+ armed = false;
37
+ window.removeEventListener('mousemove', onMove);
38
+ window.removeEventListener('mouseup', onUp);
39
+ }
40
+ };
41
+ }
File without changes
@@ -0,0 +1,48 @@
1
+ /**
2
+ * floor-math — the drag/resize/order math of a one-row pane floor
3
+ * (generalized from DSI's floor-math.ts, ADR-0062 W3 / D8). PURE functions
4
+ * over plain structural inputs; no DOM, no stores, no consumer vocabulary.
5
+ *
6
+ * GENERIC over the pane type: a consumer's own pane interface (union,
7
+ * discriminated kinds, extra fields) flows through UNCHANGED — every
8
+ * function returns the same pane type it took. Only the structural core
9
+ * (id + width, optional kind) is required.
10
+ *
11
+ * The width bound is INJECTED (the clamp): the generic module cannot know
12
+ * a consumer's config-driven bounds, so every resize entry takes an
13
+ * optional clamp — the consumer's single bound site (DSI passes
14
+ * clampPanelWidth). Behavior is byte-identical to the pre-move DSI file
15
+ * when the consumer passes its clamp.
16
+ */
17
+ /** The structural core every pane must expose (satisfied by any consumer
18
+ * pane with an id and a width; the kind tag rides through opaquely). */
19
+ export interface PaneCore {
20
+ id: string;
21
+ width: number;
22
+ /** Opaque kind tag — passed through to the injected clamp only. */
23
+ kind?: unknown;
24
+ }
25
+ /** A consumer's single width bound site (load-side sanitize AND drags). */
26
+ export type WidthClamp = (width: number, kind?: string) => number;
27
+ /** Move direction, named in the INVOKING surface's geometry: horizontal
28
+ * header chevrons vs vertical rows. The floor owner maps both pairs onto
29
+ * one array operation — left/up toward the start, right/down toward the
30
+ * end. */
31
+ export type PaneMoveDir = 'left' | 'right' | 'up' | 'down';
32
+ /** Plain drag: ONLY the pane left of the dragged gutter changes — its
33
+ * snapshot width plus delta, passed through the injected clamp. Other
34
+ * panes keep their widths untouched (the row's total grows/shrinks; the
35
+ * floor viewport scrolls when the widened row overflows it). */
36
+ export declare function resizeOne<P extends PaneCore>(panels: readonly P[], snapshots: readonly number[], index: number, delta: number, clamp?: WidthClamp): P[];
37
+ /** Shift+drag: EVERY pane scales by its snapshot share of the row — ratios
38
+ * are preserved (new_i/new_j = snap_i/snap_j) until a pane saturates at a
39
+ * bound; an extreme drag clamps the row. A degenerate row (total <= 0)
40
+ * returns the input order untouched. */
41
+ export declare function scaleProportionally<P extends PaneCore>(panels: readonly P[], snapshots: readonly number[], delta: number, clamp?: WidthClamp): P[];
42
+ /** Honest-slider check: a row is uniform when every width equals the
43
+ * first (an empty row is not — nothing to sync the preset to). */
44
+ export declare function isUniformRow<P extends PaneCore>(panels: readonly P[]): boolean;
45
+ /** Move one pane one slot toward the direction's edge — left/up swap with
46
+ * the predecessor, right/down with the successor. A no-op returning the
47
+ * input order at either edge or for an unknown pane id. */
48
+ export declare function movePanelWithin<P extends PaneCore>(panels: readonly P[], panelId: string, dir: PaneMoveDir): P[];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * floor-math — the drag/resize/order math of a one-row pane floor
3
+ * (generalized from DSI's floor-math.ts, ADR-0062 W3 / D8). PURE functions
4
+ * over plain structural inputs; no DOM, no stores, no consumer vocabulary.
5
+ *
6
+ * GENERIC over the pane type: a consumer's own pane interface (union,
7
+ * discriminated kinds, extra fields) flows through UNCHANGED — every
8
+ * function returns the same pane type it took. Only the structural core
9
+ * (id + width, optional kind) is required.
10
+ *
11
+ * The width bound is INJECTED (the clamp): the generic module cannot know
12
+ * a consumer's config-driven bounds, so every resize entry takes an
13
+ * optional clamp — the consumer's single bound site (DSI passes
14
+ * clampPanelWidth). Behavior is byte-identical to the pre-move DSI file
15
+ * when the consumer passes its clamp.
16
+ */
17
+ const identityClamp = (width) => width;
18
+ /** Plain drag: ONLY the pane left of the dragged gutter changes — its
19
+ * snapshot width plus delta, passed through the injected clamp. Other
20
+ * panes keep their widths untouched (the row's total grows/shrinks; the
21
+ * floor viewport scrolls when the widened row overflows it). */
22
+ export function resizeOne(panels, snapshots, index, delta, clamp = identityClamp) {
23
+ const target = clamp(snapshots[index] + delta, panels[index]?.kind);
24
+ return panels.map((p, i) => (i === index ? { ...p, width: target } : p));
25
+ }
26
+ /** Shift+drag: EVERY pane scales by its snapshot share of the row — ratios
27
+ * are preserved (new_i/new_j = snap_i/snap_j) until a pane saturates at a
28
+ * bound; an extreme drag clamps the row. A degenerate row (total <= 0)
29
+ * returns the input order untouched. */
30
+ export function scaleProportionally(panels, snapshots, delta, clamp = identityClamp) {
31
+ const total = snapshots.reduce((sum, w) => sum + w, 0);
32
+ if (total <= 0)
33
+ return [...panels];
34
+ return panels.map((p, i) => ({
35
+ ...p,
36
+ width: clamp(snapshots[i] + (snapshots[i] / total) * delta, panels[i]?.kind)
37
+ }));
38
+ }
39
+ /** Honest-slider check: a row is uniform when every width equals the
40
+ * first (an empty row is not — nothing to sync the preset to). */
41
+ export function isUniformRow(panels) {
42
+ if (panels.length === 0)
43
+ return false;
44
+ const first = panels[0].width;
45
+ return panels.every((p) => p.width === first);
46
+ }
47
+ /** Move one pane one slot toward the direction's edge — left/up swap with
48
+ * the predecessor, right/down with the successor. A no-op returning the
49
+ * input order at either edge or for an unknown pane id. */
50
+ export function movePanelWithin(panels, panelId, dir) {
51
+ const idx = panels.findIndex((p) => p.id === panelId);
52
+ if (idx === -1)
53
+ return [...panels];
54
+ const target = dir === 'left' || dir === 'up' ? idx - 1 : idx + 1;
55
+ if (target < 0 || target >= panels.length)
56
+ return [...panels];
57
+ const next = [...panels];
58
+ [next[idx], next[target]] = [next[target], next[idx]];
59
+ return next;
60
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * placement — the generic order/anchor math of a pane floor (ADR-0062 W3,
3
+ * D8: split from DSI's panel-placement; the kind-tagged request union and
4
+ * DSI-specific branches stay in the consumer). PURE, list-shaped only.
5
+ */
6
+ /** The slot directly BELOW an anchor pane — anchor + 1 — or the caller's
7
+ * fallback when no pane carries the anchor (anchorIndex < 0). The
8
+ * fork-child / below-the-explorer slot rule. */
9
+ export declare function slotAfter(anchorIndex: number, fallback: number): number;
10
+ /** Insert ONE item at an anchor index. Boundary anchors clamp: an empty
11
+ * list (or an anchor past the end) appends; a negative anchor inserts at
12
+ * the front. Ordering is stable — existing members never reorder. */
13
+ export declare function insertAtAnchor<T>(list: readonly T[], anchorIndex: number, item: T): T[];
14
+ /** The focused-slot rule: a fresh item lands at the FOCUSED index —
15
+ * immediately before it. 0 when nothing is focused (negative or past the
16
+ * end) — front-add stays the fallback; Math.max keeps splice's
17
+ * negative-index semantics out of the no-selection case. */
18
+ export declare function focusedSlot(selectedIndex: number, listLength: number): number;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * placement — the generic order/anchor math of a pane floor (ADR-0062 W3,
3
+ * D8: split from DSI's panel-placement; the kind-tagged request union and
4
+ * DSI-specific branches stay in the consumer). PURE, list-shaped only.
5
+ */
6
+ /** The slot directly BELOW an anchor pane — anchor + 1 — or the caller's
7
+ * fallback when no pane carries the anchor (anchorIndex < 0). The
8
+ * fork-child / below-the-explorer slot rule. */
9
+ export function slotAfter(anchorIndex, fallback) {
10
+ return anchorIndex >= 0 ? anchorIndex + 1 : fallback;
11
+ }
12
+ /** Insert ONE item at an anchor index. Boundary anchors clamp: an empty
13
+ * list (or an anchor past the end) appends; a negative anchor inserts at
14
+ * the front. Ordering is stable — existing members never reorder. */
15
+ export function insertAtAnchor(list, anchorIndex, item) {
16
+ const at = Math.max(0, Math.min(anchorIndex, list.length));
17
+ const next = [...list];
18
+ next.splice(at, 0, item);
19
+ return next;
20
+ }
21
+ /** The focused-slot rule: a fresh item lands at the FOCUSED index —
22
+ * immediately before it. 0 when nothing is focused (negative or past the
23
+ * end) — front-add stays the fallback; Math.max keeps splice's
24
+ * negative-index semantics out of the no-selection case. */
25
+ export function focusedSlot(selectedIndex, listLength) {
26
+ return selectedIndex >= 0 && selectedIndex < listLength ? selectedIndex : 0;
27
+ }
File without changes
@@ -0,0 +1,30 @@
1
+ /**
2
+ * action-registry — the generic leaf-to-root action channel (ADR-0062 W3,
3
+ * D8: split from DSI's panel-registry; the payload unions stay in the
4
+ * consumer). Shape follows the OCI ControlRail precedent: register(name,
5
+ * fn | null) slots owned by the host's mount/destroy lifecycle, invoke
6
+ * returning boolean (false = no handler, graceful no-op — never a crash
7
+ * in a leaf). Payload-agnostic: actions are string-keyed, arguments
8
+ * variadic, the table never inspects a payload.
9
+ */
10
+ type AnyHandler = (...args: never[]) => unknown;
11
+ export interface ActionTable {
12
+ /** Set (or overwrite) an action's handler; null unregisters. */
13
+ register(action: string, handler: AnyHandler | null): void;
14
+ /** True when a handler is currently registered for the action. */
15
+ has(action: string): boolean;
16
+ /** Run the action's handler if registered.
17
+ * @returns true when a handler ran; false otherwise. */
18
+ invoke(action: string, ...args: unknown[]): boolean;
19
+ /** Like invoke, but returns the HANDLER'S OWN return value — undefined
20
+ * when no handler is registered. For result-carrying actions (e.g. a
21
+ * handler that reports a miss) where 'did it run' and 'what did it
22
+ * say' are different questions. */
23
+ invokeForResult(action: string, ...args: unknown[]): unknown;
24
+ /** True when ANY handler is registered (a host is mounted). */
25
+ isActive(): boolean;
26
+ /** Clear every slot (isolation between tests / host destroy). */
27
+ reset(): void;
28
+ }
29
+ export declare function createActionTable(): ActionTable;
30
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * action-registry — the generic leaf-to-root action channel (ADR-0062 W3,
3
+ * D8: split from DSI's panel-registry; the payload unions stay in the
4
+ * consumer). Shape follows the OCI ControlRail precedent: register(name,
5
+ * fn | null) slots owned by the host's mount/destroy lifecycle, invoke
6
+ * returning boolean (false = no handler, graceful no-op — never a crash
7
+ * in a leaf). Payload-agnostic: actions are string-keyed, arguments
8
+ * variadic, the table never inspects a payload.
9
+ */
10
+ export function createActionTable() {
11
+ const slots = new Map();
12
+ return {
13
+ register(action, handler) {
14
+ if (handler === null)
15
+ slots.delete(action);
16
+ else
17
+ slots.set(action, handler);
18
+ },
19
+ has(action) {
20
+ return slots.has(action);
21
+ },
22
+ invoke(action, ...args) {
23
+ const handler = slots.get(action);
24
+ if (handler === undefined)
25
+ return false;
26
+ // The table is payload-agnostic by construction; the consumer's
27
+ // register-time types are the contract, invoke erases shape.
28
+ handler(...args);
29
+ return true;
30
+ },
31
+ invokeForResult(action, ...args) {
32
+ const handler = slots.get(action);
33
+ if (handler === undefined)
34
+ return undefined;
35
+ return handler(...args);
36
+ },
37
+ isActive() {
38
+ return slots.size > 0;
39
+ },
40
+ reset() {
41
+ slots.clear();
42
+ }
43
+ };
44
+ }
@@ -0,0 +1,25 @@
1
+ export interface PrefsStoreOptions<T> {
2
+ /** The un-suffixed localStorage key (namespaced per profile). */
3
+ key: string;
4
+ /** Persisted defaults — a factory, so callers never share object state. */
5
+ defaults: () => T;
6
+ /** Parse a parsed-JSON value into prefs; return null to reject the blob
7
+ * as junk (load falls back to defaults). */
8
+ revive: (raw: unknown) => T | null;
9
+ /** Optional bound pass, applied to the revived value (after revive). */
10
+ clamp?: (prefs: T) => T;
11
+ /** Opt-in `{v, data}` envelope — new consumers only (D4); read-side
12
+ * still accepts a bare (legacy) blob so adoption is one-way safe. */
13
+ envelope?: {
14
+ version: number;
15
+ };
16
+ }
17
+ export interface PrefsStore<T> {
18
+ /** Read from localStorage; defaults on SSR, absence, junk, or error. */
19
+ load(profile?: string | null): T;
20
+ /** Write to localStorage (best-effort; no-op under SSR). */
21
+ save(prefs: T, profile?: string | null): void;
22
+ /** The exact key this store reads/writes for a profile. */
23
+ key(profile?: string | null): string;
24
+ }
25
+ export declare function createPrefsStore<T>(options: PrefsStoreOptions<T>): PrefsStore<T>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * createPrefsStore — the survival-layer primitive (ADR-0062 D4).
3
+ *
4
+ * Nine consumer modules hand-rolled one pattern: SSR guard → revive with
5
+ * junk fallback → clamp → best-effort write. This is that pattern, once:
6
+ *
7
+ * const store = createPrefsStore<MyPrefs>({
8
+ * key: 'my-key',
9
+ * defaults: () => ({ ... }),
10
+ * revive: (raw) => sanitize(raw), // return null ⇒ junk ⇒ defaults
11
+ * clamp: (v) => bound(v)
12
+ * });
13
+ *
14
+ * Semantics (pinned by tests/storage/create-prefs-store.test.ts):
15
+ * - SSR (`typeof localStorage === 'undefined'`): load → defaults, save → no-op.
16
+ * - junk / truncated / unparseable blob → defaults, never throws.
17
+ * - clamp runs AFTER revive, on every load.
18
+ * - writes are best-effort: a full or blocked store never throws.
19
+ * - blob shapes are consumer-owned: the stored text is exactly
20
+ * JSON.stringify(prefs) unless the consumer opts into the `{v, data}`
21
+ * envelope (new consumers only — existing blobs never migrate, D4).
22
+ */
23
+ import { storageKey } from './namespace';
24
+ /** True when the parsed JSON carries the envelope wrapper. */
25
+ function isEnvelope(raw) {
26
+ if (raw === null || typeof raw !== 'object')
27
+ return false;
28
+ const rec = raw;
29
+ return 'v' in rec && 'data' in rec;
30
+ }
31
+ export function createPrefsStore(options) {
32
+ const { key, defaults, revive, clamp, envelope } = options;
33
+ const keyFor = (profile) => storageKey(key, profile);
34
+ const finalize = (prefs) => (clamp ? clamp(prefs) : prefs);
35
+ return {
36
+ key: keyFor,
37
+ load(profile) {
38
+ if (typeof localStorage === 'undefined')
39
+ return defaults();
40
+ try {
41
+ const raw = localStorage.getItem(keyFor(profile));
42
+ if (!raw)
43
+ return defaults();
44
+ const parsed = JSON.parse(raw);
45
+ const body = envelope !== undefined && isEnvelope(parsed) ? parsed.data : parsed;
46
+ const revived = revive(body);
47
+ if (revived === null)
48
+ return defaults();
49
+ return finalize(revived);
50
+ }
51
+ catch {
52
+ return defaults();
53
+ }
54
+ },
55
+ save(prefs, profile) {
56
+ if (typeof localStorage === 'undefined')
57
+ return;
58
+ try {
59
+ const payload = envelope !== undefined ? { v: envelope.version, data: prefs } : prefs;
60
+ localStorage.setItem(keyFor(profile), JSON.stringify(payload));
61
+ }
62
+ catch {
63
+ // Store full or blocked — persistence is lost, the app is not.
64
+ }
65
+ }
66
+ };
67
+ }