@mosetta/ui 0.0.0-stage → 0.0.10

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.
@@ -0,0 +1,229 @@
1
+ import { useState } from 'react';
2
+ import { STYLE_PROPERTIES } from '@mosetta/core';
3
+ import type { IStyleProperty, IStyleVariable, TStyles, TStyleState } from '@mosetta/core';
4
+ import { cn } from '../cn';
5
+
6
+ // The form half of the STYLER (the runtime half is `useStyler` in @mosetta/react): a block's
7
+ // editor facet drops it in with the block's parts and the node's `styles` setting, and its users
8
+ // restyle each part — always, under the pointer, or on a narrow screen — from the properties the
9
+ // styler allows. Only the properties set are listed; "Add a style" offers the rest by group.
10
+ //
11
+ // <StylerForm parts={[{ name: 'root', label: 'Button' }]}
12
+ // value={draft.styles}
13
+ // variables={useThemeVariables()}
14
+ // onChange={(styles) => update({ styles })} />
15
+ //
16
+ // `variables` — the theme variables around the block (the editor knows them): a colour, a font or
17
+ // a size may name one (`var(--theme-accent)`) instead of a literal, and follows the theme then.
18
+
19
+ export interface IStylerPart {
20
+ /** the name the block's runtime marks the element with (`styler.part('root')`) */
21
+ readonly name: string;
22
+ /** what the form calls it */
23
+ readonly label: string;
24
+ }
25
+
26
+ export interface IStylerFormProps {
27
+ readonly parts: readonly IStylerPart[];
28
+ readonly value: TStyles | undefined;
29
+ readonly onChange: (styles: TStyles) => void;
30
+ /** the theme variables a value may name; none — literals only */
31
+ readonly variables?: readonly IStyleVariable[];
32
+ }
33
+
34
+ const STATES: readonly { state: TStyleState; label: string; hint: string }[] = [
35
+ { state: 'base', label: 'Always', hint: 'How the part looks' },
36
+ { state: 'hover', label: 'Hover', hint: 'Under the pointer' },
37
+ { state: 'current', label: 'Current', hint: 'The link to where the reader is: this page, the chosen category or language' },
38
+ { state: 'mobile', label: 'Mobile', hint: 'On screens up to 640px wide' },
39
+ ];
40
+
41
+ const GROUP_LABELS: Record<IStyleProperty['group'], string> = {
42
+ text: 'Text',
43
+ box: 'Box',
44
+ border: 'Border',
45
+ layout: 'Layout',
46
+ effects: 'Effects',
47
+ };
48
+
49
+ const tab = (active: boolean) => cn(
50
+ 'dui:h-7 dui:rounded-md dui:px-2 dui:text-xs dui:font-medium',
51
+ active ? 'dui:bg-foreground dui:text-background' : 'dui:text-muted-foreground dui:hover:bg-accent',
52
+ );
53
+
54
+ const HEX = /^#[0-9a-f]{6}$/i;
55
+ const VAR_RE = /^var\((--[a-z0-9-]+)\)$/;
56
+
57
+ /** which theme variables suit a property: colours for a colour, fonts for a font, the rest for sizes */
58
+ function kindOf(property: IStyleProperty): IStyleVariable['kind'] | null {
59
+ if (property.input === 'choice') return null;
60
+ if (property.input === 'color') return 'color';
61
+ return property.name === 'fontFamily' ? 'font' : 'value';
62
+ }
63
+
64
+ function PropertyInput({ property, value, onChange, variables }: {
65
+ property: IStyleProperty;
66
+ value: string;
67
+ onChange: (value: string) => void;
68
+ variables: readonly IStyleVariable[];
69
+ }) {
70
+ const field = 'dui:h-8 dui:w-full dui:min-w-0 dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:px-2 dui:text-sm';
71
+ const kind = kindOf(property);
72
+ const suitable = kind ? variables.filter((v) => v.kind === kind) : [];
73
+ const named = VAR_RE.exec(value.trim())?.[1] ?? null;
74
+ const variable = named ? variables.find((v) => v.name === named) ?? null : null;
75
+ // a value naming a variable shows that variable's colour in the swatch
76
+ const swatch = variable?.value ?? value;
77
+ // naming a variable, the list IS the value (the text field steps aside); a literal keeps the
78
+ // field and a narrow list beside it
79
+ const themed = suitable.length ? (
80
+ <select className={variable
81
+ ? 'dui:h-8 dui:w-full dui:min-w-0 dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:px-2 dui:text-sm'
82
+ : 'dui:h-8 dui:w-9 dui:shrink-0 dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:px-1 dui:text-sm'}
83
+ aria-label={`${property.label} from the theme`}
84
+ data-style-variable={property.name}
85
+ data-tooltip="Take it from the theme: it changes with the theme"
86
+ value={variable ? variable.name : ''}
87
+ onChange={(e) => onChange(e.target.value ? `var(${e.target.value})` : (variable?.value ?? ''))}>
88
+ {/* closed, a literal's list is a glyph wide — the field keeps the room */}
89
+ <option value="">{variable ? 'a value of its own' : '◐'}</option>
90
+ {suitable.map((v) => <option key={v.name}
91
+ value={v.name}>{v.label}{v.value ? ` · ${v.value}` : ''}</option>)}
92
+ </select>
93
+ ) : null;
94
+ if (property.input === 'choice') {
95
+ return (
96
+ <select className={field}
97
+ value={value}
98
+ data-style-field={property.name}
99
+ onChange={(e) => onChange(e.target.value)}>
100
+ <option value="">—</option>
101
+ {property.choices!.map((c) => <option key={c}
102
+ value={c}>{c}</option>)}
103
+ </select>
104
+ );
105
+ }
106
+ return (
107
+ <div className="dui:flex dui:w-full dui:min-w-0 dui:items-center dui:gap-1">
108
+ {property.input === 'color' && (
109
+ <input type="color"
110
+ aria-label={`${property.label} colour`}
111
+ className="dui:h-8 dui:w-8 dui:shrink-0 dui:cursor-pointer dui:rounded-md dui:border dui:border-input dui:bg-transparent dui:p-0.5"
112
+ value={HEX.test(swatch) ? swatch : '#000000'}
113
+ onChange={(e) => onChange(e.target.value)} />
114
+ )}
115
+ {variable ? null : (
116
+ <input className={field}
117
+ value={value}
118
+ placeholder={property.placeholder ?? (property.input === 'color' ? '#1b1f33 or var(--theme-accent)' : '')}
119
+ spellCheck={false}
120
+ data-style-field={property.name}
121
+ onChange={(e) => onChange(e.target.value)} />
122
+ )}
123
+ {themed}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ const NO_VARIABLES: readonly IStyleVariable[] = [];
129
+
130
+ export function StylerForm({ parts, value, onChange, variables = NO_VARIABLES }: IStylerFormProps) {
131
+ const [part, setPart] = useState(parts[0]?.name ?? 'root');
132
+ const [state, setState] = useState<TStyleState>('base');
133
+ const styles = value ?? {};
134
+ const declarations = styles[part]?.[state] ?? {};
135
+ const set = Object.keys(declarations);
136
+
137
+ const write = (next: Record<string, string>) => {
138
+ const partStyles = { ...styles[part], [state]: next };
139
+ if (!Object.keys(next).length) delete (partStyles as Record<string, unknown>)[state];
140
+ const all = { ...styles, [part]: partStyles };
141
+ if (!Object.keys(partStyles).length) delete (all as Record<string, unknown>)[part];
142
+ onChange(all);
143
+ };
144
+ const remaining = STYLE_PROPERTIES.filter((p) => !set.includes(p.name));
145
+ const groups = [...new Set(remaining.map((p) => p.group))];
146
+
147
+ return (
148
+ // the styles of a block's parts: in an editor's settings, the Styles tab (data-inspector-tab)
149
+ <div className="dui:flex dui:flex-col dui:gap-2"
150
+ data-styler
151
+ data-inspector-tab="styles">
152
+ {parts.length > 1 && (
153
+ <div className="dui:flex dui:flex-wrap dui:gap-1"
154
+ role="tablist"
155
+ aria-label="Part">
156
+ {parts.map((p) => (
157
+ <button key={p.name}
158
+ type="button"
159
+ role="tab"
160
+ aria-selected={p.name === part}
161
+ className={tab(p.name === part)}
162
+ data-styler-part={p.name}
163
+ data-tooltip={`Style the ${p.label.toLowerCase()}`}
164
+ onClick={() => setPart(p.name)}>
165
+ {p.label}
166
+ {styles[p.name] ? ' •' : ''}
167
+ </button>
168
+ ))}
169
+ </div>
170
+ )}
171
+ <div className="dui:flex dui:gap-1"
172
+ role="tablist"
173
+ aria-label="When">
174
+ {STATES.map((s) => (
175
+ <button key={s.state}
176
+ type="button"
177
+ role="tab"
178
+ aria-selected={s.state === state}
179
+ className={tab(s.state === state)}
180
+ data-styler-state={s.state}
181
+ data-tooltip={s.hint}
182
+ onClick={() => setState(s.state)}>
183
+ {s.label}
184
+ {styles[part]?.[s.state] ? ' •' : ''}
185
+ </button>
186
+ ))}
187
+ </div>
188
+ {set.map((name) => {
189
+ const property = STYLE_PROPERTIES.find((p) => p.name === name)!;
190
+ return (
191
+ <div key={name}
192
+ className="dui:grid dui:grid-cols-[96px_1fr_auto] dui:items-center dui:gap-2">
193
+ <span className="dui:truncate dui:text-xs dui:text-muted-foreground">{property.label}</span>
194
+ <PropertyInput property={property}
195
+ value={declarations[name] ?? ''}
196
+ variables={variables}
197
+ onChange={(v) => write({ ...declarations, [name]: v })} />
198
+ <button type="button"
199
+ className="dui:h-8 dui:w-8 dui:rounded-md dui:text-muted-foreground dui:hover:bg-accent"
200
+ aria-label={`Remove ${property.label}`}
201
+ data-tooltip={`Remove ${property.label.toLowerCase()} — the block's own look comes back`}
202
+ onClick={() => {
203
+ const { [name]: _gone, ...rest } = declarations;
204
+ write(rest);
205
+ }}>
206
+ ×
207
+ </button>
208
+ </div>
209
+ );
210
+ })}
211
+ <select className="dui:h-8 dui:w-full dui:rounded-md dui:border dui:border-dashed dui:border-input dui:bg-transparent dui:px-2 dui:text-sm dui:text-muted-foreground"
212
+ value=""
213
+ data-styler-add
214
+ aria-label="Add a style"
215
+ onChange={(e) => {
216
+ if (e.target.value) write({ ...declarations, [e.target.value]: '' });
217
+ }}>
218
+ <option value="">{set.length ? 'Add another style…' : 'Add a style…'}</option>
219
+ {groups.map((g) => (
220
+ <optgroup key={g}
221
+ label={GROUP_LABELS[g]}>
222
+ {remaining.filter((p) => p.group === g).map((p) => <option key={p.name}
223
+ value={p.name}>{p.label}</option>)}
224
+ </optgroup>
225
+ ))}
226
+ </select>
227
+ </div>
228
+ );
229
+ }
@@ -0,0 +1,98 @@
1
+ // Typography primitives for the ui-kit distributive (heading/text protocol modules render these).
2
+ // Not shadcn canon — but same conventions: variants + `dui:` prefix (scoped tailwind).
3
+ import { type HTMLAttributes } from 'react';
4
+ import { cn } from '../cn';
5
+ import { variants, type VariantProps } from '../variants';
6
+
7
+ const headingVariants = variants({
8
+ base: 'dui:text-foreground dui:tracking-tight',
9
+ variants: {
10
+ level: {
11
+ 1: 'dui:text-4xl dui:font-extrabold',
12
+ 2: 'dui:text-3xl dui:font-semibold',
13
+ 3: 'dui:text-2xl dui:font-semibold',
14
+ 4: 'dui:text-xl dui:font-semibold',
15
+ },
16
+ },
17
+ defaultVariants: { level: 2 },
18
+ });
19
+
20
+ export interface IHeadingProps extends HTMLAttributes<HTMLHeadingElement> {
21
+ readonly level?: 1 | 2 | 3 | 4;
22
+ }
23
+
24
+ export function Heading({ level = 2, className, ...props }: IHeadingProps) {
25
+ const Tag = `h${level}` as const;
26
+ return <Tag className={headingVariants({ level, className })}
27
+ {...props} />;
28
+ }
29
+
30
+ const textVariants = variants({
31
+ base: 'dui:text-foreground',
32
+ variants: {
33
+ tone: {
34
+ default: '',
35
+ muted: 'dui:text-muted-foreground',
36
+ destructive: 'dui:text-destructive',
37
+ },
38
+ size: {
39
+ sm: 'dui:text-sm',
40
+ default: 'dui:text-base',
41
+ lg: 'dui:text-lg',
42
+ },
43
+ },
44
+ defaultVariants: { tone: 'default', size: 'default' },
45
+ });
46
+
47
+ export interface ITextProps
48
+ extends HTMLAttributes<HTMLParagraphElement>,
49
+ VariantProps<{ tone: Record<'default' | 'muted' | 'destructive', string>;
50
+ size: Record<'sm' | 'default' | 'lg', string> }> {}
51
+
52
+ export function Text({ tone, size, className, ...props }: ITextProps) {
53
+ return <p className={textVariants({ tone, size, className })}
54
+ {...props} />;
55
+ }
56
+
57
+ // ---- article blocks: lists and quotes ---------------------------------------------------------
58
+
59
+ const listVariants = variants({
60
+ base: 'dui:text-foreground dui:pl-6 dui:space-y-1',
61
+ variants: {
62
+ ordered: {
63
+ true: 'dui:list-decimal',
64
+ false: 'dui:list-disc',
65
+ },
66
+ },
67
+ defaultVariants: { ordered: 'false' },
68
+ });
69
+
70
+ export interface IListProps extends HTMLAttributes<HTMLUListElement | HTMLOListElement> {
71
+ readonly ordered?: boolean;
72
+ }
73
+
74
+ export function List({ ordered = false, className, ...props }: IListProps) {
75
+ const Tag = ordered ? 'ol' : 'ul';
76
+ return <Tag className={listVariants({ ordered: ordered ? 'true' : 'false', className })}
77
+ {...(props)} />;
78
+ }
79
+
80
+ export function ListItem({ className, ...props }: HTMLAttributes<HTMLLIElement>) {
81
+ return <li className={cn('dui:text-base', className)}
82
+ {...props} />;
83
+ }
84
+
85
+ export interface IBlockquoteProps extends HTMLAttributes<HTMLQuoteElement> {
86
+ /** who said it — rendered as a footer line after the quoted blocks */
87
+ readonly cite?: string;
88
+ }
89
+
90
+ export function Blockquote({ cite, className, children, ...props }: IBlockquoteProps) {
91
+ return (
92
+ <blockquote className={cn('dui:border-l-4 dui:border-border dui:pl-4 dui:italic dui:text-muted-foreground dui:space-y-2', className)}
93
+ {...props}>
94
+ {children}
95
+ {cite ? <footer className="dui:not-italic dui:text-sm">— {cite}</footer> : null}
96
+ </blockquote>
97
+ );
98
+ }
@@ -0,0 +1,212 @@
1
+ // The dock ARRANGEMENT on the wire: the user's layout as a query-parameter-sized string, so the
2
+ // address bar — not localStorage — carries it. An address survives a browser restart, a duplicated
3
+ // tab, and travels to another person as a link; a localStorage blob does none of that, and its
4
+ // shape is dockview's serialization model, not data. The format below is deliberately minimal —
5
+ // rows and columns with tab names and percent sizes — and every character of it is legal RAW in a
6
+ // URL query (RFC 3986), so nothing percent-encodes: the address stays exactly this readable.
7
+ // Measured against deflate+base64url on real workbench strings the plain text is SHORTER
8
+ // (~85 chars vs ~106): compression pays its header on strings this small, so there is none.
9
+ //
10
+ // Grammar (orientation alternates with depth; the root lays out COLUMNS unless it starts with
11
+ // `-`, which flips it to rows):
12
+ //
13
+ // arrangement = ["-"] item ("," item)* (";" reference)*
14
+ // item = [size ":"] node size — integer percent of the parent's axis;
15
+ // items without one split the remainder equally
16
+ // node = "(" item ("," item)* ")" a nested branch (orientation flipped)
17
+ // | tab ("~" tab)* ONE group: its tabs in row order
18
+ // tab = name ["!"] "!" — the group's front tab (default: the first)
19
+ // name = authored-panel-id e.g. `tree` — must not start with a digit or `_`
20
+ // | "_" a blank tab (the dock's empty content)
21
+ // | ordinal "*" ref-index a component tab: its `component:N` ordinal and an
22
+ // index into the `;`-appended reference dictionary
23
+ //
24
+ // Example: `20:(60:tree,palette),(1*0!~2*0),settings;_ns_x_t_component_n_canvas_v_0_0_1`
25
+ // — three columns: tree over palette (60/40) at 20% width, one group with two canvas tabs
26
+ // (the first in front), and settings taking what remains.
27
+ //
28
+ // The parser is TOLERANT the way the address bar demands: a malformed tab is dropped, an emptied
29
+ // group or branch dissolves, and a string with nothing left parses to null — the dock then builds
30
+ // its default layout. Component-tab ordinals are preserved verbatim because other parameters key
31
+ // on the `component:N` ids (the editor's ?canvas= does). Titles are NOT serialized: content that
32
+ // cares names its own tab through the DockTab contract once it mounts.
33
+
34
+ /** dynamic blank tabs live under this id prefix, outside the authored panel set */
35
+ export const EMPTY_TAB_PREFIX = 'empty:';
36
+
37
+ /** dynamic component tabs (opened by reference) live under this one */
38
+ export const COMPONENT_TAB_PREFIX = 'component:';
39
+
40
+ export interface IArrangedTab {
41
+ /** an authored panel id, `component:N`, or `empty:N` */
42
+ readonly id: string;
43
+ /** component tabs only: what the tab shows */
44
+ readonly reference?: string;
45
+ /** the group's front tab (serialized as `!` when it is not simply the first) */
46
+ readonly active?: boolean;
47
+ }
48
+
49
+ export interface IArrangedGroup {
50
+ readonly kind: 'group';
51
+ /** integer percent of the parent's axis; absent — an equal split of the remainder */
52
+ readonly sizePercent?: number;
53
+ readonly tabs: readonly IArrangedTab[];
54
+ }
55
+
56
+ export interface IArrangedBranch {
57
+ readonly kind: 'branch';
58
+ readonly sizePercent?: number;
59
+ readonly items: readonly TArrangedNode[];
60
+ }
61
+
62
+ export type TArrangedNode = IArrangedGroup | IArrangedBranch;
63
+
64
+ export interface IArrangement {
65
+ /** the root lays out columns (true) or rows; orientation alternates with every nesting */
66
+ readonly horizontal: boolean;
67
+ readonly items: readonly TArrangedNode[];
68
+ }
69
+
70
+ const AUTHORED_ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
71
+ const REFERENCE = /^[A-Za-z0-9_-]+$/;
72
+ const COMPONENT_TAB = /^(\d+)\*(\d+)$/;
73
+ const SIZE_PREFIX = /^(\d{1,2}):/;
74
+
75
+ function tabText(tab: IArrangedTab, references: string[]): string | null {
76
+ const mark = tab.active ? '!' : '';
77
+ if (tab.id.startsWith(COMPONENT_TAB_PREFIX)) {
78
+ const ordinal = tab.id.slice(COMPONENT_TAB_PREFIX.length);
79
+ if (!/^\d+$/.test(ordinal) || !tab.reference || !REFERENCE.test(tab.reference)) return null;
80
+ const known = references.indexOf(tab.reference);
81
+ const index = known === -1 ? references.push(tab.reference) - 1 : known;
82
+ return `${ordinal}*${index}${mark}`;
83
+ }
84
+ if (tab.id.startsWith(EMPTY_TAB_PREFIX)) return `_${mark}`;
85
+ return AUTHORED_ID.test(tab.id) ? `${tab.id}${mark}` : null;
86
+ }
87
+
88
+ /** the items of one branch as text; recursion carries nesting, the LAST item never carries a size */
89
+ function itemsText(items: readonly TArrangedNode[], references: string[]): string | null {
90
+ const texts = items
91
+ .map<{ node: TArrangedNode; text: string | null }>((node) => {
92
+ if (node.kind === 'branch') {
93
+ const inner = itemsText(node.items, references);
94
+ return { node, text: inner === null ? null : `(${inner})` };
95
+ }
96
+ const tabs = node.tabs
97
+ .map((tab) => tabText(tab, references))
98
+ .filter((text): text is string => text !== null);
99
+ return { node, text: tabs.length === 0 ? null : tabs.join('~') };
100
+ })
101
+ .filter((entry): entry is { node: TArrangedNode; text: string } => entry.text !== null);
102
+ if (texts.length === 0) return null;
103
+ return texts
104
+ .map(({ node, text }, index) => {
105
+ const size = node.sizePercent === undefined ? undefined : Math.round(node.sizePercent);
106
+ const last = index === texts.length - 1;
107
+ return !last && size !== undefined && size >= 1 && size <= 99 ? `${size}:${text}` : text;
108
+ })
109
+ .join(',');
110
+ }
111
+
112
+ /** `null` when there is nothing to say — the parameter should leave the address */
113
+ export function serializeArrangement(arrangement: IArrangement): string | null {
114
+ const references: string[] = [];
115
+ const items = itemsText(arrangement.items, references);
116
+ if (items === null) return null;
117
+ const dictionary = references.map((reference) => `;${reference}`).join('');
118
+ return `${arrangement.horizontal ? '' : '-'}${items}${dictionary}`;
119
+ }
120
+
121
+ interface IParseState {
122
+ readonly references: readonly string[];
123
+ readonly taken: Set<string>;
124
+ emptySeq: number;
125
+ }
126
+
127
+ function parseTab(text: string, state: IParseState): IArrangedTab | null {
128
+ const active = text.endsWith('!');
129
+ const name = active ? text.slice(0, -1) : text;
130
+ const withActive = active ? { active: true } : {};
131
+ if (name === '_') {
132
+ state.emptySeq += 1;
133
+ return { id: `${EMPTY_TAB_PREFIX}${state.emptySeq}`, ...withActive };
134
+ }
135
+ const component = COMPONENT_TAB.exec(name);
136
+ if (component) {
137
+ const reference = state.references[Number(component[2])];
138
+ const id = `${COMPONENT_TAB_PREFIX}${Number(component[1])}`;
139
+ if (!reference || state.taken.has(id)) return null;
140
+ state.taken.add(id);
141
+ return { id, reference, ...withActive };
142
+ }
143
+ if (!AUTHORED_ID.test(name) || state.taken.has(name)) return null;
144
+ state.taken.add(name);
145
+ return { id: name, ...withActive };
146
+ }
147
+
148
+ /** split on `separator` at paren depth zero */
149
+ function splitTop(text: string, separator: string): string[] {
150
+ const parts: string[] = [];
151
+ let depth = 0;
152
+ let current = '';
153
+ for (const char of text) {
154
+ if (char === '(') depth += 1;
155
+ else if (char === ')') depth = Math.max(0, depth - 1);
156
+ if (char === separator && depth === 0) {
157
+ parts.push(current);
158
+ current = '';
159
+ } else {
160
+ current += char;
161
+ }
162
+ }
163
+ parts.push(current);
164
+ return parts.filter((part) => part.length > 0);
165
+ }
166
+
167
+ function balanced(text: string): boolean {
168
+ let depth = 0;
169
+ for (const char of text) {
170
+ if (char === '(') depth += 1;
171
+ else if (char === ')') {
172
+ depth -= 1;
173
+ if (depth < 0) return false;
174
+ }
175
+ }
176
+ return depth === 0;
177
+ }
178
+
179
+ /** the items of one branch; recursion carries nesting, malformed items are dropped */
180
+ function parseItems(text: string, state: IParseState): TArrangedNode[] | null {
181
+ const items = splitTop(text, ',').flatMap<TArrangedNode>((part) => {
182
+ const sized = SIZE_PREFIX.exec(part);
183
+ const sizePercent = sized ? Number(sized[1]) : undefined;
184
+ const body = sized ? part.slice(sized[0].length) : part;
185
+ const withSize = sizePercent !== undefined && sizePercent >= 1 ? { sizePercent } : {};
186
+ if (body.startsWith('(')) {
187
+ if (!body.endsWith(')') || !balanced(body)) return [];
188
+ const inner = parseItems(body.slice(1, -1), state);
189
+ if (inner === null) return [];
190
+ // a one-item wrapper is no branch at all — the item takes its place (and its size)
191
+ if (inner.length === 1) return [{ ...inner[0], ...withSize }];
192
+ return [{ kind: 'branch', ...withSize, items: inner }];
193
+ }
194
+ const tabs = body.split('~').flatMap((piece) => {
195
+ const tab = parseTab(piece, state);
196
+ return tab === null ? [] : [tab];
197
+ });
198
+ return tabs.length === 0 ? [] : [{ kind: 'group', ...withSize, tabs }];
199
+ });
200
+ return items.length === 0 ? null : items;
201
+ }
202
+
203
+ /** tolerant of hand-mangled addresses; `null` means "build the default layout" */
204
+ export function parseArrangement(value: string | null): IArrangement | null {
205
+ if (!value) return null;
206
+ const [tree, ...references] = value.split(';');
207
+ if (!tree || references.some((reference) => !REFERENCE.test(reference))) return null;
208
+ const horizontal = !tree.startsWith('-');
209
+ const body = horizontal ? tree : tree.slice(1);
210
+ const items = parseItems(body, { references, taken: new Set<string>(), emptySeq: 0 });
211
+ return items === null ? null : { horizontal, items };
212
+ }
@@ -0,0 +1,139 @@
1
+ // The DOCK LAYOUT CONTRACT: how content cooperates with a dock that hosts it — entirely OPTIONAL
2
+ // on both sides. The dock (a @mosetta/ui-kit-react block) registers a DockLayout in the page's
3
+ // DI container and hands each panel a scoped DockTab; content pulls them with `useDiOptional` and
4
+ // simply does nothing when they are absent (rendered outside any dock, or inside a dock that
5
+ // predates the contract). The contract lives HERE — in the shared base library — because DI
6
+ // tokens match by INSTANCE: both the dock and its guests must import the very same dependency
7
+ // artifact, and @mosetta/ui is the one library both already build against.
8
+ //
9
+ // A tab's CONTENT is a component reference, not a slot: the authored children of the dock are
10
+ // only the offered first-visit layout, and any module may ask the dock to open any component —
11
+ // which is how a page holding nothing but a dock and a blank-tab block winds itself up into a
12
+ // full workbench, click by click, the layout (references included) persisting as a preference.
13
+ //
14
+ // The per-panel handles (DockTab, DockTabRow) travel through DI SCOPES: the dock registers each
15
+ // handle in the panel's own scope, and content resolves it up the scope chain (useDiOptional) —
16
+ // no wrapped containers anywhere on the way.
17
+ import { SingleDiToken } from '@mosetta/di';
18
+
19
+ export type TDockSide = 'left' | 'right' | 'bottom' | 'center';
20
+
21
+ /** an open tab as the layout manager reports it */
22
+ export interface IDockTabInfo {
23
+ readonly id: string;
24
+ readonly title: string;
25
+ /** the root reference of what the tab shows (an authored child's root, or the opened component) */
26
+ readonly reference?: string;
27
+ /** whatever the tab's content whispered about itself (setMeta) — e.g. the open draft id */
28
+ readonly meta: Readonly<Record<string, unknown>>;
29
+ /** the tab is the one its group shows */
30
+ readonly front?: boolean;
31
+ }
32
+
33
+ /** a panel of the AUTHORED first-visit set — what `open(id)` and the arrangement resolve first */
34
+ export interface IDockOpenable {
35
+ readonly id: string;
36
+ readonly title: string;
37
+ }
38
+
39
+ /** a component to open as a tab — the reference IS the tab's identity for find-or-open */
40
+ export interface IDockComponent {
41
+ readonly reference: string;
42
+ readonly title: string;
43
+ readonly settings?: Record<string, unknown>;
44
+ }
45
+
46
+ export interface IDockOpenOptions {
47
+ /** skip the find-by-reference dedupe: another INSTANCE of an already-open component */
48
+ readonly fresh?: boolean;
49
+ /** open as a tab of the group holding THIS tab (id), instead of the active group */
50
+ readonly beside?: string;
51
+ }
52
+
53
+ /**
54
+ * One wish of an arrangement: resolved in order — an OPEN tab by `id`, an open tab by
55
+ * `reference`, the authored set by `id`, and finally `reference` opens the component fresh at
56
+ * the wished side. A wish may carry only an `id` (authored sets), only a `reference`
57
+ * (bootstrap from nothing), or both.
58
+ */
59
+ export interface IDockWant {
60
+ readonly id?: string;
61
+ readonly reference?: string;
62
+ readonly title?: string;
63
+ readonly dock?: TDockSide;
64
+ readonly settings?: Record<string, unknown>;
65
+ }
66
+
67
+ /**
68
+ * The dock's layout manager: a DI service proxying the dock's own view api. The dock component
69
+ * registers one instance per realm and re-attaches it on every re-mount; whoever needs to drive
70
+ * the layout resolves it optionally and stays functional without it.
71
+ */
72
+ export abstract class DockLayout {
73
+ /** bring an AUTHORED panel forward by its id; a closed one is re-created at its declared side */
74
+ abstract open(id: string): void;
75
+
76
+ /** find a tab showing this component (activate), or open it as a fresh tab; returns the tab id */
77
+ abstract openComponent(component: IDockComponent, options?: IDockOpenOptions): string | null;
78
+
79
+ /** the offered arrangement: the primary beside the widest group, companions in their columns */
80
+ abstract arrange(primary: IDockWant, companions?: readonly IDockWant[]): void;
81
+
82
+ /** activate an OPEN tab by its id (see tabs()) */
83
+ abstract activate(tabId: string): void;
84
+
85
+ /**
86
+ * Name an OPEN tab by its id. Content usually names its own tab (DockTab), but an INACTIVE
87
+ * address-restored tab never mounts its content — whoever restores it says what it holds.
88
+ */
89
+ abstract rename(tabId: string, title: string): void;
90
+
91
+ /** every open tab with its content-reported meta */
92
+ abstract tabs(): IDockTabInfo[];
93
+
94
+ /** the AUTHORED panel set — what `open(id)` can resolve without a reference */
95
+ abstract openable(): IDockOpenable[];
96
+
97
+ /** fires on every layout change (tabs opened/closed/moved); returns the unsubscribe */
98
+ abstract onDidChange(listener: () => void): () => void;
99
+ }
100
+
101
+ export const DOCK_LAYOUT_TOKEN = new SingleDiToken<DockLayout>('DOCK_LAYOUT_TOKEN');
102
+
103
+ /**
104
+ * The CURRENT TAB, scoped to a panel's content: the dock provides one per panel, so a module
105
+ * can name its own tab after what it shows and report identity meta for tabs() lookups.
106
+ */
107
+ export abstract class DockTab {
108
+ abstract readonly id: string;
109
+
110
+ abstract rename(title: string): void;
111
+
112
+ /** merge identity facts about the content (e.g. { editingDraftId }) into the tab's meta */
113
+ abstract setMeta(meta: Record<string, unknown>): void;
114
+
115
+ /** fires when THIS tab becomes the active one of its group; returns the unsubscribe */
116
+ abstract onDidActivate(listener: () => void): () => void;
117
+
118
+ /**
119
+ * Whether this tab is the one its group shows RIGHT NOW. A host that keeps every tab mounted
120
+ * (the workbench shell) answers it so content mounting behind the front tab does not act as
121
+ * if it were in front; a host that cannot say leaves it undefined, and content assumes it is.
122
+ */
123
+ front?(): boolean;
124
+ }
125
+
126
+ export const DOCK_TAB_TOKEN = new SingleDiToken<DockTab>('DOCK_TAB_TOKEN');
127
+
128
+ /**
129
+ * The TAB ROW a header-slotted control renders in (one instance per group): lets a "new tab"
130
+ * button open things as tabs of ITS OWN row rather than at a declared side.
131
+ */
132
+ export abstract class DockTabRow {
133
+ abstract openHere(id: string): void;
134
+
135
+ /** a FRESH tab in this row, rendering the dock's empty content — a blank page to start from */
136
+ abstract openEmpty(): void;
137
+ }
138
+
139
+ export const DOCK_TAB_ROW_TOKEN = new SingleDiToken<DockTabRow>('DOCK_TAB_ROW_TOKEN');