@orbytes/astrolab 0.4.0-next.1 → 0.4.0-next.2

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 (50) hide show
  1. package/README.md +184 -84
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +7 -20
  4. package/docs/PIN-CONTRACT.md +76 -10
  5. package/docs/PIN.md +93 -23
  6. package/index.d.ts +1 -7
  7. package/index.mjs +14 -81
  8. package/package.json +2 -2
  9. package/src/Home.astro +7 -8
  10. package/src/LabHead.astro +1 -1
  11. package/src/chrome/ActionsMenu.astro +97 -0
  12. package/src/chrome/ComponentCard.astro +9 -2
  13. package/src/chrome/Nav.astro +36 -10
  14. package/src/chrome/Panel.astro +17 -4
  15. package/src/chrome/Properties.astro +104 -0
  16. package/src/chrome/SectionsTree.astro +128 -0
  17. package/src/chrome/Shell.astro +20 -6
  18. package/src/chrome/StoryView.astro +103 -162
  19. package/src/chrome/Tree.astro +56 -53
  20. package/src/chrome/ViewportControls.astro +136 -61
  21. package/src/chrome/ViewportStage.astro +26 -3
  22. package/src/chrome/icons.ts +9 -0
  23. package/src/chrome/marks-client.ts +26 -53
  24. package/src/chrome/model.ts +14 -0
  25. package/src/chrome/navbar-client.ts +324 -0
  26. package/src/chrome/params-client.ts +434 -0
  27. package/src/chrome/pins-data.ts +42 -9
  28. package/src/chrome/shell-client.ts +99 -3
  29. package/src/chrome/trees.ts +112 -7
  30. package/src/chrome/viewport-client.ts +68 -242
  31. package/src/chrome/views/Assets.astro +21 -6
  32. package/src/chrome/views/Pages.astro +90 -54
  33. package/src/chrome/views/Placeholder.astro +3 -3
  34. package/src/chrome/views/Tasks.astro +12 -40
  35. package/src/core/LICENSE-astrobook +5 -0
  36. package/src/core/utils/kebab-case.ts +2 -2
  37. package/src/pin/board.mjs +25 -15
  38. package/src/pin/index.mjs +34 -20
  39. package/src/pin/tickets.mjs +6 -5
  40. package/src/pin/toolbar.js +81 -3
  41. package/src/shell/Browse.astro +35 -10
  42. package/src/shell/lab-index.ts +5 -4
  43. package/src/shell/lab-params.ts +113 -6
  44. package/src/shell/live-files.mjs +212 -10
  45. package/src/shell/marks.mjs +17 -41
  46. package/src/ui/components/preview-layout.astro +17 -0
  47. package/src/ui/components/theme-script.astro +4 -3
  48. package/src/ui/lab.css +2167 -566
  49. package/virtual.d.ts +0 -4
  50. package/bin/lab-cull.mjs +0 -401
@@ -860,6 +860,10 @@ export default defineToolbarApp({
860
860
  }
861
861
 
862
862
  let mode = 'idle'; // idle | picking | selected
863
+ /** Whether Astro has this app toggled on — the lab's Comment button shows it as pressed. */
864
+ let appActive = false;
865
+ /** Set by the lab's Comment: the element whose box confines the pick (› the lab channel). */
866
+ let scope = null;
863
867
  let hoverEl = null;
864
868
  let selectedEl = null;
865
869
  /**
@@ -1633,6 +1637,7 @@ export default defineToolbarApp({
1633
1637
  syncMarkers();
1634
1638
  // The count on the toolbar button is read from the same list, so it moves with it.
1635
1639
  updateTicketsButton();
1640
+ publish();
1636
1641
  }
1637
1642
  }
1638
1643
 
@@ -2280,6 +2285,7 @@ export default defineToolbarApp({
2280
2285
 
2281
2286
  if (next === 'picking') {
2282
2287
  if (!surface.isConnected) canvas.append(surface);
2288
+ fitSurface();
2283
2289
  hideChrome(hoverHighlight);
2284
2290
  // The picker owns clicks while it is armed — a marker must not swallow one that
2285
2291
  // was meant to pin the element underneath it.
@@ -2300,6 +2306,18 @@ export default defineToolbarApp({
2300
2306
  clearCrumbs();
2301
2307
  hideChrome(selectedHighlight);
2302
2308
  }
2309
+ publish();
2310
+ }
2311
+
2312
+ /** The surface covers the page — or only the scope's box, when the lab's Comment set one. */
2313
+ function fitSurface() {
2314
+ const r = scope && scope.isConnected ? scope.getBoundingClientRect() : null;
2315
+ Object.assign(
2316
+ surface.style,
2317
+ r
2318
+ ? { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px`, right: 'auto', bottom: 'auto' }
2319
+ : { left: '0', top: '0', right: '0', bottom: '0', width: '', height: '' },
2320
+ );
2303
2321
  }
2304
2322
 
2305
2323
  function hideChrome(el) {
@@ -2362,6 +2380,7 @@ export default defineToolbarApp({
2362
2380
  }
2363
2381
 
2364
2382
  function reposition() {
2383
+ if (mode === 'picking') fitSurface();
2365
2384
  if (mode === 'picking' && hoverEl) {
2366
2385
  place(hoverHighlight, hoverEl, hoverFrames);
2367
2386
  const { source } = resolveSource(hoverEl);
@@ -2424,6 +2443,10 @@ export default defineToolbarApp({
2424
2443
  * @returns {{ el: Element|null, frames: Element[], note: string|null }}
2425
2444
  */
2426
2445
  function hitTest(x, y) {
2446
+ if (scope) {
2447
+ const r = scope.getBoundingClientRect();
2448
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) return { el: null, frames: [], note: null };
2449
+ }
2427
2450
  let doc = document;
2428
2451
  let px = x;
2429
2452
  let py = y;
@@ -2439,7 +2462,8 @@ export default defineToolbarApp({
2439
2462
  }
2440
2463
 
2441
2464
  const frame = frameUnder(el, px, py);
2442
- if (!frame) return { el, frames: frames.slice(), note: null };
2465
+ // A scoped pick (the lab's Comment) pins what is IN the frame, never the lab around it.
2466
+ if (!frame) return scope && !frames.length ? { el: null, frames: [], note: null } : { el, frames: frames.slice(), note: null };
2443
2467
 
2444
2468
  const inner = frameDocument(frame);
2445
2469
  if (!inner) {
@@ -2881,6 +2905,19 @@ export default defineToolbarApp({
2881
2905
  // box constantly — and a dictated paragraph cannot be retyped from memory. So Escape
2882
2906
  // never discards text: with anything in the box it holds the selection, the words and
2883
2907
  // the caret, and points at Cancel, which is the one control that discards on purpose.
2908
+ //
2909
+ // Inside the lab, Escape ends Comment mode rather than stopping at idle. Decided
2910
+ // 2026-09-24: the lab's Comment is one mode — pick, write, send — so wherever Escape would
2911
+ // leave this app on and idle ("Nothing selected", with Comment still pressed), it turns the
2912
+ // app off instead, exactly as pressing Comment again does. Only a pick the lab started has a
2913
+ // `scope`; the toolbar's own button keeps the steps above. The branch that holds a comment
2914
+ // still runs first, and turning the app off never empties the box, so a word typed here
2915
+ // survives every Escape.
2916
+ function leaveOrIdle() {
2917
+ if (scope) app.toggleState({ state: false });
2918
+ else setMode('idle');
2919
+ }
2920
+
2884
2921
  function onKeydown(event) {
2885
2922
  // The climb. Capture phase and preventDefault, because ⌥↑ in a textarea is
2886
2923
  // "move the caret to the start of the paragraph" on macOS and would otherwise
@@ -2904,7 +2941,8 @@ export default defineToolbarApp({
2904
2941
  swallowEscapeKeyup = true;
2905
2942
  // The badge and the hint line both change on setMode — saying it again in words
2906
2943
  // was the log talking to itself.
2907
- setMode(selectedEl ? 'selected' : 'idle');
2944
+ if (selectedEl) setMode('selected');
2945
+ else leaveOrIdle();
2908
2946
  } else if (mode === 'selected') {
2909
2947
  event.preventDefault();
2910
2948
  event.stopPropagation();
@@ -2915,7 +2953,7 @@ export default defineToolbarApp({
2915
2953
  return;
2916
2954
  }
2917
2955
  clearSelection();
2918
- setMode('idle');
2956
+ leaveOrIdle();
2919
2957
  } else if (panelOpen) {
2920
2958
  // Idle, with the panel open. Escape peels one layer at a time — the expanded entry,
2921
2959
  // then the panel — and only once both are shut does it reach Astro's own
@@ -2936,6 +2974,13 @@ export default defineToolbarApp({
2936
2974
  } else {
2937
2975
  closePanel();
2938
2976
  }
2977
+ } else if (scope && appActive) {
2978
+ // Idle in the lab, with nothing left to peel: close the app here rather than hope
2979
+ // Astro's own Escape handler is armed, which it is only after a real hover.
2980
+ event.preventDefault();
2981
+ event.stopPropagation();
2982
+ swallowEscapeKeyup = true;
2983
+ leaveOrIdle();
2939
2984
  }
2940
2985
  }
2941
2986
 
@@ -2951,6 +2996,8 @@ export default defineToolbarApp({
2951
2996
  /* ---------- lifecycle ---------- */
2952
2997
 
2953
2998
  app.onToggled(({ state }) => {
2999
+ appActive = Boolean(state);
3000
+ if (!state) scope = null;
2954
3001
  if (state) {
2955
3002
  setMode(selectedEl ? 'selected' : 'picking');
2956
3003
  // Astro hides an app's whole canvas when the app is toggled off, so the markers go
@@ -3129,6 +3176,37 @@ export default defineToolbarApp({
3129
3176
  }
3130
3177
  });
3131
3178
 
3179
+ /* ---------- the lab's channel ---------- */
3180
+
3181
+ /**
3182
+ * The component lab drives this app from its own navbar — the Comment button, and the pins
3183
+ * it draws over its framed preview — and imports nothing from this file, so the door in and
3184
+ * the window out are plain window events:
3185
+ *
3186
+ * in `orbytes-pin:command` { action: 'pick', within?: Element } | { action: 'stop' }
3187
+ * out `orbytes-pin:state` { active, mode, tickets } after every mode change, toggle and
3188
+ * board read; the same snapshot sits on `window.__orbytesPin` for a late listener.
3189
+ *
3190
+ * `within` confines the pick to that element's box, and to what lies inside a frame there,
3191
+ * so Comment pins the previewed page and never the lab's chrome around it. The toolbar's own
3192
+ * button still opens the app unscoped, exactly as before.
3193
+ */
3194
+ function publish() {
3195
+ const detail = { active: appActive, mode, tickets: panelTickets };
3196
+ window.__orbytesPin = detail;
3197
+ window.dispatchEvent(new CustomEvent('orbytes-pin:state', { detail }));
3198
+ }
3199
+ window.addEventListener('orbytes-pin:command', (event) => {
3200
+ const { action, within } = (event && event.detail) || {};
3201
+ if (action === 'pick') {
3202
+ scope = within instanceof Element ? within : null;
3203
+ if (appActive) setMode(selectedEl ? 'selected' : 'picking');
3204
+ else app.toggleState({ state: true });
3205
+ } else if (action === 'stop') {
3206
+ app.toggleState({ state: false });
3207
+ }
3208
+ });
3209
+
3132
3210
  setMode('idle');
3133
3211
  restoreAfterReload();
3134
3212
  // Read the board once at start-up so the markers are on the page the moment the app is
@@ -5,7 +5,9 @@
5
5
  // sub-folder they sit in, and the panel's search and filter narrow both at once.
6
6
  //
7
7
  // REDESIGNED 2026-09-24 onto the chrome shell (../chrome/Shell.astro). Cards are per component
8
- // now, not per story — a component's stories are tabs on its own page.
8
+ // now, not per story — a component's stories are its variants, chosen on its own page. The title
9
+ // bar is the listing one (Figma `Lab / Listing`): title, counts, and filter chips that drive the
10
+ // same filter as level 2's menu. On the sections tier, level 2 is the Sections tree.
9
11
  //
10
12
  // Pure chrome: this page renders no story. Its thumbnails are iframes of /lab/stories/<id>, which
11
13
  // are their own documents and get the consumer's CSS there (decided 2026-09-22 — ../ui/lab.css).
@@ -73,26 +75,49 @@ const filters: FilterOption[] = [
73
75
  : []),
74
76
  ];
75
77
  const live = mods.filter((m) => m.live).length;
78
+ const used = mods.filter((m) => !m.live && m.usedBy.length).length;
79
+ const unused = mods.length - live - used;
76
80
  const stories = mods.reduce((n, m) => n + m.stories.length, 0);
81
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
82
+ // What one of these is called: "sections" on the sections tier, "components" anywhere else.
83
+ const noun = tier ? tierLabel(tier).toLowerCase() : "components";
84
+ const counts = [
85
+ segments.length > 1 ? segments.slice(0, -1).map((s, i) => (i === 0 ? tierLabel(s) : s)).join(" / ") : null,
86
+ plural(mods.length, noun.replace(/s$/, ""), noun),
87
+ plural(stories, "variant"),
88
+ live > 0 ? `${live} live` : null,
89
+ used > 0 ? `${used} used` : null,
90
+ unused > 0 ? `${unused} unused` : null,
91
+ ].filter(Boolean);
92
+ // The title bar's filter chips set the same filter as level 2's menu (./../chrome/shell-client.ts).
93
+ const chips = [
94
+ { value: "", label: "All", n: mods.length },
95
+ { value: "live", label: "Live", n: live },
96
+ { value: "used", label: "Used", n: used },
97
+ { value: "unused", label: "Unused", n: unused },
98
+ ];
77
99
  ---
78
100
 
79
101
  <Shell
80
102
  title={title}
81
103
  active={tier ? `tier:${tier}` : "home"}
104
+ navbarClass="lab-navbar--listing"
82
105
  panel={folder ? { label: tierLabel(tier), crumbs, filters, filterLabel: "Show components", searchPlaceholder: `Search ${tierLabel(tier).toLowerCase()}` } : undefined}
83
106
  >
84
107
  {folder && <Tree slot="panel" nodes={tierTree(index, tier)} />}
85
108
 
86
109
  <Fragment slot="navbar">
87
- <div class="lab-navbar__id">
88
- <h1 class="lab-navbar__title">
89
- {title}
90
- {segments.length > 1 && <small>{segments.slice(0, -1).map((s, i) => (i === 0 ? tierLabel(s) : s)).join(" / ")}</small>}
91
- </h1>
92
- <div class="lab-navbar__meta">
93
- <span class="lab-pill">{mods.length} component{mods.length === 1 ? "" : "s"}</span>
94
- <span class="lab-pill">{stories} stor{stories === 1 ? "y" : "ies"}</span>
95
- {live > 0 && <span class="lab-pill lab-pill--live">{live} live</span>}
110
+ <div class="lab-listing-bar">
111
+ <h1 class="lab-listing-bar__title">{title}</h1>
112
+ <p class="lab-listing-bar__counts">{counts.join(" · ")}</p>
113
+ <div class="lab-chips" role="radiogroup" aria-label="Show">
114
+ {
115
+ chips.map((chip) => (
116
+ <button class="lab-chip" type="button" role="radio" aria-checked={String(chip.value === "")} data-lab-filter={chip.value}>
117
+ {chip.label} {chip.n}
118
+ </button>
119
+ ))
120
+ }
96
121
  </div>
97
122
  </div>
98
123
  </Fragment>
@@ -75,13 +75,14 @@ export interface LabItem {
75
75
  livePage: string | null;
76
76
  /** every page that mounts it — one entry on a normal site, more for shared chrome */
77
77
  liveOn: LiveMount[];
78
- /** section versions only (tier "sections"); null everywhere the mark does not apply */
78
+ /** section versions only (the tier holding the responsive role); null everywhere else */
79
79
  responsive: { done: boolean; approved: boolean } | null;
80
80
  /** the same story on the deployed lab; null when no `stagingUrl` is configured */
81
81
  stagingUrl: string | null;
82
82
  /** vscode://file/<absolute path> to the component; null in a build — the link is dev-only */
83
83
  editorUrl: string | null;
84
- /** repo-relative files of LIVE section components that import this item's componentFile */
84
+ /** repo-relative files that import this item's componentFile and are live themselves (a page
85
+ * mounts them, whatever their tier or folder) */
85
86
  usedBy: string[];
86
87
  tags: string[];
87
88
  summary: string | null;
@@ -149,7 +150,7 @@ const summaryOf = (rootDir: string, moduleFile: string): string | null => {
149
150
  const classify = (directory: string) => {
150
151
  const parts = directory ? directory.split("/") : [];
151
152
  const tier = parts[0] ?? "";
152
- const sectional = tier === "sections";
153
+ const sectional = labConfig.sectionsTier !== null && tier === labConfig.sectionsTier;
153
154
  return {
154
155
  tier,
155
156
  section: sectional && parts[1] ? parts[1] : null,
@@ -329,7 +330,7 @@ export const itemsUnder = (index: LabIndex, folderPath: string): LabItem[] =>
329
330
  /**
330
331
  * The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of 12`
331
332
  * — and only a page that is not the home page names itself: `live · /about, slot 3`.
332
- * The sidebar script in LabHead.astro carries the same three lines; keep the two in step.
333
+ * Every live pill in the chrome is built here; nothing else formats one.
333
334
  */
334
335
  export const livePillText = (item: LabItem): string | null => {
335
336
  if (!item.live) return null;
@@ -10,9 +10,9 @@
10
10
  at all.
11
11
 
12
12
  So: a component that has something to tune DECLARES it here and renders nothing. The panel is
13
- drawn by the viewport lab's chrome (./Viewport.astro), between the top bar and the
14
- stage — and only there. The same story at <subpath>/stories/<id> (the bare render) and on the
15
- dashboard shows no panel at all, because nothing in this module touches the DOM.
13
+ drawn by the lab's chrome (../chrome/params-client.ts) as a drawer on the right edge of the
14
+ component page's canvas — and only there. The same story at <subpath>/stories/<id> (the bare
15
+ render) shows no panel at all, because nothing in this module touches the DOM.
16
16
 
17
17
  HOW IT FITS TOGETHER. This module runs INSIDE the story document (the iframe). It keeps the
18
18
  registered groups on `window.__labParams` and announces each one with a `lab:params` event on
@@ -34,7 +34,7 @@
34
34
  with the restored values, so `apply` must tolerate being called before the thing it configures
35
35
  exists. Then:
36
36
 
37
- import { registerLabParams } from "@orbytes/astrolab/params";
37
+ import { LAB_UNITS, parseLabLength, registerLabParams } from "@orbytes/astrolab/params";
38
38
 
39
39
  const shader = document.querySelector<HTMLCanvasElement>(".hero__shader");
40
40
  let uniforms: Uniforms | null = null; // filled in below; apply() tolerates null
@@ -46,6 +46,7 @@
46
46
  note: "Uniforms are read every frame; nothing here is on the live page.",
47
47
  controls: [
48
48
  { kind: "range", id: "speed", label: "Speed", min: 0, max: 4, step: 0.05, value: 1, unit: "×" },
49
+ { kind: "number", id: "warp", label: "Warp", value: 24, unit: "px", units: LAB_UNITS.size },
49
50
  { kind: "toggle", id: "grain", label: "Grain", value: true },
50
51
  { kind: "select", id: "blend", label: "Blend", value: "screen",
51
52
  options: [{ value: "screen", label: "screen" }, { value: "add", label: "add" }] },
@@ -54,6 +55,7 @@
54
55
  apply(values) {
55
56
  if (!uniforms) return;
56
57
  uniforms.speed = Number(values.speed);
58
+ uniforms.warp = parseLabLength(values.warp)?.value ?? 24; // "24px" → 24
57
59
  uniforms.grain = Boolean(values.grain);
58
60
  uniforms.blend = String(values.blend);
59
61
  uniforms.tint = String(values.tint);
@@ -66,14 +68,55 @@
66
68
 
67
69
  That is all. Reset, Copy settings, the collapse chevron, the readouts, the units and the
68
70
  storage are the panel's job, not the component's.
71
+
72
+ ---------------------------------------------------------------------------------------------
73
+ SLIDER OR NUMBER? Decided 2026-09-24: a parameter that is a SIZE — a font size, a tracking, a
74
+ line height, a width, an offset, a blur radius — is never a slider. It is `kind: "number"`: a
75
+ field to type the value into, joined to a select of the units it can be expressed in (72 px,
76
+ 4.5 rem, 12.5 vw). Unless it names its own `units`, it offers px · rem · em · vw · vh · %.
77
+ Sliders stay for unitless tuning values (speed, grain, scale, opacity).
78
+
79
+ A number control hands `apply` ONE CSS-READY STRING — the number and the unit joined, "72px",
80
+ "12.5rem", or "1.05" when the unit is "" — so it drops straight into a style:
81
+
82
+ el.style.setProperty("--headline-size", String(values.headlineSize)); // "72px"
83
+
84
+ and `parseLabLength` takes it apart again when the number itself is wanted (a shader uniform,
85
+ a calculation). Changing the unit keeps the number and changes what it means; the panel never
86
+ converts between units, because px → rem depends on a root size it cannot know.
87
+
88
+ { kind: "number", id: "headlineSize", label: "Headline size", value: 72, unit: "px",
89
+ units: LAB_UNITS.size },
90
+ { kind: "number", id: "tracking", label: "Tracking", value: -0.02, unit: "em",
91
+ units: LAB_UNITS.tracking, step: 0.01 },
92
+ { kind: "number", id: "lineHeight", label: "Line height", value: 1.05, unit: "",
93
+ units: LAB_UNITS.lineHeight, step: 0.01 },
69
94
  ============================================================================================= */
70
95
 
71
96
  export type LabParamValue = number | boolean | string;
72
97
  export type LabParamValues = Record<string, LabParamValue>;
73
98
 
99
+ /**
100
+ * The unit sets a number control usually offers — pass one as `units`, or any list of your own.
101
+ * "" is "no unit" (the drawer shows it as "—"), which is what a line height is at its most
102
+ * useful. Figma writes tracking as a percentage; that is em × 100, so Figma's −2% is −0.02em.
103
+ */
104
+ export const LAB_UNITS = {
105
+ /** Font sizes, widths, offsets, radii — and the set a number control gets when it names none. */
106
+ size: ["px", "rem", "em", "vw", "vh", "%"],
107
+ /** Letter spacing. */
108
+ tracking: ["em", "px"],
109
+ /** Line height: unitless first, because a unitless line height scales with the font. */
110
+ lineHeight: ["", "px"],
111
+ } as const satisfies Record<string, readonly string[]>;
112
+
74
113
  /** One row of the panel. `id` is the key in `values`; `note` is shown on hover. */
75
114
  export type LabParamControl =
76
115
  | {
116
+ /**
117
+ * A slider, for a UNITLESS tuning value — speed, grain, scale. The value is a number and
118
+ * `unit` is only the readout's suffix ("×"). A size is a `number` control instead.
119
+ */
77
120
  kind: "range";
78
121
  id: string;
79
122
  label: string;
@@ -93,7 +136,59 @@ export type LabParamControl =
93
136
  options: { value: string; label: string }[];
94
137
  note?: string;
95
138
  }
96
- | { kind: "color"; id: string; label: string; value: string; note?: string };
139
+ | { kind: "color"; id: string; label: string; value: string; note?: string }
140
+ | {
141
+ /**
142
+ * A SIZE — a number field joined to a unit select (72 px · 4.5 rem · 12.5 vw).
143
+ *
144
+ * The value in `values` is ONE CSS-ready STRING, the number and unit joined: "72px",
145
+ * "12.5rem", "-0.02em", or "1.05" when the unit is "". Put it straight into a style, or
146
+ * take it apart with `parseLabLength`. Changing the unit keeps the number; nothing is
147
+ * converted.
148
+ */
149
+ kind: "number";
150
+ id: string;
151
+ label: string;
152
+ /** The default number, bare: 72. */
153
+ value: number;
154
+ /** The default unit — "px", "em", or "" for none. Added to `units` if it is missing. */
155
+ unit: string;
156
+ /**
157
+ * What the unit select offers, in order. Defaults to `LAB_UNITS.size` (px · rem · em · vw ·
158
+ * vh · %); a tracking wants `LAB_UNITS.tracking`, a line height `LAB_UNITS.lineHeight`.
159
+ * A list of one shows the unit without a choice.
160
+ */
161
+ units?: readonly string[];
162
+ /** What ↑ / ↓ add. Defaults to 1. */
163
+ step?: number;
164
+ /** Optional bounds, applied to typed and stored values alike. */
165
+ min?: number;
166
+ max?: number;
167
+ note?: string;
168
+ };
169
+
170
+ /** The units a number control offers: its declared `units`, with its default `unit` guaranteed. */
171
+ export const labUnitsOf = (control: Extract<LabParamControl, { kind: "number" }>): string[] => {
172
+ const units = [...(control.units ?? LAB_UNITS.size)];
173
+ return units.includes(control.unit) ? units : [control.unit, ...units];
174
+ };
175
+
176
+ /**
177
+ * "72px" → { value: 72, unit: "px" }; "1.05" → { value: 1.05, unit: "" }. Null for anything that
178
+ * is not one number followed by an optional unit — so a value from a number control always
179
+ * parses, and a stray string never quietly becomes 0.
180
+ */
181
+ export const parseLabLength = (raw: unknown): { value: number; unit: string } | null => {
182
+ if (typeof raw === "number") return Number.isFinite(raw) ? { value: raw, unit: "" } : null;
183
+ if (typeof raw !== "string") return null;
184
+ const match = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*([a-z%]*)\s*$/i.exec(raw);
185
+ if (!match) return null;
186
+ const value = Number(match[1]);
187
+ return Number.isFinite(value) ? { value, unit: match[2]!.toLowerCase() } : null;
188
+ };
189
+
190
+ /** The inverse: 72 and "px" → "72px". `String()` already turns −0 into "0". */
191
+ export const formatLabLength = (value: number, unit: string): string => `${String(value)}${unit}`;
97
192
 
98
193
  export interface LabParamGroup {
99
194
  /** Stable, and the localStorage key suffix. One group per thing being tuned. */
@@ -180,14 +275,26 @@ const coerce = (control: LabParamControl, raw: unknown): LabParamValue | undefin
180
275
  return typeof raw === "string" && control.options.some((o) => o.value === raw) ? raw : undefined;
181
276
  case "color":
182
277
  return typeof raw === "string" && /^#[0-9a-f]{3,8}$/i.test(raw) ? raw : undefined;
278
+ case "number": {
279
+ // A bare number is what a slider saved. Reading it in the default unit is what lets a size
280
+ // that used to be a `range` become a `number` without losing the tuning saved for it.
281
+ const parsed = typeof raw === "number" ? parseLabLength(`${raw}${control.unit}`) : parseLabLength(raw);
282
+ if (!parsed || !labUnitsOf(control).includes(parsed.unit)) return undefined;
283
+ const value = Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, parsed.value));
284
+ return formatLabLength(value, parsed.unit);
285
+ }
183
286
  }
184
287
  };
185
288
 
289
+ /** A control's declared default, as `values` holds it. */
290
+ const defaultOf = (control: LabParamControl): LabParamValue =>
291
+ control.kind === "number" ? formatLabLength(control.value, control.unit) : control.value;
292
+
186
293
  export function registerLabParams(group: LabParamGroup): LabParamHandle {
187
294
  const storageKey = group.storageKey ?? `${STORE_PREFIX}${group.id}`;
188
295
 
189
296
  const defaults: LabParamValues = {};
190
- for (const control of group.controls) defaults[control.id] = control.value;
297
+ for (const control of group.controls) defaults[control.id] = defaultOf(control);
191
298
 
192
299
  const values: LabParamValues = { ...defaults };
193
300
  const stored = readStore(storageKey);