@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.
- package/README.md +184 -84
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +7 -20
- package/docs/PIN-CONTRACT.md +76 -10
- package/docs/PIN.md +93 -23
- package/index.d.ts +1 -7
- package/index.mjs +14 -81
- package/package.json +2 -2
- package/src/Home.astro +7 -8
- package/src/LabHead.astro +1 -1
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +9 -2
- package/src/chrome/Nav.astro +36 -10
- package/src/chrome/Panel.astro +17 -4
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +20 -6
- package/src/chrome/StoryView.astro +103 -162
- package/src/chrome/Tree.astro +56 -53
- package/src/chrome/ViewportControls.astro +136 -61
- package/src/chrome/ViewportStage.astro +26 -3
- package/src/chrome/icons.ts +9 -0
- package/src/chrome/marks-client.ts +26 -53
- package/src/chrome/model.ts +14 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +42 -9
- package/src/chrome/shell-client.ts +99 -3
- package/src/chrome/trees.ts +112 -7
- package/src/chrome/viewport-client.ts +68 -242
- package/src/chrome/views/Assets.astro +21 -6
- package/src/chrome/views/Pages.astro +90 -54
- package/src/chrome/views/Placeholder.astro +3 -3
- package/src/chrome/views/Tasks.astro +12 -40
- package/src/core/LICENSE-astrobook +5 -0
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/pin/board.mjs +25 -15
- package/src/pin/index.mjs +34 -20
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +81 -3
- package/src/shell/Browse.astro +35 -10
- package/src/shell/lab-index.ts +5 -4
- package/src/shell/lab-params.ts +113 -6
- package/src/shell/live-files.mjs +212 -10
- package/src/shell/marks.mjs +17 -41
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +4 -3
- package/src/ui/lab.css +2167 -566
- package/virtual.d.ts +0 -4
- package/bin/lab-cull.mjs +0 -401
package/src/pin/toolbar.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/shell/Browse.astro
CHANGED
|
@@ -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
|
|
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-
|
|
88
|
-
<h1 class="lab-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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>
|
package/src/shell/lab-index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 ===
|
|
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
|
-
*
|
|
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;
|
package/src/shell/lab-params.ts
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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);
|