@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,208 @@
|
|
|
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
|
+
|
|
22
|
+
export const CHORD_WINDOW_MS = 2_000;
|
|
23
|
+
|
|
24
|
+
export type ChordPrefix = 's' | 't';
|
|
25
|
+
|
|
26
|
+
export interface ChordState {
|
|
27
|
+
/** True between the arming release and the digit / disarm. */
|
|
28
|
+
armed: boolean;
|
|
29
|
+
/** Date.now() of the arming keyup — the window clock. */
|
|
30
|
+
since: number;
|
|
31
|
+
/** Which prefix owns this chord — the prefix names the follow-up table. */
|
|
32
|
+
prefix: ChordPrefix;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const IDLE_CHORD: ChordState = { armed: false, since: 0, prefix: 's' };
|
|
36
|
+
|
|
37
|
+
export interface ChordOptionRow {
|
|
38
|
+
key: string;
|
|
39
|
+
label: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ChordAction =
|
|
43
|
+
| { type: 'none' }
|
|
44
|
+
| { type: 'disarm' }
|
|
45
|
+
| { type: 'apply'; n: number }
|
|
46
|
+
| { type: 'verb'; verb: string; 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
|
+
|
|
64
|
+
/** The consumer's chord tables (D5): WHICH letters are verbs under the
|
|
65
|
+
* plain prefix, and what the terminal prefix's letters map to. Absence
|
|
66
|
+
* of a letter in the terminal map disarms; a letter outside `verbs`
|
|
67
|
+
* disarms. An empty default table keeps the machine neutral — the
|
|
68
|
+
* consumer's config module owns the real table. */
|
|
69
|
+
export interface ChordTables {
|
|
70
|
+
/** Letters that fire `{ type: 'verb', verb: letter }` under the
|
|
71
|
+
* plain (first) prefix. */
|
|
72
|
+
verbs: string;
|
|
73
|
+
/** The terminal prefix's letter → verb-name map (a verb name the
|
|
74
|
+
* host dispatches; the machine never interprets it). */
|
|
75
|
+
terminal?: Record<string, string>;
|
|
76
|
+
/** Which prefix is the terminal one (default 't'). */
|
|
77
|
+
terminalPrefix?: ChordPrefix;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NO_TABLES: ChordTables = { verbs: '' };
|
|
81
|
+
|
|
82
|
+
/** True when the chord can arm at all — any preset slot on any lane. */
|
|
83
|
+
export function hasAnyPreset(p: {
|
|
84
|
+
agent: Record<number, unknown>;
|
|
85
|
+
glass: Record<number, unknown>;
|
|
86
|
+
sidebar: Record<number, unknown>;
|
|
87
|
+
zoom?: Record<number, unknown>;
|
|
88
|
+
}): boolean {
|
|
89
|
+
return (
|
|
90
|
+
Object.keys(p.agent).length > 0 ||
|
|
91
|
+
Object.keys(p.glass).length > 0 ||
|
|
92
|
+
Object.keys(p.sidebar).length > 0 ||
|
|
93
|
+
Object.keys(p.zoom ?? {}).length > 0
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Is the event target an editable surface? The wiring calls this FIRST —
|
|
98
|
+
* typing "1" in a composer must never fire a preset. */
|
|
99
|
+
export function isEditableTarget(t: unknown): boolean {
|
|
100
|
+
const el = t as { closest?: (sel: string) => unknown } | null;
|
|
101
|
+
if (!el || typeof el.closest !== 'function') return false;
|
|
102
|
+
return el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') != null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One key event through the machine. now is injectable for tests.
|
|
106
|
+
*
|
|
107
|
+
* ARM ON KEYDOWN — keyup is unreachable through window keydown wiring,
|
|
108
|
+
* and an arm-on-keyup rule would eat the chord it just armed: keyups
|
|
109
|
+
* pass through untouched. */
|
|
110
|
+
export function chordKey(
|
|
111
|
+
state: ChordState,
|
|
112
|
+
ev: ChordKey,
|
|
113
|
+
canArm: boolean,
|
|
114
|
+
now: number = Date.now(),
|
|
115
|
+
tables: ChordTables = NO_TABLES
|
|
116
|
+
): { state: ChordState; action: ChordAction } {
|
|
117
|
+
const key = ev.key.toLowerCase();
|
|
118
|
+
|
|
119
|
+
if (ev.type === 'keyup') return { state, action: { type: 'none' } };
|
|
120
|
+
|
|
121
|
+
// PHYSICAL identity: code='KeyS'/'KeyT' survives the macOS Alt remap;
|
|
122
|
+
// key==='s'/'t' stays as the fallback for code-less events. The
|
|
123
|
+
// arming key NAMES the prefix; the prefix owns the follow-up table.
|
|
124
|
+
const terminalPrefix = tables.terminalPrefix ?? 't';
|
|
125
|
+
const prefix: ChordPrefix | null =
|
|
126
|
+
ev.code === 'KeyS' || (key === 's' && !ev.code)
|
|
127
|
+
? 's'
|
|
128
|
+
: ev.code === 'KeyT' || (key === 't' && !ev.code)
|
|
129
|
+
? 't'
|
|
130
|
+
: null;
|
|
131
|
+
// Digits: Digit1..9 first (matches even with Alt still held), key
|
|
132
|
+
// fallback. Digits are the FIRST prefix's lanes — under the terminal
|
|
133
|
+
// prefix they disarm (the table is truthful).
|
|
134
|
+
const digit =
|
|
135
|
+
state.prefix === 's'
|
|
136
|
+
? (/^Digit([1-9])$/.exec(ev.code ?? '')?.[1] ?? (/^[1-9]$/.test(ev.key) ? ev.key : null))
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
// Arming: a clean Alt+<prefix key> keydown (no ctrl/meta — browser verbs).
|
|
140
|
+
if (prefix && ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
|
141
|
+
if (!canArm) return { state, action: { type: 'none' } };
|
|
142
|
+
// last prefix wins — an armed chord re-arms cleanly.
|
|
143
|
+
return { state: { armed: true, since: now, prefix }, action: { type: 'none' } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!state.armed) return { state, action: { type: 'none' } };
|
|
147
|
+
|
|
148
|
+
// The window expired on this keypress: disarm first, key never counts.
|
|
149
|
+
if (now - state.since > CHORD_WINDOW_MS) {
|
|
150
|
+
return { state: IDLE_CHORD, action: { type: 'disarm' } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A digit fires the preset (the operator taps; we don't grade the phase).
|
|
154
|
+
if (digit) {
|
|
155
|
+
return { state: IDLE_CHORD, action: { type: 'apply', n: Number(digit) } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A verb letter fires its table action — code first (the same
|
|
159
|
+
// physical-identity rule as arming), key fallback after. The LETTER
|
|
160
|
+
// SETS are the consumer's tables (D5), never this module's.
|
|
161
|
+
const letter =
|
|
162
|
+
(/^[a-z]$/.test(ev.key) ? ev.key : undefined) ??
|
|
163
|
+
(/^Key([A-Z])$/.exec(ev.code ?? '')?.[1]?.toLowerCase());
|
|
164
|
+
if (letter) {
|
|
165
|
+
if (state.prefix === terminalPrefix && tables.terminal) {
|
|
166
|
+
const verb = tables.terminal[letter];
|
|
167
|
+
if (verb === undefined) return { state: IDLE_CHORD, action: { type: 'disarm' } };
|
|
168
|
+
return { state: IDLE_CHORD, action: { type: 'verb', verb, prefix: state.prefix } };
|
|
169
|
+
}
|
|
170
|
+
if (tables.verbs.includes(letter)) {
|
|
171
|
+
return { state: IDLE_CHORD, action: { type: 'verb', verb: letter, prefix: state.prefix } };
|
|
172
|
+
}
|
|
173
|
+
return { state: IDLE_CHORD, action: { type: 'disarm' } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Any other key disarms.
|
|
177
|
+
return { state: IDLE_CHORD, action: { type: 'disarm' } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The capture-first decision as a PURE function so the route adapter
|
|
181
|
+
* stays three lines and the capture rules unit-test without DOM.
|
|
182
|
+
* Ordering IS the contract: (1) a clean arming keydown captures
|
|
183
|
+
* everywhere; (2) an armed chord captures the next keydown everywhere;
|
|
184
|
+
* (3) anything else — including an un-armed digit on an editable
|
|
185
|
+
* target — passes. preventDefault is the swallowing the composer must
|
|
186
|
+
* never see. */
|
|
187
|
+
export function decideChordKeydown(
|
|
188
|
+
state: ChordState,
|
|
189
|
+
ev: ChordKey,
|
|
190
|
+
canArm: boolean,
|
|
191
|
+
now: number = Date.now(),
|
|
192
|
+
tables: ChordTables = NO_TABLES
|
|
193
|
+
): { state: ChordState; action: ChordAction; preventDefault: boolean } {
|
|
194
|
+
const k = ev.key.toLowerCase();
|
|
195
|
+
const isArm =
|
|
196
|
+
((ev.code === 'KeyS' || (k === 's' && !ev.code)) ||
|
|
197
|
+
(ev.code === 'KeyT' || (k === 't' && !ev.code))) &&
|
|
198
|
+
ev.altKey &&
|
|
199
|
+
!ev.ctrlKey &&
|
|
200
|
+
!ev.metaKey;
|
|
201
|
+
if (ev.type === 'keydown' && isArm) {
|
|
202
|
+
return { ...chordKey(state, ev, canArm, now, tables), preventDefault: true };
|
|
203
|
+
}
|
|
204
|
+
if (state.armed && ev.type === 'keydown') {
|
|
205
|
+
return { ...chordKey(state, ev, canArm, now, tables), preventDefault: true };
|
|
206
|
+
}
|
|
207
|
+
return { state, action: { type: 'none' }, preventDefault: false };
|
|
208
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
27
|
+
export function createGutterResize(handlers: {
|
|
28
|
+
/** Gutter mousedown: return the SNAPSHOT width (the width the drag
|
|
29
|
+
* moves away from), or null to ignore this gesture. */
|
|
30
|
+
onStart: (e: MouseEvent) => number | null;
|
|
31
|
+
/** The host's bounds — a pure width -> width clamp. */
|
|
32
|
+
clamp: (width: number) => number;
|
|
33
|
+
/** Apply the clamped width — called per window mousemove while armed. */
|
|
34
|
+
onResize: (width: number, e: MouseEvent) => void;
|
|
35
|
+
/** Settle on window mouseup: persist, arm afterglow, etc. The final
|
|
36
|
+
* clamped width is the one the last resize produced. */
|
|
37
|
+
onSettle?: (width: number) => void;
|
|
38
|
+
}): GutterResizeMachine {
|
|
39
|
+
let armed = false;
|
|
40
|
+
let snapshot = 0;
|
|
41
|
+
let lastWidth = 0;
|
|
42
|
+
|
|
43
|
+
function onMove(e: MouseEvent): void {
|
|
44
|
+
if (!armed) return;
|
|
45
|
+
lastWidth = handlers.clamp(snapshot + (e.clientX - lastStartX));
|
|
46
|
+
handlers.onResize(lastWidth, e);
|
|
47
|
+
}
|
|
48
|
+
function onUp(e: MouseEvent): void {
|
|
49
|
+
if (!armed) return;
|
|
50
|
+
armed = false;
|
|
51
|
+
window.removeEventListener('mousemove', onMove);
|
|
52
|
+
window.removeEventListener('mouseup', onUp);
|
|
53
|
+
handlers.onSettle?.(lastWidth);
|
|
54
|
+
}
|
|
55
|
+
let lastStartX = 0;
|
|
56
|
+
return {
|
|
57
|
+
start(e: MouseEvent): void {
|
|
58
|
+
const snap = handlers.onStart(e);
|
|
59
|
+
if (snap === null) return;
|
|
60
|
+
snapshot = snap;
|
|
61
|
+
lastStartX = e.clientX;
|
|
62
|
+
lastWidth = handlers.clamp(snap);
|
|
63
|
+
armed = true;
|
|
64
|
+
window.addEventListener('mousemove', onMove);
|
|
65
|
+
window.addEventListener('mouseup', onUp);
|
|
66
|
+
},
|
|
67
|
+
get active(): boolean {
|
|
68
|
+
return armed;
|
|
69
|
+
},
|
|
70
|
+
destroy(): void {
|
|
71
|
+
armed = false;
|
|
72
|
+
window.removeEventListener('mousemove', onMove);
|
|
73
|
+
window.removeEventListener('mouseup', onUp);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
|
|
18
|
+
/** The structural core every pane must expose (satisfied by any consumer
|
|
19
|
+
* pane with an id and a width; the kind tag rides through opaquely). */
|
|
20
|
+
export interface PaneCore {
|
|
21
|
+
id: string;
|
|
22
|
+
width: number;
|
|
23
|
+
/** Opaque kind tag — passed through to the injected clamp only. */
|
|
24
|
+
kind?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A consumer's single width bound site (load-side sanitize AND drags). */
|
|
28
|
+
export type WidthClamp = (width: number, kind?: string) => number;
|
|
29
|
+
|
|
30
|
+
/** Move direction, named in the INVOKING surface's geometry: horizontal
|
|
31
|
+
* header chevrons vs vertical rows. The floor owner maps both pairs onto
|
|
32
|
+
* one array operation — left/up toward the start, right/down toward the
|
|
33
|
+
* end. */
|
|
34
|
+
export type PaneMoveDir = 'left' | 'right' | 'up' | 'down';
|
|
35
|
+
|
|
36
|
+
const identityClamp: WidthClamp = (width) => width;
|
|
37
|
+
|
|
38
|
+
/** Plain drag: ONLY the pane left of the dragged gutter changes — its
|
|
39
|
+
* snapshot width plus delta, passed through the injected clamp. Other
|
|
40
|
+
* panes keep their widths untouched (the row's total grows/shrinks; the
|
|
41
|
+
* floor viewport scrolls when the widened row overflows it). */
|
|
42
|
+
export function resizeOne<P extends PaneCore>(
|
|
43
|
+
panels: readonly P[],
|
|
44
|
+
snapshots: readonly number[],
|
|
45
|
+
index: number,
|
|
46
|
+
delta: number,
|
|
47
|
+
clamp: WidthClamp = identityClamp
|
|
48
|
+
): P[] {
|
|
49
|
+
const target = clamp(snapshots[index] + delta, panels[index]?.kind as string | undefined);
|
|
50
|
+
return panels.map((p, i) => (i === index ? { ...p, width: target } : p));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shift+drag: EVERY pane scales by its snapshot share of the row — ratios
|
|
54
|
+
* are preserved (new_i/new_j = snap_i/snap_j) until a pane saturates at a
|
|
55
|
+
* bound; an extreme drag clamps the row. A degenerate row (total <= 0)
|
|
56
|
+
* returns the input order untouched. */
|
|
57
|
+
export function scaleProportionally<P extends PaneCore>(
|
|
58
|
+
panels: readonly P[],
|
|
59
|
+
snapshots: readonly number[],
|
|
60
|
+
delta: number,
|
|
61
|
+
clamp: WidthClamp = identityClamp
|
|
62
|
+
): P[] {
|
|
63
|
+
const total = snapshots.reduce((sum, w) => sum + w, 0);
|
|
64
|
+
if (total <= 0) return [...panels];
|
|
65
|
+
return panels.map((p, i) => ({
|
|
66
|
+
...p,
|
|
67
|
+
width: clamp(snapshots[i] + (snapshots[i] / total) * delta, panels[i]?.kind as string | undefined)
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Honest-slider check: a row is uniform when every width equals the
|
|
72
|
+
* first (an empty row is not — nothing to sync the preset to). */
|
|
73
|
+
export function isUniformRow<P extends PaneCore>(panels: readonly P[]): boolean {
|
|
74
|
+
if (panels.length === 0) return false;
|
|
75
|
+
const first = panels[0].width;
|
|
76
|
+
return panels.every((p) => p.width === first);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Move one pane one slot toward the direction's edge — left/up swap with
|
|
80
|
+
* the predecessor, right/down with the successor. A no-op returning the
|
|
81
|
+
* input order at either edge or for an unknown pane id. */
|
|
82
|
+
export function movePanelWithin<P extends PaneCore>(
|
|
83
|
+
panels: readonly P[],
|
|
84
|
+
panelId: string,
|
|
85
|
+
dir: PaneMoveDir
|
|
86
|
+
): P[] {
|
|
87
|
+
const idx = panels.findIndex((p) => p.id === panelId);
|
|
88
|
+
if (idx === -1) return [...panels];
|
|
89
|
+
const target = dir === 'left' || dir === 'up' ? idx - 1 : idx + 1;
|
|
90
|
+
if (target < 0 || target >= panels.length) return [...panels];
|
|
91
|
+
const next = [...panels];
|
|
92
|
+
[next[idx], next[target]] = [next[target], next[idx]];
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
|
|
7
|
+
/** The slot directly BELOW an anchor pane — anchor + 1 — or the caller's
|
|
8
|
+
* fallback when no pane carries the anchor (anchorIndex < 0). The
|
|
9
|
+
* fork-child / below-the-explorer slot rule. */
|
|
10
|
+
export function slotAfter(anchorIndex: number, fallback: number): number {
|
|
11
|
+
return anchorIndex >= 0 ? anchorIndex + 1 : fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Insert ONE item at an anchor index. Boundary anchors clamp: an empty
|
|
15
|
+
* list (or an anchor past the end) appends; a negative anchor inserts at
|
|
16
|
+
* the front. Ordering is stable — existing members never reorder. */
|
|
17
|
+
export function insertAtAnchor<T>(list: readonly T[], anchorIndex: number, item: T): T[] {
|
|
18
|
+
const at = Math.max(0, Math.min(anchorIndex, list.length));
|
|
19
|
+
const next = [...list];
|
|
20
|
+
next.splice(at, 0, item);
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The focused-slot rule: a fresh item lands at the FOCUSED index —
|
|
25
|
+
* immediately before it. 0 when nothing is focused (negative or past the
|
|
26
|
+
* end) — front-add stays the fallback; Math.max keeps splice's
|
|
27
|
+
* negative-index semantics out of the no-selection case. */
|
|
28
|
+
export function focusedSlot(selectedIndex: number, listLength: number): number {
|
|
29
|
+
return selectedIndex >= 0 && selectedIndex < listLength ? selectedIndex : 0;
|
|
30
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
|
|
11
|
+
type AnyHandler = (...args: never[]) => unknown;
|
|
12
|
+
|
|
13
|
+
export interface ActionTable {
|
|
14
|
+
/** Set (or overwrite) an action's handler; null unregisters. */
|
|
15
|
+
register(action: string, handler: AnyHandler | null): void;
|
|
16
|
+
/** True when a handler is currently registered for the action. */
|
|
17
|
+
has(action: string): boolean;
|
|
18
|
+
/** Run the action's handler if registered.
|
|
19
|
+
* @returns true when a handler ran; false otherwise. */
|
|
20
|
+
invoke(action: string, ...args: unknown[]): boolean;
|
|
21
|
+
/** Like invoke, but returns the HANDLER'S OWN return value — undefined
|
|
22
|
+
* when no handler is registered. For result-carrying actions (e.g. a
|
|
23
|
+
* handler that reports a miss) where 'did it run' and 'what did it
|
|
24
|
+
* say' are different questions. */
|
|
25
|
+
invokeForResult(action: string, ...args: unknown[]): unknown;
|
|
26
|
+
/** True when ANY handler is registered (a host is mounted). */
|
|
27
|
+
isActive(): boolean;
|
|
28
|
+
/** Clear every slot (isolation between tests / host destroy). */
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createActionTable(): ActionTable {
|
|
33
|
+
const slots = new Map<string, AnyHandler>();
|
|
34
|
+
return {
|
|
35
|
+
register(action: string, handler: AnyHandler | null): void {
|
|
36
|
+
if (handler === null) slots.delete(action);
|
|
37
|
+
else slots.set(action, handler);
|
|
38
|
+
},
|
|
39
|
+
has(action: string): boolean {
|
|
40
|
+
return slots.has(action);
|
|
41
|
+
},
|
|
42
|
+
invoke(action: string, ...args: unknown[]): boolean {
|
|
43
|
+
const handler = slots.get(action);
|
|
44
|
+
if (handler === undefined) return false;
|
|
45
|
+
// The table is payload-agnostic by construction; the consumer's
|
|
46
|
+
// register-time types are the contract, invoke erases shape.
|
|
47
|
+
(handler as (...a: unknown[]) => unknown)(...args);
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
invokeForResult(action: string, ...args: unknown[]): unknown {
|
|
51
|
+
const handler = slots.get(action);
|
|
52
|
+
if (handler === undefined) return undefined;
|
|
53
|
+
return (handler as (...a: unknown[]) => unknown)(...args);
|
|
54
|
+
},
|
|
55
|
+
isActive(): boolean {
|
|
56
|
+
return slots.size > 0;
|
|
57
|
+
},
|
|
58
|
+
reset(): void {
|
|
59
|
+
slots.clear();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
|
|
25
|
+
export interface PrefsStoreOptions<T> {
|
|
26
|
+
/** The un-suffixed localStorage key (namespaced per profile). */
|
|
27
|
+
key: string;
|
|
28
|
+
/** Persisted defaults — a factory, so callers never share object state. */
|
|
29
|
+
defaults: () => T;
|
|
30
|
+
/** Parse a parsed-JSON value into prefs; return null to reject the blob
|
|
31
|
+
* as junk (load falls back to defaults). */
|
|
32
|
+
revive: (raw: unknown) => T | null;
|
|
33
|
+
/** Optional bound pass, applied to the revived value (after revive). */
|
|
34
|
+
clamp?: (prefs: T) => T;
|
|
35
|
+
/** Opt-in `{v, data}` envelope — new consumers only (D4); read-side
|
|
36
|
+
* still accepts a bare (legacy) blob so adoption is one-way safe. */
|
|
37
|
+
envelope?: { version: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PrefsStore<T> {
|
|
41
|
+
/** Read from localStorage; defaults on SSR, absence, junk, or error. */
|
|
42
|
+
load(profile?: string | null): T;
|
|
43
|
+
/** Write to localStorage (best-effort; no-op under SSR). */
|
|
44
|
+
save(prefs: T, profile?: string | null): void;
|
|
45
|
+
/** The exact key this store reads/writes for a profile. */
|
|
46
|
+
key(profile?: string | null): string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the parsed JSON carries the envelope wrapper. */
|
|
50
|
+
function isEnvelope(raw: unknown): raw is { v: number; data: unknown } {
|
|
51
|
+
if (raw === null || typeof raw !== 'object') return false;
|
|
52
|
+
const rec = raw as Record<string, unknown>;
|
|
53
|
+
return 'v' in rec && 'data' in rec;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createPrefsStore<T>(options: PrefsStoreOptions<T>): PrefsStore<T> {
|
|
57
|
+
const { key, defaults, revive, clamp, envelope } = options;
|
|
58
|
+
const keyFor = (profile?: string | null) => storageKey(key, profile);
|
|
59
|
+
const finalize = (prefs: T): T => (clamp ? clamp(prefs) : prefs);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
key: keyFor,
|
|
63
|
+
load(profile?: string | null): T {
|
|
64
|
+
if (typeof localStorage === 'undefined') return defaults();
|
|
65
|
+
try {
|
|
66
|
+
const raw = localStorage.getItem(keyFor(profile));
|
|
67
|
+
if (!raw) return defaults();
|
|
68
|
+
const parsed: unknown = JSON.parse(raw);
|
|
69
|
+
const body = envelope !== undefined && isEnvelope(parsed) ? parsed.data : parsed;
|
|
70
|
+
const revived = revive(body);
|
|
71
|
+
if (revived === null) return defaults();
|
|
72
|
+
return finalize(revived);
|
|
73
|
+
} catch {
|
|
74
|
+
return defaults();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
save(prefs: T, profile?: string | null): void {
|
|
78
|
+
if (typeof localStorage === 'undefined') return;
|
|
79
|
+
try {
|
|
80
|
+
const payload = envelope !== undefined ? { v: envelope.version, data: prefs } : prefs;
|
|
81
|
+
localStorage.setItem(keyFor(profile), JSON.stringify(payload));
|
|
82
|
+
} catch {
|
|
83
|
+
// Store full or blocked — persistence is lost, the app is not.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage namespace — the ONE home of the workspace-profile key convention
|
|
3
|
+
* (generalized from DSI's storage-profile.ts; ADR-0062 D4). Consumer keys
|
|
4
|
+
* keep their exact existing strings: the suffix rule is the only thing
|
|
5
|
+
* this module owns.
|
|
6
|
+
*
|
|
7
|
+
* no profile → my-key
|
|
8
|
+
* ?profile=widi → my-key_widi
|
|
9
|
+
*
|
|
10
|
+
* The profile is sticky and sanitized here once — every prefs store routes
|
|
11
|
+
* its key through the namespace fn so no two call sites re-implement it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sanitize a raw `?profile=` value into a storage-key-safe segment.
|
|
16
|
+
* Keeps `[A-Za-z0-9_-]` (case preserved — profiles are exact ids), caps at
|
|
17
|
+
* 40 chars; anything that sanitizes to nothing is not a profile.
|
|
18
|
+
* @returns the safe segment, or null when the value carries no usable
|
|
19
|
+
* characters (the caller then behaves as if no profile were set).
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeWorkspaceProfile(raw: string): string | null {
|
|
22
|
+
const safe = raw.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
|
|
23
|
+
return safe.length > 0 ? safe : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The storage key for a workspace profile.
|
|
28
|
+
* @param base the un-suffixed storage key (e.g. 'dsi-panels').
|
|
29
|
+
* @param profile sanitized profile segment, or null/undefined for the
|
|
30
|
+
* default desk (the base key unchanged).
|
|
31
|
+
*/
|
|
32
|
+
export function storageKey(base: string, profile?: string | null): string {
|
|
33
|
+
return profile ? `${base}_${profile}` : base;
|
|
34
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @deepseek-insight/container-base — default theme tokens (ADR-0062 W5,
|
|
3
|
+
* D7): a fresh consumer is styled out of the box; the host OVERRIDES every
|
|
4
|
+
* var (DSI's theme defines all of them, byte-identical visuals).
|
|
5
|
+
*
|
|
6
|
+
* --zoom is NOT a theme token: it is a per-frame runtime variable the
|
|
7
|
+
* PanelsZoom component writes itself.
|
|
8
|
+
*
|
|
9
|
+
* Ground rule (dev/article/2026-11/04.re-architect, 2026-11-14): styling
|
|
10
|
+
* crosses ONLY through tokens — components never carry a skin. W6 added
|
|
11
|
+
* the chord-palette tokens after the boundary audit found the overlay
|
|
12
|
+
* wearing Tailwind palette classes.
|
|
13
|
+
*/
|
|
14
|
+
:root {
|
|
15
|
+
/* Surfaces */
|
|
16
|
+
--color-surface-primary: #ffffff;
|
|
17
|
+
--color-surface-secondary: #f1f3f5;
|
|
18
|
+
--color-surface-elevated: #ffffff;
|
|
19
|
+
--color-surface-border: #dee2e6;
|
|
20
|
+
--color-header-wash: #fff8ec;
|
|
21
|
+
|
|
22
|
+
/* Text */
|
|
23
|
+
--color-text-primary: #212529;
|
|
24
|
+
--color-text-secondary: #6c757d;
|
|
25
|
+
|
|
26
|
+
/* Accents (the selection ring gradient stops) */
|
|
27
|
+
--color-accent-blue: #3b82f6;
|
|
28
|
+
--color-accent-purple: #a855f7;
|
|
29
|
+
--color-accent-rose: #f43f5e;
|
|
30
|
+
|
|
31
|
+
/* Chord palette (the armed overlay's decay ticker — W6 ground rule:
|
|
32
|
+
* theming crosses only through tokens; the component ships no skin) */
|
|
33
|
+
--color-chord-armed: #86efac;
|
|
34
|
+
--color-chord-idle: #fef08a;
|
|
35
|
+
}
|