@stnd/lab 0.2.0 → 1.0.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/Lab.svelte ADDED
@@ -0,0 +1,1809 @@
1
+ <script>
2
+ /**
3
+ * @stnd/lab — The Design Lab
4
+ *
5
+ * Svelte 5 island: full-height side panel for live-editing
6
+ * the seed-first color system, typography, and scale tokens.
7
+ * Inspired by https://rsms.me/inter/lab/
8
+ *
9
+ * Integration: <Lab client:load /> inside a transition:persist zone.
10
+ * Keyboard: ⇧⌘S toggle · ⇧⌘D debug · Esc close
11
+ */
12
+ import { onMount } from "svelte";
13
+ import { fly, slide } from "@stnd/ui/transitions.js";
14
+ import bezier from "bezier-easing";
15
+ import { modules as loadedModules } from "virtual:stnd/modules-metadata";
16
+ import { confirm } from "@stnd/ui/dialog.js";
17
+ import Combobox from "../ui/Combobox.svelte";
18
+
19
+ // Sidebar transition parameters (defaults)
20
+ let sidebarDuration = $state(2000); // milliseconds
21
+ let customEasing = $state(bezier(0.22, 1, 0.36, 1));
22
+
23
+ // =========================================================================
24
+ // MODULE METADATA — dynamic theme & font discovery
25
+ // =========================================================================
26
+ const THEMES = [
27
+ { value: "", label: "None" },
28
+ { value: "default", label: "Default" },
29
+ ...loadedModules
30
+ .filter((m) => m.meta?.type === "theme")
31
+ .map((m) => ({ value: m.meta.themeId, label: m.meta.label }))
32
+ .sort((a, b) => a.label.localeCompare(b.label)),
33
+ ];
34
+
35
+ const FONTS = loadedModules
36
+ .filter((m) => m.meta?.type === "font")
37
+ .map((m) => m.meta.label)
38
+ .sort((a, b) => a.localeCompare(b));
39
+
40
+ // =========================================================================
41
+ // STATE
42
+ // =========================================================================
43
+ let view = $state("dormant"); // dormant | open
44
+ let closeTimer;
45
+ let isScrolling = $state(false);
46
+ let pinned = $state(false);
47
+ let scrollTimer;
48
+ let sidebarEl = $state();
49
+
50
+ // Font dropdown state: tracks which font field is showing the picker
51
+ let fontDropdown = $state(null); // null | "--font-text" | "--font-header" | ...
52
+ let fontSearch = $state("");
53
+ let fontHighlight = $state(-1);
54
+
55
+ let filteredFonts = $derived(
56
+ fontSearch.trim() === ""
57
+ ? FONTS
58
+ : FONTS.filter((f) => f.toLowerCase().includes(fontSearch.toLowerCase())),
59
+ );
60
+
61
+ function openFontDropdown(token) {
62
+ fontDropdown = token;
63
+ fontSearch = "";
64
+ fontHighlight = -1;
65
+ }
66
+
67
+ function closeFontDropdown() {
68
+ fontDropdown = null;
69
+ fontSearch = "";
70
+ fontHighlight = -1;
71
+ }
72
+
73
+ function selectFont(token, name) {
74
+ const val =
75
+ name.includes(" ") && !name.startsWith('"') ? `"${name}"` : name;
76
+ writeVar(token, val);
77
+ loadGoogleFont(name);
78
+ if (token === "--font-text") fontText = val;
79
+ else if (token === "--font-header") fontHeader = val;
80
+ else if (token === "--font-interface") fontInterface = val;
81
+ else if (token === "--font-monospace") fontMono = val;
82
+ closeFontDropdown();
83
+ }
84
+
85
+ function onFontKeydown(token, e) {
86
+ if (e.key === "ArrowDown") {
87
+ e.preventDefault();
88
+ fontHighlight = Math.min(fontHighlight + 1, filteredFonts.length - 1);
89
+ } else if (e.key === "ArrowUp") {
90
+ e.preventDefault();
91
+ fontHighlight = Math.max(fontHighlight - 1, -1);
92
+ } else if (e.key === "Enter" && fontHighlight >= 0) {
93
+ e.preventDefault();
94
+ selectFont(token, filteredFonts[fontHighlight]);
95
+ } else if (e.key === "Escape") {
96
+ closeFontDropdown();
97
+ }
98
+ }
99
+
100
+ function openLab() {
101
+ clearTimeout(closeTimer);
102
+ view = "open";
103
+ }
104
+
105
+ function requestCloseLab() {
106
+ if (pinned) return;
107
+ // Keep open if user is interacting with inputs inside
108
+ if (
109
+ sidebarEl &&
110
+ typeof document !== "undefined" &&
111
+ sidebarEl.contains(document.activeElement)
112
+ ) {
113
+ return;
114
+ }
115
+
116
+ clearTimeout(closeTimer);
117
+ closeTimer = setTimeout(() => {
118
+ view = "dormant";
119
+ }, 100); // Increased delay for smoother transitions
120
+ }
121
+
122
+ function cancelCloseLab() {
123
+ clearTimeout(closeTimer);
124
+ }
125
+
126
+ let modified = $state(new Set());
127
+ let version = $state("");
128
+ let viewportW = $state(0);
129
+
130
+ // Collapsible sections
131
+ let sectionsOpen = $state({
132
+ theme: false,
133
+ colors: false,
134
+ typography: false,
135
+ scale: false,
136
+ spacing: false,
137
+ debug: false,
138
+ links: false,
139
+ });
140
+
141
+ // Color scheme detection
142
+ let systemScheme = $state(
143
+ typeof window !== "undefined" &&
144
+ window.matchMedia?.("(prefers-color-scheme: dark)").matches
145
+ ? "dark"
146
+ : "light",
147
+ );
148
+ let userScheme = $state("auto");
149
+ let scheme = $derived(userScheme === "auto" ? systemScheme : userScheme);
150
+
151
+ $effect(() => {
152
+ if (typeof sessionStorage !== "undefined") {
153
+ sessionStorage.setItem("stnd-scheme", userScheme);
154
+ sessionStorage.setItem("stnd-pinned", String(pinned));
155
+ sessionStorage.setItem("stnd-sections", JSON.stringify(sectionsOpen));
156
+ }
157
+ if (typeof document !== "undefined") {
158
+ if (userScheme === "auto") {
159
+ document.documentElement.removeAttribute("data-lab-scheme");
160
+ } else {
161
+ document.documentElement.setAttribute("data-lab-scheme", userScheme);
162
+ }
163
+ }
164
+ });
165
+ let bgToken = $derived(
166
+ scheme === "dark" ? "--color-dark-background" : "--color-light-background",
167
+ );
168
+ let fgToken = $derived(
169
+ scheme === "dark" ? "--color-dark-foreground" : "--color-light-foreground",
170
+ );
171
+
172
+ // Quick panel seeds (scheme-aware)
173
+ let seedBg = $state("");
174
+ let seedFg = $state("");
175
+ let seedBgHex = $state("#ffffff");
176
+ let seedFgHex = $state("#262626");
177
+ let fontText = $state("");
178
+ let fontHeader = $state("");
179
+ let fontInterface = $state("");
180
+ let fontMono = $state("");
181
+ let fontSize = $state(1);
182
+ let lineHeight = $state(1.414);
183
+ let rhythmGap = $state(1);
184
+ let ratio = $state(1.414);
185
+ let gapBody = $state(1);
186
+ let radius = $state(0);
187
+ let debug = $state(false);
188
+ let eink = $state(false);
189
+ let ink = $state(false);
190
+ let texture = $state(false);
191
+ let stnd1 = $state(false);
192
+ let stnd2 = $state(false);
193
+ let stnd3 = $state(false);
194
+ let theme = $state("");
195
+
196
+ // Google Fonts tracking
197
+ const fontsLoaded = new Set();
198
+
199
+ // Stored defaults for reset
200
+ let defaults = {};
201
+
202
+ // =========================================================================
203
+ // HELPERS
204
+ // =========================================================================
205
+
206
+ /** Read a CSS custom property, resolving one level of var() */
207
+ function readVar(token) {
208
+ const raw = getComputedStyle(document.documentElement)
209
+ .getPropertyValue(token)
210
+ .trim();
211
+ if (!raw) return "";
212
+ const m = raw.match(/^var\(--([\w-]+)\)$/);
213
+ return m
214
+ ? getComputedStyle(document.documentElement)
215
+ .getPropertyValue(`--${m[1]}`)
216
+ .trim()
217
+ : raw;
218
+ }
219
+
220
+ /** Write an inline override and mark as modified */
221
+ function writeVar(token, value) {
222
+ document.documentElement.style.setProperty(token, value);
223
+ modified = new Set([...modified, token]);
224
+
225
+ // Persist to session
226
+ try {
227
+ const overrides = JSON.parse(
228
+ sessionStorage.getItem("stnd-overrides") || "{}",
229
+ );
230
+ overrides[token] = value;
231
+ sessionStorage.setItem("stnd-overrides", JSON.stringify(overrides));
232
+ } catch {
233
+ /* noop */
234
+ }
235
+ }
236
+
237
+ /** Convert any CSS color to #hex for <input type="color"> */
238
+ function toHex(css) {
239
+ if (!css) return "#000000";
240
+ if (/^#[0-9a-f]{6}$/i.test(css.trim())) return css.trim();
241
+ if (css.trim().toLowerCase() === "white") return "#ffffff";
242
+ if (css.trim().toLowerCase() === "black") return "#000000";
243
+
244
+ // Use a temp element to resolve any CSS color format
245
+ try {
246
+ const el = document.createElement("div");
247
+ el.style.color = css;
248
+ document.body.appendChild(el);
249
+ const resolved = getComputedStyle(el).color;
250
+ el.remove();
251
+ const m = resolved.match(/(\d+)/g);
252
+ if (m && m.length >= 3) {
253
+ return (
254
+ "#" +
255
+ m
256
+ .slice(0, 3)
257
+ .map((n) => parseInt(n).toString(16).padStart(2, "0"))
258
+ .join("")
259
+ );
260
+ }
261
+ } catch {
262
+ /* noop */
263
+ }
264
+ return "#000000";
265
+ }
266
+
267
+ /** Resolve a CSS length (var, calc, unit) to pixels */
268
+ function resolvePx(value) {
269
+ if (typeof document === "undefined" || !value) return 0;
270
+ try {
271
+ const el = document.createElement("div");
272
+ el.style.position = "absolute";
273
+ el.style.visibility = "hidden";
274
+ el.style.width = value;
275
+ document.body.appendChild(el);
276
+ const px = parseFloat(getComputedStyle(el).width);
277
+ el.remove();
278
+ return isNaN(px) ? 0 : px;
279
+ } catch {
280
+ return 0;
281
+ }
282
+ }
283
+
284
+ /** Read all token values from the current DOM cascade */
285
+ function syncFromDOM() {
286
+ // Detect current scheme
287
+ systemScheme = window.matchMedia?.("(prefers-color-scheme: dark)").matches
288
+ ? "dark"
289
+ : "light";
290
+
291
+ // Read the active scheme's seeds
292
+ seedBg = readVar(bgToken);
293
+ seedFg = readVar(fgToken);
294
+ seedBgHex = toHex(seedBg);
295
+ seedFgHex = toHex(seedFg);
296
+ fontText = readVar("--font-text");
297
+ fontHeader = readVar("--font-header");
298
+ fontInterface = readVar("--font-interface");
299
+ fontMono = readVar("--font-monospace");
300
+ fontSize = parseFloat(readVar("--font-size")) || 1;
301
+ lineHeight = parseFloat(readVar("--line-height")) || 1.414;
302
+ ratio = parseFloat(readVar("--optical-ratio")) || 1.414;
303
+
304
+ const rawGap = readVar("--gap-body");
305
+ const pxSpace = resolvePx("var(--space)");
306
+
307
+ // Calculate body gap multiplier
308
+ const pxGap = resolvePx(rawGap);
309
+ gapBody = pxSpace ? pxGap / pxSpace : 1;
310
+
311
+ // Calculate rhythm gap multiplier
312
+ const pxRhythm = resolvePx("var(--rhythm-gap-new-line)");
313
+ rhythmGap = pxSpace ? pxRhythm / pxSpace : 0.5;
314
+
315
+ // Calculate radius multiplier
316
+ const rawRadius = readVar("--radius");
317
+ const pxRadius = resolvePx(rawRadius);
318
+ radius = pxSpace ? pxRadius / pxSpace : 0;
319
+
320
+ debug = document.documentElement.classList.contains("stnd-debug");
321
+ eink = document.documentElement.classList.contains("standard-eink");
322
+ theme = document.documentElement.getAttribute("data-theme") || "";
323
+ viewportW = window.innerWidth;
324
+ }
325
+
326
+ /** Store current computed values as defaults for reset */
327
+ function storeDefaults() {
328
+ const tokens = [
329
+ "--color-light-background",
330
+ "--color-light-foreground",
331
+ "--color-dark-background",
332
+ "--color-dark-foreground",
333
+ "--font-text",
334
+ "--font-header",
335
+ "--font-interface",
336
+ "--font-monospace",
337
+ "--font-size",
338
+ "--line-height",
339
+ "--optical-ratio",
340
+ "--gap-body",
341
+ "--rhythm-gap-new-line",
342
+ "--radius",
343
+ ];
344
+ for (const t of tokens) {
345
+ defaults[t] = readVar(t);
346
+ }
347
+ }
348
+
349
+ /** Load a Google Font by family name */
350
+ function loadGoogleFont(family) {
351
+ const clean = family.trim();
352
+ const system = [
353
+ "system-ui",
354
+ "sans-serif",
355
+ "serif",
356
+ "monospace",
357
+ "cursive",
358
+ "ui-monospace",
359
+ "ui-sans-serif",
360
+ "ui-serif",
361
+ "ui-rounded",
362
+ "Georgia",
363
+ "Times New Roman",
364
+ "Arial",
365
+ ];
366
+ if (!clean || system.includes(clean) || fontsLoaded.has(clean)) return;
367
+ fontsLoaded.add(clean);
368
+ const encoded = encodeURIComponent(clean);
369
+ try {
370
+ const link = document.createElement("link");
371
+ link.rel = "stylesheet";
372
+ link.href = `https://fonts.googleapis.com/css2?family=${encoded}:wght@100..900&display=swap`;
373
+ document.head.appendChild(link);
374
+ } catch {
375
+ /* noop */
376
+ }
377
+ }
378
+
379
+ /** Copy current overrides as CSS to clipboard */
380
+ function copyCSS() {
381
+ const tokens = [
382
+ "--color-light-background",
383
+ "--color-light-foreground",
384
+ "--color-dark-background",
385
+ "--color-dark-foreground",
386
+ "--font-text",
387
+ "--font-header",
388
+ "--font-interface",
389
+ "--font-monospace",
390
+ "--font-size",
391
+ "--line-height",
392
+ "--optical-ratio",
393
+ "--gap-body",
394
+ "--rhythm-gap-new-line",
395
+ "--radius",
396
+ ];
397
+ const css = tokens
398
+ .filter((t) => modified.has(t))
399
+ .map(
400
+ (t) =>
401
+ ` ${t}: ${document.documentElement.style.getPropertyValue(t).trim()};`,
402
+ )
403
+ .join("\n");
404
+ if (css) {
405
+ navigator.clipboard?.writeText(`:root {\n${css}\n}`);
406
+ window.toast?.("CSS copied", "success");
407
+ } else {
408
+ window.toast?.("No overrides to copy", "info");
409
+ }
410
+ }
411
+
412
+ function getBreakpoint(w) {
413
+ if (w < 640) return "Mobile";
414
+ if (w < 768) return "Small";
415
+ if (w < 1024) return "Medium";
416
+ if (w < 1280) return "Large";
417
+ return "X-Large";
418
+ }
419
+
420
+ /** Toggle debug overlay panels */
421
+ function syncDebugOverlays(on) {
422
+ document.querySelectorAll("[data-debug-overlay]").forEach((el) => {
423
+ el.style.display = on ? "" : "none";
424
+ });
425
+
426
+ const overlay = document.getElementById("stnd-debug-overlay");
427
+ if (overlay) {
428
+ overlay.style.display = on ? "" : "none";
429
+ } else if (on) {
430
+ const div = document.createElement("div");
431
+ div.id = "stnd-debug-overlay";
432
+ div.setAttribute("data-debug-overlay", "");
433
+ document.body.appendChild(div);
434
+ }
435
+ }
436
+
437
+ // =========================================================================
438
+ // LIFECYCLE
439
+ // =========================================================================
440
+ onMount(() => {
441
+ version = document.documentElement.dataset.standardVersion || "";
442
+ viewportW = window.innerWidth;
443
+
444
+ // Attempt to read CSS transition variables so the Svelte fly transition
445
+ // matches the CSS page margin transition. Use the single trusted token
446
+ // `--transition` first and fall back to `--transition-slow` if not present.
447
+ try {
448
+ const cs = getComputedStyle(document.documentElement);
449
+
450
+ // Prefer --transition; fall back to --transition-slow if missing.
451
+ let transRaw = (cs.getPropertyValue("--transition") || "").trim();
452
+ if (!transRaw) {
453
+ transRaw = (cs.getPropertyValue("--transition-slow") || "").trim();
454
+ }
455
+
456
+ if (transRaw) {
457
+ // Extract duration (e.g. "1s cubic-bezier(...)" or "200ms ease")
458
+ const durMatch = transRaw.match(/([\d.]+)(ms|s)/);
459
+ if (durMatch) {
460
+ sidebarDuration =
461
+ durMatch[2] === "ms"
462
+ ? parseFloat(durMatch[1])
463
+ : parseFloat(durMatch[1]) * 1000;
464
+ }
465
+
466
+ // Extract easing curve from the same variable (cubic-bezier)
467
+ const easeMatch = transRaw.match(/cubic-bezier\(([^)]+)\)/);
468
+ if (easeMatch && easeMatch[1]) {
469
+ const nums = easeMatch[1].split(",").map((n) => parseFloat(n.trim()));
470
+ if (nums.length === 4 && nums.every((v) => !Number.isNaN(v))) {
471
+ customEasing = bezier(nums[0], nums[1], nums[2], nums[3]);
472
+ }
473
+ }
474
+ }
475
+ } catch (err) {
476
+ // If anything goes wrong (SSR, missing vars, parsing), fall back to defaults.
477
+ }
478
+
479
+ // Listen for system color scheme changes
480
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
481
+ const onSchemeChange = (e) => {
482
+ systemScheme = e.matches ? "dark" : "light";
483
+ requestAnimationFrame(syncFromDOM);
484
+ };
485
+ mql.addEventListener("change", onSchemeChange);
486
+
487
+ const restoreLabState = () => {
488
+ if (typeof document === "undefined") return;
489
+
490
+ const savedScheme = sessionStorage.getItem("stnd-scheme");
491
+ if (savedScheme && ["auto", "light", "dark"].includes(savedScheme)) {
492
+ userScheme = savedScheme;
493
+ }
494
+ if (userScheme !== "auto") {
495
+ document.documentElement.setAttribute("data-lab-scheme", userScheme);
496
+ }
497
+
498
+ // Re-apply Lab theme (last word)
499
+ const savedTheme = sessionStorage.getItem("stnd-theme");
500
+ if (savedTheme !== null) {
501
+ if (savedTheme) {
502
+ document.documentElement.setAttribute("data-theme", savedTheme);
503
+ } else {
504
+ document.documentElement.removeAttribute("data-theme");
505
+ }
506
+ }
507
+
508
+ const savedPinned = sessionStorage.getItem("stnd-pinned");
509
+ if (savedPinned === "true") pinned = true;
510
+
511
+ const savedDebug = sessionStorage.getItem("stnd-debug-mode");
512
+ if (savedDebug === "true") {
513
+ document.documentElement.classList.add("stnd-debug");
514
+ debug = true;
515
+ } else {
516
+ document.documentElement.classList.remove("stnd-debug");
517
+ debug = false;
518
+ }
519
+
520
+ // Restore debug class toggles (.ink, .texture, .stnd-1/2/3)
521
+ const classes = [
522
+ { k: "stnd-class-ink", c: "ink", s: (v) => (ink = v) },
523
+ { k: "stnd-class-texture", c: "texture", s: (v) => (texture = v) },
524
+ { k: "stnd-class-stnd-1", c: "stnd-1", s: (v) => (stnd1 = v) },
525
+ { k: "stnd-class-stnd-2", c: "stnd-2", s: (v) => (stnd2 = v) },
526
+ { k: "stnd-class-stnd-3", c: "stnd-3", s: (v) => (stnd3 = v) },
527
+ ];
528
+
529
+ classes.forEach(({ k, c, s }) => {
530
+ const val = sessionStorage.getItem(k) === "true";
531
+ document.documentElement.classList.toggle(c, val);
532
+ s(val);
533
+ });
534
+
535
+ const savedPanel = sessionStorage.getItem("stnd-panel-state");
536
+ if (savedPanel === "open") view = "open";
537
+
538
+ // Restore sections
539
+ try {
540
+ const savedSections = JSON.parse(
541
+ sessionStorage.getItem("stnd-sections") || "{}",
542
+ );
543
+ if (Object.keys(savedSections).length > 0) {
544
+ sectionsOpen = { ...sectionsOpen, ...savedSections };
545
+ }
546
+ } catch {
547
+ /* noop */
548
+ }
549
+
550
+ // Restore overrides
551
+ try {
552
+ const savedOverrides = JSON.parse(
553
+ sessionStorage.getItem("stnd-overrides") || "{}",
554
+ );
555
+ if (Object.keys(savedOverrides).length > 0) {
556
+ for (const [token, value] of Object.entries(savedOverrides)) {
557
+ document.documentElement.style.setProperty(token, value);
558
+ }
559
+ modified = new Set([...Object.keys(savedOverrides)]);
560
+ } else {
561
+ modified = new Set();
562
+ }
563
+ } catch {
564
+ /* noop */
565
+ }
566
+
567
+ // Read current values from cascade
568
+ syncFromDOM();
569
+ storeDefaults();
570
+ syncDebugOverlays(
571
+ document.documentElement.classList.contains("stnd-debug"),
572
+ );
573
+ };
574
+
575
+ restoreLabState();
576
+ document.addEventListener("astro:after-swap", restoreLabState);
577
+
578
+ // Global API for dev toolbar compatibility
579
+ window.StandardLab = {
580
+ get state() {
581
+ return view;
582
+ },
583
+ open() {
584
+ view = "open";
585
+ },
586
+ close() {
587
+ view = "dormant";
588
+ },
589
+ toggle() {
590
+ view = view === "dormant" ? "open" : "dormant";
591
+ },
592
+ refresh: syncFromDOM,
593
+ };
594
+
595
+ // Keyboard shortcuts
596
+ function onKey(e) {
597
+ const mod = navigator.platform.includes("Mac") ? e.metaKey : e.ctrlKey;
598
+
599
+ // Toggle lab with Cmd/Ctrl+S (override default save shortcut)
600
+ if (mod && !e.shiftKey && e.key.toLowerCase() === "s") {
601
+ e.preventDefault();
602
+ view = view === "dormant" ? "open" : "dormant";
603
+ }
604
+
605
+ if (e.shiftKey && mod && e.key.toLowerCase() === "s") {
606
+ e.preventDefault();
607
+ view = view === "dormant" ? "open" : "dormant";
608
+ }
609
+ if (mod && !e.shiftKey && e.key.toLowerCase() === "d") {
610
+ e.preventDefault();
611
+ view = view === "dormant" ? "open" : "dormant";
612
+ }
613
+ if (e.shiftKey && mod && e.key.toLowerCase() === "d") {
614
+ e.preventDefault();
615
+ debug = !debug;
616
+ document.documentElement.classList.toggle("stnd-debug", debug);
617
+ sessionStorage.setItem("stnd-debug-mode", String(debug));
618
+ syncDebugOverlays(debug);
619
+ window.toast?.(`Debug ${debug ? "on" : "off"}`, "info");
620
+ }
621
+ if (e.key === "Escape" && view !== "dormant") {
622
+ view = "dormant";
623
+ }
624
+ }
625
+
626
+ function onResize() {
627
+ viewportW = window.innerWidth;
628
+ }
629
+
630
+ function onScroll() {
631
+ isScrolling = true;
632
+ clearTimeout(scrollTimer);
633
+ scrollTimer = setTimeout(() => {
634
+ isScrolling = false;
635
+ }, 500);
636
+ }
637
+
638
+ function onGlobalClick(e) {
639
+ if (view !== "open" || pinned) return;
640
+ if (sidebarEl && sidebarEl.contains(e.target)) return;
641
+ if (
642
+ e.target.closest(".lab-handler") ||
643
+ e.target.closest(".lab-handler-top")
644
+ )
645
+ return;
646
+ view = "dormant";
647
+ }
648
+
649
+ document.addEventListener("keydown", onKey);
650
+ document.addEventListener("click", onGlobalClick);
651
+ window.addEventListener("resize", onResize);
652
+ window.addEventListener("scroll", onScroll, { passive: true });
653
+
654
+ return () => {
655
+ document.removeEventListener("keydown", onKey);
656
+ document.removeEventListener("click", onGlobalClick);
657
+ document.removeEventListener("astro:after-swap", restoreLabState);
658
+ window.removeEventListener("resize", onResize);
659
+ window.removeEventListener("scroll", onScroll);
660
+ mql.removeEventListener("change", onSchemeChange);
661
+ delete window.StandardLab;
662
+ };
663
+ });
664
+
665
+ // =========================================================================
666
+ // EFFECTS
667
+ // =========================================================================
668
+
669
+ // Persist panel state + dispatch event for dev toolbar
670
+ // Shrink the page when the sidebar is open (like DevTools)
671
+ $effect(() => {
672
+ if (typeof document === "undefined") return;
673
+ const el = document.documentElement;
674
+ if (view === "open") {
675
+ el.classList.add("lab-open");
676
+ } else {
677
+ el.classList.remove("lab-open");
678
+ }
679
+ });
680
+
681
+ $effect(() => {
682
+ if (typeof window === "undefined") return;
683
+ if (view === "dormant") {
684
+ sessionStorage.removeItem("stnd-panel-state");
685
+ } else {
686
+ sessionStorage.setItem("stnd-panel-state", view);
687
+ }
688
+ try {
689
+ window.dispatchEvent(
690
+ new CustomEvent("standardlab:state", { detail: { state: view } }),
691
+ );
692
+ } catch {
693
+ /* SSR guard */
694
+ }
695
+ });
696
+
697
+ // =========================================================================
698
+ // HANDLERS — Theme
699
+ // =========================================================================
700
+ function onThemeChange(e) {
701
+ const t = e.target.value;
702
+ theme = t;
703
+
704
+ // Clear ALL inline token overrides so the new theme's cascade wins
705
+ const tokensToClear = [
706
+ "--color-light-background",
707
+ "--color-light-foreground",
708
+ "--color-dark-background",
709
+ "--color-dark-foreground",
710
+ "--font-text",
711
+ "--font-header",
712
+ "--font-interface",
713
+ "--font-monospace",
714
+ "--font-size",
715
+ "--line-height",
716
+ "--optical-ratio",
717
+ "--gap-body",
718
+ "--rhythm-gap-new-line",
719
+ "--radius",
720
+ ];
721
+ for (const tok of tokensToClear) {
722
+ document.documentElement.style.removeProperty(tok);
723
+ }
724
+ modified = new Set();
725
+
726
+ if (t) {
727
+ document.documentElement.setAttribute("data-theme", t);
728
+ } else {
729
+ document.documentElement.removeAttribute("data-theme");
730
+ }
731
+ sessionStorage.setItem("stnd-theme", t);
732
+
733
+ // Re-read all values after cascade settles
734
+ requestAnimationFrame(() => {
735
+ syncFromDOM();
736
+ storeDefaults();
737
+ });
738
+ window.toast?.(`Theme: ${t || "None"}`, "info");
739
+ }
740
+
741
+ // =========================================================================
742
+ // HANDLERS — Seed Colors
743
+ // =========================================================================
744
+ function onColorSwatch(role, e) {
745
+ const hex = e.target.value;
746
+ const token = role === "bg" ? bgToken : fgToken;
747
+ if (role === "bg") {
748
+ seedBg = hex;
749
+ seedBgHex = hex;
750
+ } else {
751
+ seedFg = hex;
752
+ seedFgHex = hex;
753
+ }
754
+ writeVar(token, hex);
755
+ }
756
+
757
+ function onColorText(role, e) {
758
+ const val = e.target.value;
759
+ const token = role === "bg" ? bgToken : fgToken;
760
+ if (role === "bg") {
761
+ seedBg = val;
762
+ seedBgHex = toHex(val);
763
+ } else {
764
+ seedFg = val;
765
+ seedFgHex = toHex(val);
766
+ }
767
+ writeVar(token, val);
768
+ }
769
+
770
+ // =========================================================================
771
+ // HANDLERS — Fonts
772
+ // =========================================================================
773
+ function onFontInput(token, e) {
774
+ const raw = e.target.value;
775
+ // Wrap multi-word font names in quotes for valid CSS font-family
776
+ const val = raw.includes(" ") && !raw.startsWith('"') ? `"${raw}"` : raw;
777
+ writeVar(token, val);
778
+ loadGoogleFont(raw);
779
+ // Re-read the specific font state
780
+ if (token === "--font-text") fontText = val;
781
+ else if (token === "--font-header") fontHeader = val;
782
+ else if (token === "--font-interface") fontInterface = val;
783
+ else if (token === "--font-monospace") fontMono = val;
784
+ // Update search to match typed value for filtering
785
+ fontSearch = raw;
786
+ }
787
+
788
+ // =========================================================================
789
+ // HANDLERS — Scale
790
+ // =========================================================================
791
+ function onFontSizeInput(e) {
792
+ fontSize = parseFloat(e.target.value);
793
+ writeVar("--font-size", `${fontSize}rem`);
794
+ }
795
+
796
+ function onLineHeightInput(e) {
797
+ lineHeight = parseFloat(e.target.value);
798
+ writeVar("--line-height", String(lineHeight));
799
+ }
800
+
801
+ function onRatioInput(e) {
802
+ ratio = parseFloat(e.target.value);
803
+ writeVar("--optical-ratio", String(ratio));
804
+ }
805
+
806
+ function onGapInput(e) {
807
+ gapBody = parseFloat(e.target.value);
808
+ writeVar("--gap-body", `calc(var(--space) * ${gapBody})`);
809
+ }
810
+
811
+ function onRhythmGapInput(e) {
812
+ rhythmGap = parseFloat(e.target.value);
813
+ writeVar("--rhythm-gap-new-line", `calc(var(--space) * ${rhythmGap})`);
814
+ }
815
+
816
+ function onRadiusInput(e) {
817
+ radius = parseFloat(e.target.value);
818
+ writeVar("--radius", `calc(var(--space) * ${radius})`);
819
+ }
820
+
821
+ // =========================================================================
822
+ // HANDLERS — Debug
823
+ // =========================================================================
824
+ function onDebugToggle(e) {
825
+ debug = e.target.checked;
826
+ document.documentElement.classList.toggle("stnd-debug", debug);
827
+ sessionStorage.setItem("stnd-debug-mode", String(debug));
828
+ syncDebugOverlays(debug);
829
+ window.toast?.(`Debug ${debug ? "on" : "off"}`, "info");
830
+ }
831
+
832
+ function onEinkToggle(e) {
833
+ eink = e.target.checked;
834
+ document.documentElement.classList.toggle("standard-eink", eink);
835
+ // E-ink state is often auto-detected or managed by client/eink.js
836
+ // but here we allow manual override. We don't persist it to session
837
+ // to avoid conflict with auto-detection on reload, or we could if desired.
838
+ // For now, let's treat it as a temporary override in the Lab.
839
+ window.toast?.(`E-Ink Mode ${eink ? "on" : "off"}`, "info");
840
+ }
841
+
842
+ /** Toggle a global debug class and persist to sessionStorage */
843
+ function onClassToggle(className, storageKey, e) {
844
+ const checked = e.target.checked;
845
+ // Update corresponding local state
846
+ if (className === "ink") ink = checked;
847
+ else if (className === "texture") texture = checked;
848
+ else if (className === "stnd-1") stnd1 = checked;
849
+ else if (className === "stnd-2") stnd2 = checked;
850
+ else if (className === "stnd-3") stnd3 = checked;
851
+
852
+ document.documentElement.classList.toggle(className, checked);
853
+ try {
854
+ sessionStorage.setItem(storageKey, String(checked));
855
+ } catch {
856
+ /* noop */
857
+ }
858
+ window.toast?.(`${className} ${checked ? "on" : "off"}`, "info");
859
+ }
860
+
861
+ // =========================================================================
862
+ // HANDLERS — Reset
863
+ // =========================================================================
864
+ async function resetAll() {
865
+ console.log("[Lab] Resetting all preferences...");
866
+ document.documentElement.removeAttribute("style");
867
+ document.documentElement.removeAttribute("data-theme");
868
+ document.documentElement.classList.remove("stnd-debug");
869
+ sessionStorage.removeItem("stnd-theme");
870
+ sessionStorage.removeItem("stnd-debug-mode");
871
+ sessionStorage.removeItem("stnd-panel-state");
872
+ sessionStorage.removeItem("stnd-overrides");
873
+ sessionStorage.removeItem("stnd-sections");
874
+ // Clear theme preferences from storage
875
+ localStorage.removeItem("stnd-theme-global"); // theme-utils persistence
876
+ console.log(
877
+ "[Lab] localStorage after clear:",
878
+ localStorage.getItem("stnd-theme-global"),
879
+ );
880
+ document.cookie =
881
+ "std_prefs=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
882
+ document.documentElement.removeAttribute("data-color-mode");
883
+ userScheme = "auto";
884
+ // Reset server-side colorMode preference so page reloads don't re-apply it
885
+ try {
886
+ const { actions } = await import("astro:actions");
887
+ await actions.updatePreferences({ colorMode: "auto" });
888
+ } catch {}
889
+ modified = new Set();
890
+ theme = "";
891
+ debug = false;
892
+ syncDebugOverlays(false);
893
+ requestAnimationFrame(() => {
894
+ syncFromDOM();
895
+ storeDefaults();
896
+ });
897
+ window.toast?.info("All tokens reset");
898
+ console.log("[Lab] Reset complete");
899
+ }
900
+
901
+ function toggleSection(key) {
902
+ sectionsOpen = { ...sectionsOpen, [key]: !sectionsOpen[key] };
903
+ }
904
+
905
+ // =========================================================================
906
+ // DERIVED
907
+ // =========================================================================
908
+ let modCount = $derived(modified.size);
909
+ </script>
910
+
911
+ <!-- ======================================================================
912
+ TRIGGER / HANDLER
913
+ ====================================================================== -->
914
+ <div
915
+ class="lab-handler-top m-0"
916
+ role="button"
917
+ tabindex="0"
918
+ aria-label="Open Design Lab"
919
+ onmouseenter={openLab}
920
+ onmouseleave={requestCloseLab}
921
+ onclick={() => (view = view === "dormant" ? "open" : "dormant")}
922
+ onkeydown={(e) => e.key === "Enter" && (view = "open")}
923
+ >
924
+ <div class="lab-dot"></div>
925
+ </div>
926
+
927
+ <div
928
+ class="lab-handler m-0"
929
+ class:scrolling={isScrolling}
930
+ class:modified={modCount > 0}
931
+ onmouseenter={openLab}
932
+ onmouseleave={requestCloseLab}
933
+ role="button"
934
+ tabindex="0"
935
+ aria-label="Open Design Lab"
936
+ onclick={() => (view = view === "dormant" ? "open" : "dormant")}
937
+ onkeydown={(e) => e.key === "Enter" && (view = "open")}
938
+ >
939
+ <div class="lab-handler-bar"></div>
940
+ </div>
941
+
942
+ <!-- ======================================================================
943
+ SIDE PANEL
944
+ ====================================================================== -->
945
+ {#if view === "open"}
946
+ <div
947
+ bind:this={sidebarEl}
948
+ transition:fly={{
949
+ x: 320,
950
+ duration: sidebarDuration,
951
+ easing: customEasing,
952
+ opacity: false,
953
+ }}
954
+ class="lab-sidebar no-rhythm mt-0"
955
+ role="dialog"
956
+ tabindex="-1"
957
+ aria-label="Design Lab"
958
+ onmouseenter={cancelCloseLab}
959
+ onmouseleave={requestCloseLab}
960
+ >
961
+ <!-- ═══════════ HEADER ═══════════ -->
962
+ <header class="lab-header">
963
+ <div class="lab-header-title">
964
+ <h2>stnd</h2>
965
+ {#if version}<span class="lab-version">v{version}</span>{/if}
966
+ </div>
967
+ <div class="lab-header-meta">
968
+ <span class="badge ghost small text-subtle" title="Viewport Width">
969
+ {viewportW}px
970
+ </span>
971
+ <span class="badge ghost small text-subtle"
972
+ >{getBreakpoint(viewportW)}</span
973
+ >
974
+ <button
975
+ class="smaller ghost"
976
+ onclick={() => {
977
+ const modes = ["auto", "light", "dark"];
978
+ userScheme = modes[(modes.indexOf(userScheme) + 1) % modes.length];
979
+ }}
980
+ title="Toggle Theme (Auto / Light / Dark)"
981
+ >
982
+ {userScheme === "auto" ? "auto" : scheme}
983
+ </button>
984
+
985
+ <button
986
+ class="smaller icon ghost"
987
+ onclick={() => (pinned = !pinned)}
988
+ aria-label={pinned ? "Unpin Lab" : "Pin Lab"}
989
+ title={pinned ? "Unpin Lab" : "Pin Lab"}
990
+ >
991
+ {pinned ? "●" : "○"}
992
+ </button>
993
+ <button
994
+ class="smaller icon ghost"
995
+ onclick={() => (view = "dormant")}
996
+ aria-label="Close Lab"
997
+ >
998
+ ✕
999
+ </button>
1000
+ </div>
1001
+ </header>
1002
+
1003
+ <!-- ═══════════ SCROLLABLE BODY ═══════════ -->
1004
+ <div class="lab-body">
1005
+ <!-- ─── THEME ─── -->
1006
+ <section class="lab-section">
1007
+ <button class="lab-section-head" onclick={() => toggleSection("theme")}>
1008
+ <span class="lab-section-arrow" class:open={sectionsOpen.theme}
1009
+ >▶</span
1010
+ >
1011
+ <span class="lab-section-title">Theme</span>
1012
+ </button>
1013
+ {#if sectionsOpen.theme}
1014
+ <div class="lab-section-body" transition:slide>
1015
+ <div class="lab-field">
1016
+ <label class="lab-field-label" for="lab-preset">Preset</label>
1017
+ <select
1018
+ id="lab-preset"
1019
+ class="lab-select"
1020
+ value={theme}
1021
+ onchange={onThemeChange}
1022
+ >
1023
+ {#each THEMES as t (t.value)}
1024
+ <option value={t.value} selected={t.value === theme}
1025
+ >{t.label}</option
1026
+ >
1027
+ {/each}
1028
+ </select>
1029
+ </div>
1030
+ </div>
1031
+ {/if}
1032
+ </section>
1033
+
1034
+ <!-- ─── COLORS ─── -->
1035
+ <section class="lab-section">
1036
+ <button
1037
+ class="lab-section-head"
1038
+ onclick={() => toggleSection("colors")}
1039
+ >
1040
+ <span class="lab-section-arrow" class:open={sectionsOpen.colors}
1041
+ >▶</span
1042
+ >
1043
+ <span class="lab-section-title">Colors</span>
1044
+ </button>
1045
+ {#if sectionsOpen.colors}
1046
+ <div class="lab-section-body" transition:slide>
1047
+ <div class="lab-field">
1048
+ <label class="lab-field-label" for="lab-bg-text">Background</label
1049
+ >
1050
+ <div class="lab-color-row">
1051
+ <input
1052
+ type="color"
1053
+ class="lab-swatch"
1054
+ value={seedBgHex}
1055
+ title="{scheme} background seed"
1056
+ oninput={(e) => onColorSwatch("bg", e)}
1057
+ />
1058
+ <input
1059
+ id="lab-bg-text"
1060
+ type="text"
1061
+ class="lab-text-input"
1062
+ value={seedBg}
1063
+ spellcheck="false"
1064
+ placeholder="e.g. #fafafa"
1065
+ oninput={(e) => onColorText("bg", e)}
1066
+ />
1067
+ </div>
1068
+ </div>
1069
+ <div class="lab-field">
1070
+ <label class="lab-field-label" for="lab-fg-text">Foreground</label
1071
+ >
1072
+ <div class="lab-color-row">
1073
+ <input
1074
+ type="color"
1075
+ class="lab-swatch"
1076
+ value={seedFgHex}
1077
+ title="{scheme} foreground seed"
1078
+ oninput={(e) => onColorSwatch("fg", e)}
1079
+ />
1080
+ <input
1081
+ id="lab-fg-text"
1082
+ type="text"
1083
+ class="lab-text-input"
1084
+ value={seedFg}
1085
+ spellcheck="false"
1086
+ placeholder="e.g. #1a1a1a"
1087
+ oninput={(e) => onColorText("fg", e)}
1088
+ />
1089
+ </div>
1090
+ </div>
1091
+ </div>
1092
+ {/if}
1093
+ </section>
1094
+
1095
+ <!-- ─── TYPOGRAPHY ─── -->
1096
+ <section class="lab-section">
1097
+ <button
1098
+ class="lab-section-head"
1099
+ onclick={() => toggleSection("typography")}
1100
+ >
1101
+ <span class="lab-section-arrow" class:open={sectionsOpen.typography}
1102
+ >▶</span
1103
+ >
1104
+ <span class="lab-section-title">Typography</span>
1105
+ </button>
1106
+ {#if sectionsOpen.typography}
1107
+ <div class="lab-section-body" transition:slide>
1108
+ {#snippet fontField(inputId, labelText, token, currentValue)}
1109
+ <div class="lab-field lab-font-field">
1110
+ <label class="lab-field-label" for={inputId}>{labelText}</label>
1111
+
1112
+ <Combobox
1113
+ options={FONTS.map((f) => ({ label: f, value: f }))}
1114
+ value={(currentValue || "")
1115
+ .split(",")[0]
1116
+ .trim()
1117
+ .replace(/^"|"$/g, "")}
1118
+ placeholder="Font family or Google Font"
1119
+ searchPlaceholder="Search fonts..."
1120
+ emptyText="No fonts found."
1121
+ allowCustom={true}
1122
+ preview={true}
1123
+ previewDebounce={120}
1124
+ loadFont={(name) => loadGoogleFont(name)}
1125
+ class="lab-font-combobox"
1126
+ aria-controls={inputId}
1127
+ on:preview={(e) => loadGoogleFont(e.detail?.value)}
1128
+ on:change={(e) => {
1129
+ // Combobox dispatches change with detail.value (base font name)
1130
+ selectFont(token, e.detail?.value);
1131
+ }}
1132
+ />
1133
+ </div>
1134
+ {/snippet}
1135
+
1136
+ {@render fontField(
1137
+ "lab-font-body",
1138
+ "Body",
1139
+ "--font-text",
1140
+ fontText,
1141
+ )}
1142
+ {@render fontField(
1143
+ "lab-font-head",
1144
+ "Headings",
1145
+ "--font-header",
1146
+ fontHeader,
1147
+ )}
1148
+ {@render fontField(
1149
+ "lab-font-ui",
1150
+ "Interface",
1151
+ "--font-interface",
1152
+ fontInterface,
1153
+ )}
1154
+ {@render fontField(
1155
+ "lab-font-mono",
1156
+ "Monospace",
1157
+ "--font-monospace",
1158
+ fontMono,
1159
+ )}
1160
+ </div>
1161
+ {/if}
1162
+ </section>
1163
+
1164
+ <!-- ─── SCALE ─── -->
1165
+ <section class="lab-section">
1166
+ <button class="lab-section-head" onclick={() => toggleSection("scale")}>
1167
+ <span class="lab-section-arrow" class:open={sectionsOpen.scale}
1168
+ >▶</span
1169
+ >
1170
+ <span class="lab-section-title">Scale</span>
1171
+ </button>
1172
+ {#if sectionsOpen.scale}
1173
+ <div class="lab-section-body" transition:slide>
1174
+ <div class="lab-field">
1175
+ <label class="lab-field-label" for="lab-font-size"
1176
+ >Font Size</label
1177
+ >
1178
+ <div class="lab-slider-wrapper">
1179
+ <input
1180
+ id="lab-font-size"
1181
+ type="range"
1182
+ class="lab-rangess"
1183
+ value={fontSize}
1184
+ min="0.75"
1185
+ max="1.5"
1186
+ step="0.05"
1187
+ oninput={onFontSizeInput}
1188
+ />
1189
+ <span class="lab-readout">{fontSize.toFixed(2)}rem</span>
1190
+ </div>
1191
+ </div>
1192
+ <div class="lab-field">
1193
+ <label class="lab-field-label" for="lab-line-height"
1194
+ >Line Height</label
1195
+ >
1196
+ <div class="lab-slider-wrapper">
1197
+ <input
1198
+ id="lab-line-height"
1199
+ type="range"
1200
+ class="lab-range"
1201
+ value={lineHeight}
1202
+ min="1"
1203
+ max="2"
1204
+ step="0.05"
1205
+ oninput={onLineHeightInput}
1206
+ />
1207
+ <span class="lab-readout">{lineHeight.toFixed(2)}</span>
1208
+ </div>
1209
+ </div>
1210
+ <div class="lab-field">
1211
+ <label class="lab-field-label" for="lab-ratio"
1212
+ >Optical Ratio</label
1213
+ >
1214
+ <div class="lab-slider-wrapper">
1215
+ <input
1216
+ id="lab-ratio"
1217
+ type="range"
1218
+ class="lab-range"
1219
+ value={ratio}
1220
+ min="1"
1221
+ max="2"
1222
+ step="0.01"
1223
+ oninput={onRatioInput}
1224
+ />
1225
+ <span class="lab-readout">{ratio.toFixed(2)}</span>
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+ {/if}
1230
+ </section>
1231
+
1232
+ <!-- ─── SPACING ─── -->
1233
+ <section class="lab-section">
1234
+ <button
1235
+ class="lab-section-head"
1236
+ onclick={() => toggleSection("spacing")}
1237
+ >
1238
+ <span class="lab-section-arrow" class:open={sectionsOpen.spacing}
1239
+ >▶</span
1240
+ >
1241
+ <span class="lab-section-title">Spacing</span>
1242
+ </button>
1243
+ {#if sectionsOpen.spacing}
1244
+ <div class="lab-section-body" transition:slide>
1245
+ <div class="lab-field">
1246
+ <label class="lab-field-label" for="lab-gap-body">Body Gap</label>
1247
+ <div class="lab-slider-wrapper">
1248
+ <input
1249
+ id="lab-gap-body"
1250
+ type="range"
1251
+ class="lab-range"
1252
+ value={gapBody}
1253
+ min="0"
1254
+ max="4"
1255
+ step="0.25"
1256
+ oninput={onGapInput}
1257
+ />
1258
+ <span class="lab-readout">{gapBody.toFixed(2)}x</span>
1259
+ </div>
1260
+ </div>
1261
+ <div class="lab-field">
1262
+ <label class="lab-field-label" for="lab-rhythm-gap"
1263
+ >Rhythm Gap</label
1264
+ >
1265
+ <div class="lab-slider-wrapper">
1266
+ <input
1267
+ id="lab-rhythm-gap"
1268
+ type="range"
1269
+ class="lab-range"
1270
+ value={rhythmGap}
1271
+ min="0"
1272
+ max="4"
1273
+ step="0.25"
1274
+ oninput={onRhythmGapInput}
1275
+ />
1276
+ <span class="lab-readout">{rhythmGap.toFixed(2)}x</span>
1277
+ </div>
1278
+ </div>
1279
+ <div class="lab-field">
1280
+ <label class="lab-field-label" for="lab-radius">Radius</label>
1281
+ <div class="lab-slider-wrapper">
1282
+ <input
1283
+ id="lab-radius"
1284
+ type="range"
1285
+ class="lab-range"
1286
+ value={radius}
1287
+ min="0"
1288
+ max="4"
1289
+ step="0.25"
1290
+ oninput={onRadiusInput}
1291
+ />
1292
+ <span class="lab-readout">{radius.toFixed(2)}x</span>
1293
+ </div>
1294
+ </div>
1295
+ </div>
1296
+ {/if}
1297
+ </section>
1298
+
1299
+ <!-- ─── DEBUG ─── -->
1300
+ <section class="lab-section">
1301
+ <button class="lab-section-head" onclick={() => toggleSection("debug")}>
1302
+ <span class="lab-section-arrow" class:open={sectionsOpen.debug}
1303
+ >▶</span
1304
+ >
1305
+ <span class="lab-section-title">Debug</span>
1306
+ </button>
1307
+ {#if sectionsOpen.debug}
1308
+ <div class="lab-section-body" transition:slide>
1309
+ <label class="lab-toggle" for="lab-debug-overlays">
1310
+ <input
1311
+ id="lab-debug-overlays"
1312
+ type="checkbox"
1313
+ checked={debug}
1314
+ onchange={onDebugToggle}
1315
+ />
1316
+ <span>Debug Overlays</span>
1317
+ <kbd>⇧⌘D</kbd>
1318
+ </label>
1319
+ <div class="lab-field mt-half">
1320
+ <button
1321
+ class="lab-btn lab-btn-secondary w-full"
1322
+ onclick={() => {
1323
+ document.cookie = "std_prefs=; Max-Age=0; path=/";
1324
+ sessionStorage.removeItem("stnd-theme");
1325
+ sessionStorage.removeItem("stnd-scheme");
1326
+ sessionStorage.removeItem("stnd-overrides");
1327
+ localStorage.removeItem("stnd-theme-global");
1328
+ theme = "";
1329
+ userScheme = "auto";
1330
+ modified = new Set();
1331
+ document.documentElement.removeAttribute("data-theme");
1332
+ document.documentElement.removeAttribute("data-lab-scheme");
1333
+ document.documentElement.removeAttribute("style");
1334
+ requestAnimationFrame(syncFromDOM);
1335
+ window.toast?.("Preferences cleared", "info");
1336
+ }}
1337
+ >
1338
+ Clear Preferences
1339
+ </button>
1340
+ </div>
1341
+ </div>
1342
+ {/if}
1343
+ </section>
1344
+
1345
+ <!-- ─── LINKS ─── -->
1346
+ <section class="lab-section">
1347
+ <button class="lab-section-head" onclick={() => toggleSection("links")}>
1348
+ <span class="lab-section-arrow" class:open={sectionsOpen.links}
1349
+ >▶</span
1350
+ >
1351
+ <span class="lab-section-title">Links</span>
1352
+ </button>
1353
+ {#if sectionsOpen.links}
1354
+ <div class="lab-section-body" transition:slide>
1355
+ <div class="lab-links">
1356
+ <a
1357
+ href="https://stnd.build/playground"
1358
+ target="_blank"
1359
+ rel="noopener"
1360
+ class="lab-link"
1361
+ >
1362
+ ↗ Playground
1363
+ </a>
1364
+ <a
1365
+ href="https://stnd.build"
1366
+ target="_blank"
1367
+ rel="noopener"
1368
+ class="lab-link"
1369
+ >
1370
+ ↗ Manual
1371
+ </a>
1372
+ <a
1373
+ href="https://stnd.build/icons"
1374
+ target="_blank"
1375
+ rel="noopener"
1376
+ class="lab-link"
1377
+ >
1378
+ ↗ Icons
1379
+ </a>
1380
+ </div>
1381
+ </div>
1382
+ {/if}
1383
+ </section>
1384
+ </div>
1385
+
1386
+ <!-- ═══════════ FOOTER ═══════════ -->
1387
+ <footer class="lab-footer">
1388
+ <button class="lab-btn lab-btn-secondary" onclick={resetAll}>
1389
+ Reset{modCount > 0 ? ` (${modCount})` : ""}
1390
+ </button>
1391
+ <button class="lab-btn lab-btn-primary" onclick={copyCSS}>
1392
+ Copy CSS
1393
+ </button>
1394
+ </footer>
1395
+ </div>
1396
+ {/if}
1397
+
1398
+ <!-- ======================================================================
1399
+ STYLES — Fully scoped, isolated from design tokens
1400
+ ====================================================================== -->
1401
+ <style>
1402
+ /* ═══════════ PAGE SHRINK (like DevTools) ═══════════ */
1403
+ :global(html.lab-open) {
1404
+ margin-right: var(--lab-sidebar-w, 320px);
1405
+ transition: margin-right var(--transition);
1406
+ }
1407
+ :global(html:not(.lab-open)) {
1408
+ transition: margin-right var(--transition);
1409
+ }
1410
+
1411
+ /* ═══════════ DESIGN CONSTANTS ═══════════ */
1412
+ :root {
1413
+ --lab-font-size: 12px;
1414
+ --lab-sidebar-w: 320px;
1415
+ }
1416
+
1417
+ /* ═══════════ HANDLER TRIGGER ═══════════ */
1418
+ .lab-handler-top {
1419
+ position: fixed;
1420
+ z-index: 200;
1421
+ top: 0;
1422
+ right: 0;
1423
+ padding: var(--space-2);
1424
+ cursor: pointer;
1425
+ opacity: 0.1;
1426
+ transition: opacity var(--transition);
1427
+ display: none;
1428
+ }
1429
+ .lab-handler-top:hover {
1430
+ opacity: 1;
1431
+ }
1432
+ .lab-dot {
1433
+ width: var(--space-d4);
1434
+ height: var(--space-d4);
1435
+ background: var(--color-foreground);
1436
+ border-radius: 50%;
1437
+ }
1438
+
1439
+ .lab-handler {
1440
+ position: fixed;
1441
+ z-index: 200;
1442
+ top: 20%;
1443
+ right: 0;
1444
+ padding: var(--space); /* Bigger hit area */
1445
+ padding-right: 20px;
1446
+ cursor: normal;
1447
+ display: flex;
1448
+ flex-direction: row;
1449
+ align-items: center;
1450
+ transition: all var(--transition);
1451
+ }
1452
+ .lab-handler.scrolling .lab-handler-bar {
1453
+ background: oklch(from var(--color-foreground) l c h / 0.3);
1454
+ }
1455
+ .lab-handler-bar {
1456
+ width: var(--space-d4);
1457
+ height: var(--space-2);
1458
+ background: oklch(from var(--color-foreground) l c h / 0);
1459
+ border-radius: var(--radius);
1460
+ box-shadow: var(--shadow-inset) var(--shadow-ring);
1461
+ transition: all var(--transition);
1462
+ cursor: normal;
1463
+ backdrop-filter: var(--filter-glass);
1464
+ }
1465
+ .lab-handler.modified .lab-handler-bar {
1466
+ background: oklch(from var(--color-accent) l c h / 0.1);
1467
+ }
1468
+
1469
+ /* ═══════════ SIDEBAR ═══════════ */
1470
+ .lab-sidebar {
1471
+ position: fixed;
1472
+ z-index: 9992;
1473
+ top: 0;
1474
+ right: 0;
1475
+ width: var(--lab-sidebar-w);
1476
+ height: 100dvh;
1477
+
1478
+ color: var(--color-foreground);
1479
+ font: var(--lab-font-size) / 1.5 var(--font-monospace);
1480
+ border-left: var(--border);
1481
+ display: flex;
1482
+ flex-direction: column;
1483
+ box-shadow: var(--shadow-ring), var(--shadow-glow);
1484
+ background: var(--color-surface);
1485
+ backdrop-filter: var(--filter-blur);
1486
+ /* Ensure the sidebar stays fully opaque and never animates opacity */
1487
+ opacity: 1 !important;
1488
+ /* Explicitly disable opacity animation while preserving other transitions */
1489
+ transition:
1490
+ transform var(--transition),
1491
+ box-shadow var(--transition),
1492
+ background var(--transition),
1493
+ opacity 0s;
1494
+
1495
+ h2 {
1496
+ margin: 0 !important;
1497
+ }
1498
+ }
1499
+
1500
+ /* Reset children to Lab font */
1501
+ .lab-sidebar * {
1502
+ font-family: var(--font-monospace) !important;
1503
+ box-sizing: border-box;
1504
+ /* Disable opacity transitions on child elements to prevent fading when opening the lab.
1505
+ Other property transitions for children remain allowed outside of opacity. */
1506
+ transition: opacity 0s !important;
1507
+ }
1508
+
1509
+ /* ═══════════ HEADER ═══════════ */
1510
+ .lab-header {
1511
+ display: flex;
1512
+ flex-wrap: wrap;
1513
+ align-items: center;
1514
+ gap: 0;
1515
+ padding: var(--space-d6) var(--space-d2);
1516
+ border-bottom: var(--border);
1517
+ flex-shrink: 0;
1518
+
1519
+ background: var(--color-surface-low);
1520
+ }
1521
+ .lab-header-title {
1522
+ display: flex;
1523
+ align-items: center;
1524
+ gap: 0px;
1525
+ flex: 1;
1526
+ min-width: 0;
1527
+ }
1528
+ .lab-header h2 {
1529
+ font-size: var(--size-sm) !important;
1530
+ line-height: 1 !important;
1531
+ }
1532
+ .lab-version {
1533
+ font-size: var(--size-xs) !important;
1534
+ color: var(--text-muted);
1535
+ }
1536
+ .lab-header-meta {
1537
+ display: flex;
1538
+ align-items: center;
1539
+ gap: var(--trim);
1540
+ }
1541
+
1542
+ /* ═══════════ SCROLLABLE BODY ═══════════ */
1543
+ .lab-body {
1544
+ flex: 1;
1545
+ overflow-y: auto;
1546
+ overflow-x: hidden;
1547
+ gap: 0;
1548
+ }
1549
+ .lab-body::-webkit-scrollbar {
1550
+ width: 4px;
1551
+ }
1552
+ .lab-body::-webkit-scrollbar-track {
1553
+ background: transparent;
1554
+ }
1555
+ .lab-body::-webkit-scrollbar-thumb {
1556
+ background: color-mix(in srgb, var(--color-foreground) 15%, transparent);
1557
+ border-radius: 2px;
1558
+ }
1559
+
1560
+ /* ═══════════ SECTIONS ═══════════ */
1561
+ .lab-section {
1562
+ box-shadow: var(--shadow-ring);
1563
+ }
1564
+ .lab-section-head {
1565
+ all: unset;
1566
+ display: flex;
1567
+ align-items: center;
1568
+ gap: 0;
1569
+ width: 100%;
1570
+ padding: var(--space-d6) 0;
1571
+ cursor: pointer;
1572
+ user-select: none;
1573
+ background: var(--color-surface);
1574
+ box-shadow: var(--shadow-raised);
1575
+ }
1576
+ .lab-section-head:hover {
1577
+ background: var(--color-surface);
1578
+ }
1579
+
1580
+ .lab-section-arrow {
1581
+ font-size: var(--size-2xs) !important;
1582
+ color: var(--text-subtle);
1583
+ transition: transform var(--transition);
1584
+ flex-shrink: 0;
1585
+ width: var(--space);
1586
+ display: inline-flex;
1587
+ justify-content: center;
1588
+ }
1589
+ .lab-section-arrow.open {
1590
+ transform: rotate(90deg);
1591
+ }
1592
+
1593
+ .lab-section-title {
1594
+ font-size: var(--size-2xs) !important;
1595
+ font-weight: 600;
1596
+ text-transform: uppercase;
1597
+ letter-spacing: 0.08em;
1598
+ color: var(--text-muted);
1599
+ }
1600
+
1601
+ .lab-section-body {
1602
+ padding: var(--space-d2) var(--space);
1603
+ background: var(--color-surface);
1604
+ box-shadow: var(--shadow);
1605
+ }
1606
+
1607
+ /* ═══════════ FIELDS ═══════════ */
1608
+ .lab-field {
1609
+ margin-bottom: var(--space-half);
1610
+ display: grid;
1611
+ min-height: var(--space);
1612
+ grid-template-columns: 80px 1fr;
1613
+ gap: 0;
1614
+ align-items: center;
1615
+ }
1616
+ .lab-field:last-child {
1617
+ margin-bottom: 0;
1618
+ }
1619
+
1620
+ .lab-field-label {
1621
+ display: block;
1622
+ font-size: var(--size-3xs) !important;
1623
+ color: var(--color-muted);
1624
+ text-transform: uppercase;
1625
+ line-height: 1;
1626
+ }
1627
+
1628
+ /* ═══════════ TEXT INPUT ═══════════ */
1629
+ .lab-text-input {
1630
+ width: 100%;
1631
+ font-size: var(--size-2xs) !important;
1632
+ line-height: 1 !important;
1633
+ transition: all var(--transition);
1634
+ margin: 0 !important;
1635
+ }
1636
+
1637
+ /* ═══════════ COLOR ROW ═══════════ */
1638
+ .lab-color-row {
1639
+ display: flex;
1640
+ align-items: center;
1641
+ gap: var(--trim);
1642
+ }
1643
+ .lab-swatch {
1644
+ width: var(--space);
1645
+ height: var(--space);
1646
+ border: var(--border);
1647
+ border-radius: var(--radius);
1648
+ cursor: pointer;
1649
+ padding: 0px;
1650
+ background: var(--color-surface);
1651
+ flex-shrink: 0;
1652
+ }
1653
+ .lab-swatch::-webkit-color-swatch-wrapper {
1654
+ padding: 0;
1655
+ }
1656
+ .lab-swatch::-webkit-color-swatch {
1657
+ border: none;
1658
+ border-radius: var(--radius);
1659
+ }
1660
+ .lab-color-row .lab-text-input {
1661
+ flex: 1;
1662
+ min-width: 0;
1663
+ }
1664
+
1665
+ /* ═══════════ SELECT ═══════════ */
1666
+ .lab-select {
1667
+ font-size: var(--size-2xs) !important;
1668
+ }
1669
+ .lab-select:focus {
1670
+ border-color: var(--color-accent);
1671
+ }
1672
+
1673
+ /* ═══════════ SLIDERS ═══════════ */
1674
+ /* ═══════════ FONT DROPDOWN ═══════════ */
1675
+ .lab-font-field {
1676
+ position: relative;
1677
+ }
1678
+
1679
+ .lab-slider-wrapper {
1680
+ display: flex;
1681
+ align-items: center;
1682
+ gap: var(--space-half);
1683
+ width: 100%;
1684
+ }
1685
+
1686
+ /* ═══════════ LINKS ═══════════ */
1687
+ .lab-links {
1688
+ display: flex;
1689
+ flex-direction: column;
1690
+ gap: var(--space-d4);
1691
+ }
1692
+
1693
+ .lab-link {
1694
+ font-size: var(--size-2xs) !important;
1695
+ color: var(--color-accent);
1696
+ text-decoration: none;
1697
+ padding: var(--space-d4) 0;
1698
+ transition: opacity var(--transition-fast);
1699
+
1700
+ &:hover {
1701
+ opacity: 0.7;
1702
+ }
1703
+ }
1704
+
1705
+ /* ═══════════ FOOTER ═══════════ */
1706
+ .lab-footer {
1707
+ display: flex;
1708
+ justify-content: space-evenly;
1709
+ gap: var(--space-half);
1710
+ padding: var(--space-half) var(--space);
1711
+ box-shadow: var(--shadow-raised);
1712
+ flex-shrink: 0;
1713
+ background: var(--color-surface-low);
1714
+ }
1715
+
1716
+ /* ═══════════ RESPONSIVE ═══════════ */
1717
+ @media (max-width: 480px) {
1718
+ .lab-sidebar {
1719
+ width: 100vw;
1720
+ }
1721
+ }
1722
+ @media print {
1723
+ .lab-handler,
1724
+ .lab-sidebar {
1725
+ display: none !important;
1726
+ }
1727
+ }
1728
+
1729
+ /* ═══════════ GLOBAL SCHEME OVERRIDES ═══════════ */
1730
+ :global(:root[data-lab-scheme="dark"]) {
1731
+ --color-background: var(--color-dark-background);
1732
+ --color-foreground: var(--color-dark-foreground);
1733
+ --color-accent: var(--color-dark-accent);
1734
+ --color-header: var(--color-dark-header, var(--color-foreground));
1735
+ --color-surface: color-mix(
1736
+ in srgb,
1737
+ var(--color-background) 100%,
1738
+ transparent
1739
+ );
1740
+ }
1741
+ :global(:root[data-lab-scheme="light"]) {
1742
+ --color-background: var(--color-light-background);
1743
+ --color-foreground: var(--color-light-foreground);
1744
+ --color-accent: var(--color-light-accent);
1745
+ --color-header: var(--color-light-header, var(--color-foreground));
1746
+ --color-surface: color-mix(
1747
+ in srgb,
1748
+ var(--color-background) 100%,
1749
+ transparent
1750
+ );
1751
+ }
1752
+ /* Combobox styles tuned for the Lab panel — make the combobox visually match lab inputs */
1753
+ :global(.lab-font-combobox .std-combobox-trigger) {
1754
+ width: 100%;
1755
+ padding: calc(var(--space-d3) - 2px) var(--space-d4);
1756
+ font-size: var(--size-2xs);
1757
+ line-height: 1;
1758
+ background: transparent;
1759
+ border: var(--border);
1760
+ border-radius: var(--radius);
1761
+ color: var(--color-foreground);
1762
+ box-shadow: var(--shadow-raised);
1763
+ text-align: left;
1764
+ display: inline-flex;
1765
+ align-items: center;
1766
+ justify-content: space-between;
1767
+ }
1768
+
1769
+ :global(.lab-font-combobox .std-combobox-trigger .truncate) {
1770
+ overflow: hidden;
1771
+ text-overflow: ellipsis;
1772
+ white-space: nowrap;
1773
+ }
1774
+
1775
+ :global(.lab-font-combobox .std-combobox-popover) {
1776
+ left: 0;
1777
+ right: 0;
1778
+ min-width: 100%;
1779
+ max-width: none;
1780
+ margin-top: var(--space-d2);
1781
+ border: var(--border);
1782
+ border-radius: var(--radius-sm);
1783
+ box-shadow: var(--shadow-xl), var(--shadow-ring);
1784
+ background: var(--color-surface, var(--color-background));
1785
+ z-index: 120;
1786
+ }
1787
+
1788
+ /* Make the search input inside the popover feel compact like lab inputs */
1789
+ :global(.lab-font-combobox .std-combobox-header) {
1790
+ padding: 0 var(--space-d4) var(--space-d3);
1791
+ margin-bottom: var(--space-d2);
1792
+ }
1793
+ :global(.lab-font-combobox .std-combobox-header input) {
1794
+ font-size: var(--size-sm);
1795
+ padding: var(--space-half) 0;
1796
+ width: 100%;
1797
+ background: transparent;
1798
+ border: none;
1799
+ outline: none;
1800
+ }
1801
+
1802
+ /* Ensure options are comfortably spaced in the Lab */
1803
+ :global(.lab-font-combobox .std-combobox-listbox .std-menu-item) {
1804
+ padding: var(--space-d2) var(--space-d4);
1805
+ font-size: var(--size-sm);
1806
+ }
1807
+
1808
+ /* Preserve the rest of the stylesheet closing tag */
1809
+ </style>