@stnd/lab 1.4.2 → 1.5.1

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 CHANGED
@@ -15,6 +15,13 @@
15
15
  import { modules as loadedModules } from "virtual:stnd/modules-metadata";
16
16
  import { confirm } from "@stnd/ui/dialog.js";
17
17
  import Combobox from "@stnd/ui/Combobox.svelte";
18
+ import {
19
+ clamp,
20
+ oklchToRgb,
21
+ rgbToHex,
22
+ formatOklch,
23
+ parseCssColor,
24
+ } from "./color.js";
18
25
 
19
26
  // Sidebar transition parameters (defaults)
20
27
  let sidebarDuration = $state(2000); // milliseconds
@@ -40,6 +47,26 @@
40
47
  ),
41
48
  ].sort((a, b) => a.localeCompare(b));
42
49
 
50
+ const MAX_CHROMA = 0.32;
51
+ const BG_PRESETS = [
52
+ { label: "Pure White", value: "#ffffff" },
53
+ { label: "Warm Paper", value: "#f7f3ee" },
54
+ { label: "Soft Cream", value: "oklch(0.955 0.018 85)" },
55
+ { label: "Sage Mist", value: "oklch(0.95 0.016 150)" },
56
+ { label: "Warm Charcoal", value: "#231e1a" },
57
+ { label: "Deep Slate", value: "oklch(0.19 0.015 255)" },
58
+ { label: "Carbon", value: "#141413" },
59
+ ];
60
+ const FG_PRESETS = [
61
+ { label: "Warm Ink", value: "#262626" },
62
+ { label: "Espresso", value: "#4a341c" },
63
+ { label: "Forest Ink", value: "oklch(0.392 0.0488 67.4)" },
64
+ { label: "Navy Ink", value: "oklch(0.28 0.045 255)" },
65
+ { label: "Warm Parchment", value: "#e6cfb3" },
66
+ { label: "Soft Ivory", value: "oklch(0.88 0.018 85)" },
67
+ { label: "Pure White", value: "#ffffff" },
68
+ ];
69
+
43
70
  // =========================================================================
44
71
  // STATE
45
72
  // =========================================================================
@@ -61,54 +88,41 @@
61
88
  let scrollTimer;
62
89
  let sidebarEl = $state();
63
90
 
64
- // Font dropdown state: tracks which font field is showing the picker
65
- let fontDropdown = $state(null); // null | "--font-text" | "--font-header" | ...
66
- let fontSearch = $state("");
67
- let fontHighlight = $state(-1);
68
-
69
- let filteredFonts = $derived(
70
- fontSearch.trim() === ""
71
- ? FONTS
72
- : FONTS.filter((f) => f.toLowerCase().includes(fontSearch.toLowerCase())),
73
- );
74
-
75
- function openFontDropdown(token) {
76
- fontDropdown = token;
77
- fontSearch = "";
78
- fontHighlight = -1;
79
- }
80
-
81
- function closeFontDropdown() {
82
- fontDropdown = null;
83
- fontSearch = "";
84
- fontHighlight = -1;
85
- }
91
+ // Custom OKLCH color picker state
92
+ let activePicker = $state(null); // null | "bg" | "fg"
93
+ let pickerFormat = $state("oklch"); // "oklch" | "hex"
94
+ let bgOklch = $state({ L: 1, C: 0, h: 0 });
95
+ let fgOklch = $state({ L: 0.2, C: 0, h: 0 });
96
+ let pickerCanvasEl = $state();
97
+ let isDraggingPlane = $state(false);
98
+ const hasEyeDropper =
99
+ typeof window !== "undefined" && "EyeDropper" in window;
86
100
 
87
101
  function selectFont(token, name) {
88
102
  if (!name) return;
89
- const val =
90
- name.includes(" ") && !name.startsWith('"') ? `"${name}"` : name;
103
+ const clean = name.trim().replace(/^"|"$/g, "");
104
+ const val = clean.includes(" ") ? `"${clean}"` : clean;
91
105
  writeVar(token, val);
92
- loadGoogleFont(name);
106
+ loadGoogleFont(clean);
93
107
  if (token === "--font-text") fontText = val;
94
108
  else if (token === "--font-header") fontHeader = val;
95
109
  else if (token === "--font-interface") fontInterface = val;
96
110
  else if (token === "--font-monospace") fontMono = val;
97
- closeFontDropdown();
98
- }
99
-
100
- function onFontKeydown(token, e) {
101
- if (e.key === "ArrowDown") {
102
- e.preventDefault();
103
- fontHighlight = Math.min(fontHighlight + 1, filteredFonts.length - 1);
104
- } else if (e.key === "ArrowUp") {
105
- e.preventDefault();
106
- fontHighlight = Math.max(fontHighlight - 1, -1);
107
- } else if (e.key === "Enter" && fontHighlight >= 0) {
108
- e.preventDefault();
109
- selectFont(token, filteredFonts[fontHighlight]);
110
- } else if (e.key === "Escape") {
111
- closeFontDropdown();
111
+ }
112
+
113
+ function previewFont(token, previewName, committedValue) {
114
+ if (typeof document === "undefined") return;
115
+ if (previewName) {
116
+ const clean = previewName.trim().replace(/^"|"$/g, "");
117
+ const val = clean.includes(" ") ? `"${clean}"` : clean;
118
+ loadGoogleFont(clean);
119
+ document.documentElement.style.setProperty(token, val);
120
+ } else {
121
+ if (modified.has(token) && committedValue) {
122
+ document.documentElement.style.setProperty(token, committedValue);
123
+ } else {
124
+ document.documentElement.style.removeProperty(token);
125
+ }
112
126
  }
113
127
  }
114
128
 
@@ -118,12 +132,13 @@
118
132
  }
119
133
 
120
134
  function requestCloseLab() {
121
- if (pinned) return;
122
- // Keep open if user is interacting with inputs inside
135
+ if (pinned || activePicker || isDraggingPlane) return;
136
+ // Keep open if user is interacting with inputs or popovers inside
123
137
  if (
124
138
  sidebarEl &&
125
139
  typeof document !== "undefined" &&
126
- sidebarEl.contains(document.activeElement)
140
+ (sidebarEl.contains(document.activeElement) ||
141
+ sidebarEl.querySelector(".std-combobox-popover"))
127
142
  ) {
128
143
  return;
129
144
  }
@@ -131,7 +146,7 @@
131
146
  clearTimeout(closeTimer);
132
147
  closeTimer = setTimeout(() => {
133
148
  view = "dormant";
134
- }, 100); // Increased delay for smoother transitions
149
+ }, 100);
135
150
  }
136
151
 
137
152
  function cancelCloseLab() {
@@ -153,13 +168,36 @@
153
168
  links: false,
154
169
  });
155
170
 
156
- // Color scheme detection
157
- let systemScheme = $state(
158
- typeof window !== "undefined" &&
159
- window.matchMedia?.("(prefers-color-scheme: dark)").matches
171
+ /** Detect the active scheme of the current page */
172
+ function detectPageScheme() {
173
+ if (typeof document === "undefined") return "light";
174
+ const root = document.documentElement;
175
+ const body = document.body;
176
+ const explicitMode =
177
+ root.getAttribute("data-color-mode") ||
178
+ body?.getAttribute("data-color-mode");
179
+ if (explicitMode === "dark" || explicitMode === "light") {
180
+ return explicitMode;
181
+ }
182
+ if (
183
+ root.classList.contains("theme-dark") ||
184
+ body?.classList.contains("theme-dark")
185
+ ) {
186
+ return "dark";
187
+ }
188
+ if (
189
+ root.classList.contains("theme-light") ||
190
+ body?.classList.contains("theme-light")
191
+ ) {
192
+ return "light";
193
+ }
194
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches
160
195
  ? "dark"
161
- : "light",
162
- );
196
+ : "light";
197
+ }
198
+
199
+ // Color scheme detection
200
+ let systemScheme = $state(detectPageScheme());
163
201
  let userScheme = $state("auto");
164
202
  let scheme = $derived(userScheme === "auto" ? systemScheme : userScheme);
165
203
 
@@ -176,6 +214,7 @@
176
214
  } else {
177
215
  document.documentElement.setAttribute("data-lab-scheme", userScheme);
178
216
  }
217
+ requestAnimationFrame(syncFromDOM);
179
218
  }
180
219
  });
181
220
  let bgToken = $derived(
@@ -219,23 +258,35 @@
219
258
  // HELPERS
220
259
  // =========================================================================
221
260
 
261
+ /** Find the element carrying the page's active theme tokens */
262
+ function getThemeTargetElement() {
263
+ if (typeof document === "undefined") return null;
264
+ if (document.documentElement.hasAttribute("data-theme")) {
265
+ return document.documentElement;
266
+ }
267
+ if (document.body?.hasAttribute("data-theme")) {
268
+ return document.body;
269
+ }
270
+ return document.documentElement;
271
+ }
272
+
222
273
  /** Read a CSS custom property, resolving one level of var() */
223
- function readVar(token) {
224
- const raw = getComputedStyle(document.documentElement)
225
- .getPropertyValue(token)
226
- .trim();
274
+ function readVar(token, el) {
275
+ if (typeof document === "undefined") return "";
276
+ const target = el || getThemeTargetElement() || document.documentElement;
277
+ const cs = getComputedStyle(target);
278
+ const raw = cs.getPropertyValue(token).trim();
227
279
  if (!raw) return "";
228
280
  const m = raw.match(/^var\(--([\w-]+)\)$/);
229
- return m
230
- ? getComputedStyle(document.documentElement)
231
- .getPropertyValue(`--${m[1]}`)
232
- .trim()
233
- : raw;
281
+ return m ? cs.getPropertyValue(`--${m[1]}`).trim() : raw;
234
282
  }
235
283
 
236
284
  /** Write an inline override and mark as modified */
237
285
  function writeVar(token, value) {
238
286
  document.documentElement.style.setProperty(token, value);
287
+ if (document.body?.hasAttribute("data-theme")) {
288
+ document.body.style.setProperty(token, value);
289
+ }
239
290
  modified = new Set([...modified, token]);
240
291
 
241
292
  // Persist to session
@@ -250,36 +301,6 @@
250
301
  }
251
302
  }
252
303
 
253
- /** Convert any CSS color to #hex for <input type="color"> */
254
- function toHex(css) {
255
- if (!css) return "#000000";
256
- if (/^#[0-9a-f]{6}$/i.test(css.trim())) return css.trim();
257
- if (css.trim().toLowerCase() === "white") return "#ffffff";
258
- if (css.trim().toLowerCase() === "black") return "#000000";
259
-
260
- // Use a temp element to resolve any CSS color format
261
- try {
262
- const el = document.createElement("div");
263
- el.style.color = css;
264
- document.body.appendChild(el);
265
- const resolved = getComputedStyle(el).color;
266
- el.remove();
267
- const m = resolved.match(/(\d+)/g);
268
- if (m && m.length >= 3) {
269
- return (
270
- "#" +
271
- m
272
- .slice(0, 3)
273
- .map((n) => parseInt(n).toString(16).padStart(2, "0"))
274
- .join("")
275
- );
276
- }
277
- } catch {
278
- /* noop */
279
- }
280
- return "#000000";
281
- }
282
-
283
304
  /** Resolve a CSS length (var, calc, unit) to pixels */
284
305
  function resolvePx(value) {
285
306
  if (typeof document === "undefined" || !value) return 0;
@@ -299,25 +320,53 @@
299
320
 
300
321
  /** Read all token values from the current DOM cascade */
301
322
  function syncFromDOM() {
302
- // Detect current scheme
303
- systemScheme = window.matchMedia?.("(prefers-color-scheme: dark)").matches
304
- ? "dark"
305
- : "light";
323
+ if (typeof document === "undefined") return;
324
+ systemScheme = detectPageScheme();
325
+ const target = getThemeTargetElement();
326
+
327
+ // Read and resolve the active scheme's background and foreground seeds
328
+ const rawBg = readVar(bgToken, target);
329
+ const rawFg = readVar(fgToken, target);
330
+
331
+ const parsedBg = parseCssColor(
332
+ rawBg && !/\b(from|light-dark|var)\b/i.test(rawBg)
333
+ ? rawBg
334
+ : `var(${bgToken}, var(--color-background))`,
335
+ target,
336
+ );
337
+ const parsedFg = parseCssColor(
338
+ rawFg && !/\b(from|light-dark|var)\b/i.test(rawFg)
339
+ ? rawFg
340
+ : `var(${fgToken}, var(--color-foreground))`,
341
+ target,
342
+ );
306
343
 
307
- // Read the active scheme's seeds
308
- seedBg = readVar(bgToken);
309
- seedFg = readVar(fgToken);
310
- seedBgHex = toHex(seedBg);
311
- seedFgHex = toHex(seedFg);
312
- fontText = readVar("--font-text");
313
- fontHeader = readVar("--font-header");
314
- fontInterface = readVar("--font-interface");
315
- fontMono = readVar("--font-monospace");
316
- fontSize = parseFloat(readVar("--font-size")) || 1;
317
- lineHeight = parseFloat(readVar("--line-height")) || 1.414;
318
- ratio = parseFloat(readVar("--optical-ratio")) || 1.414;
319
-
320
- const rawGap = readVar("--page-padding");
344
+ seedBg =
345
+ rawBg && !/\b(from|light-dark|var|calc|color-mix)\b/i.test(rawBg)
346
+ ? rawBg
347
+ : parsedBg.css;
348
+ seedFg =
349
+ rawFg && !/\b(from|light-dark|var|calc|color-mix)\b/i.test(rawFg)
350
+ ? rawFg
351
+ : parsedFg.css;
352
+
353
+ seedBgHex = parsedBg.hex;
354
+ seedFgHex = parsedFg.hex;
355
+ bgOklch = { L: parsedBg.L, C: parsedBg.C, h: parsedBg.h };
356
+ fgOklch = { L: parsedFg.L, C: parsedFg.C, h: parsedFg.h };
357
+
358
+ fontText = readVar("--font-text", target);
359
+ fontHeader = readVar("--font-header", target);
360
+ fontInterface = readVar("--font-interface", target);
361
+ fontMono = readVar("--font-monospace", target);
362
+ fontSize =
363
+ parseFloat(readVar("--font-text-size", target)) ||
364
+ parseFloat(readVar("--font-size", target)) ||
365
+ 1;
366
+ lineHeight = parseFloat(readVar("--line-height", target)) || 1.414;
367
+ ratio = parseFloat(readVar("--optical-ratio", target)) || 1.414;
368
+
369
+ const rawGap = readVar("--page-padding", target);
321
370
  const pxSpace = resolvePx("var(--space)");
322
371
 
323
372
  // Calculate body gap multiplier
@@ -329,13 +378,16 @@
329
378
  rhythmGap = pxSpace ? pxRhythm / pxSpace : 0.5;
330
379
 
331
380
  // Calculate radius multiplier
332
- const rawRadius = readVar("--radius");
381
+ const rawRadius = readVar("--radius", target);
333
382
  const pxRadius = resolvePx(rawRadius);
334
383
  radius = pxSpace ? pxRadius / pxSpace : 0;
335
384
 
336
385
  debug = document.documentElement.classList.contains("stnd-debug");
337
386
  eink = document.documentElement.classList.contains("standard-eink");
338
- theme = document.documentElement.getAttribute("data-theme") || "";
387
+ theme =
388
+ document.documentElement.getAttribute("data-theme") ||
389
+ document.body?.getAttribute("data-theme") ||
390
+ "";
339
391
  viewportW = window.innerWidth;
340
392
  }
341
393
 
@@ -350,7 +402,7 @@
350
402
  "--font-header",
351
403
  "--font-interface",
352
404
  "--font-monospace",
353
- "--font-size",
405
+ "--font-text-size",
354
406
  "--line-height",
355
407
  "--optical-ratio",
356
408
  "--page-padding",
@@ -410,7 +462,7 @@
410
462
  "--font-header",
411
463
  "--font-interface",
412
464
  "--font-monospace",
413
- "--font-size",
465
+ "--font-text-size",
414
466
  "--line-height",
415
467
  "--optical-ratio",
416
468
  "--page-padding",
@@ -588,6 +640,15 @@
588
640
  const savedOverrides = JSON.parse(
589
641
  sessionStorage.getItem("stnd-overrides") || "{}",
590
642
  );
643
+ if (savedOverrides["--font-size"]) {
644
+ delete savedOverrides["--font-size"];
645
+ document.documentElement.style.removeProperty("--font-size");
646
+ try {
647
+ sessionStorage.setItem("stnd-overrides", JSON.stringify(savedOverrides));
648
+ } catch {
649
+ /* noop */
650
+ }
651
+ }
591
652
  if (Object.keys(savedOverrides).length > 0) {
592
653
  for (const [token, value] of Object.entries(savedOverrides)) {
593
654
  document.documentElement.style.setProperty(token, value);
@@ -681,21 +742,35 @@
681
742
 
682
743
  function onGlobalClick(e) {
683
744
  if (view !== "open" || pinned) return;
684
- if (sidebarEl && sidebarEl.contains(e.target)) return;
745
+ if (sidebarEl && e.composedPath().includes(sidebarEl)) return;
685
746
  if (
686
- e.target.closest(".lab-handler") ||
687
- e.target.closest(".lab-handler-top")
747
+ e.target.closest?.(".lab-handler") ||
748
+ e.target.closest?.(".lab-handler-top")
688
749
  )
689
750
  return;
690
751
  view = "dormant";
691
752
  }
692
753
 
754
+ const domObserver = new MutationObserver(() => {
755
+ requestAnimationFrame(syncFromDOM);
756
+ });
757
+ domObserver.observe(document.documentElement, {
758
+ attributes: true,
759
+ attributeFilter: [
760
+ "data-theme",
761
+ "data-color-mode",
762
+ "class",
763
+ "data-lab-scheme",
764
+ ],
765
+ });
766
+
693
767
  document.addEventListener("keydown", onKey);
694
768
  document.addEventListener("click", onGlobalClick);
695
769
  window.addEventListener("resize", onResize);
696
770
  window.addEventListener("scroll", onScroll, { passive: true });
697
771
 
698
772
  return () => {
773
+ domObserver.disconnect();
699
774
  document.removeEventListener("keydown", onKey);
700
775
  document.removeEventListener("click", onGlobalClick);
701
776
  document.removeEventListener("astro:after-swap", restoreLabState);
@@ -741,6 +816,37 @@
741
816
  }
742
817
  });
743
818
 
819
+ // Render 2D color area on canvas whenever active picker, hue, or picker open state changes
820
+ $effect(() => {
821
+ if (!activePicker || !pickerCanvasEl) return;
822
+ const canvas = pickerCanvasEl;
823
+ const ctx = canvas.getContext("2d");
824
+ if (!ctx) return;
825
+
826
+ const width = canvas.width;
827
+ const height = canvas.height;
828
+ const imgData = ctx.createImageData(width, height);
829
+ const data = imgData.data;
830
+
831
+ const cur = activePicker === "bg" ? bgOklch : fgOklch;
832
+ const h = cur.h;
833
+
834
+ // Render 2D grid: X = Chroma (0 to MAX_CHROMA), Y = Lightness (1 at top to 0 at bottom)
835
+ for (let y = 0; y < height; y++) {
836
+ const L = 1 - y / (height - 1);
837
+ for (let x = 0; x < width; x++) {
838
+ const C = (x / (width - 1)) * MAX_CHROMA;
839
+ const rgb = oklchToRgb(L, C, h);
840
+ const idx = (y * width + x) * 4;
841
+ data[idx] = rgb.r;
842
+ data[idx + 1] = rgb.g;
843
+ data[idx + 2] = rgb.b;
844
+ data[idx + 3] = rgb.inGamut ? 255 : 180;
845
+ }
846
+ }
847
+ ctx.putImageData(imgData, 0, 0);
848
+ });
849
+
744
850
  // =========================================================================
745
851
  // HANDLERS — Theme
746
852
  // =========================================================================
@@ -758,6 +864,7 @@
758
864
  "--font-header",
759
865
  "--font-interface",
760
866
  "--font-monospace",
867
+ "--font-text-size",
761
868
  "--font-size",
762
869
  "--line-height",
763
870
  "--optical-ratio",
@@ -786,50 +893,115 @@
786
893
  }
787
894
 
788
895
  // =========================================================================
789
- // HANDLERS — Seed Colors
896
+ // HANDLERS — Color Picker
790
897
  // =========================================================================
791
- function onColorSwatch(role, e) {
792
- const hex = e.target.value;
898
+ function toggleColorPicker(role) {
899
+ activePicker = activePicker === role ? null : role;
900
+ }
901
+
902
+ function updateColorFromOklch(role, newOklch) {
903
+ const L = clamp(newOklch.L, 0, 1);
904
+ const C = clamp(newOklch.C, 0, MAX_CHROMA);
905
+ const h = ((newOklch.h % 360) + 360) % 360;
906
+
907
+ const rgb = oklchToRgb(L, C, h);
908
+ const hex = rgbToHex(rgb);
909
+ const oklchStr = formatOklch(L, C, h);
910
+ const formatted = pickerFormat === "hex" ? hex : oklchStr;
911
+
793
912
  const token = role === "bg" ? bgToken : fgToken;
913
+
794
914
  if (role === "bg") {
795
- seedBg = hex;
915
+ bgOklch = { L, C, h };
916
+ seedBg = formatted;
796
917
  seedBgHex = hex;
797
918
  } else {
798
- seedFg = hex;
919
+ fgOklch = { L, C, h };
920
+ seedFg = formatted;
799
921
  seedFgHex = hex;
800
922
  }
801
- writeVar(token, hex);
923
+
924
+ writeVar(token, formatted);
802
925
  }
803
926
 
804
927
  function onColorText(role, e) {
805
928
  const val = e.target.value;
806
929
  const token = role === "bg" ? bgToken : fgToken;
930
+ const target = getThemeTargetElement();
931
+ const parsed = parseCssColor(val, target);
932
+
807
933
  if (role === "bg") {
808
934
  seedBg = val;
809
- seedBgHex = toHex(val);
935
+ seedBgHex = parsed.hex;
936
+ bgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
810
937
  } else {
811
938
  seedFg = val;
812
- seedFgHex = toHex(val);
939
+ seedFgHex = parsed.hex;
940
+ fgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
813
941
  }
814
942
  writeVar(token, val);
815
943
  }
816
944
 
817
- // =========================================================================
818
- // HANDLERS — Fonts
819
- // =========================================================================
820
- function onFontInput(token, e) {
821
- const raw = e.target.value;
822
- // Wrap multi-word font names in quotes for valid CSS font-family
823
- const val = raw.includes(" ") && !raw.startsWith('"') ? `"${raw}"` : raw;
824
- writeVar(token, val);
825
- loadGoogleFont(raw);
826
- // Re-read the specific font state
827
- if (token === "--font-text") fontText = val;
828
- else if (token === "--font-header") fontHeader = val;
829
- else if (token === "--font-interface") fontInterface = val;
830
- else if (token === "--font-monospace") fontMono = val;
831
- // Update search to match typed value for filtering
832
- fontSearch = raw;
945
+ function onSliderInput(role, channel, e) {
946
+ const val = parseFloat(e.target.value);
947
+ const cur = role === "bg" ? { ...bgOklch } : { ...fgOklch };
948
+ cur[channel] = val;
949
+ updateColorFromOklch(role, cur);
950
+ }
951
+
952
+ function handlePlanePointer(e) {
953
+ if (!pickerCanvasEl || !activePicker) return;
954
+ const rect = pickerCanvasEl.getBoundingClientRect();
955
+ const x = clamp(e.clientX - rect.left, 0, rect.width);
956
+ const y = clamp(e.clientY - rect.top, 0, rect.height);
957
+
958
+ const C = (x / rect.width) * MAX_CHROMA;
959
+ const L = 1 - y / rect.height;
960
+
961
+ const cur = activePicker === "bg" ? bgOklch : fgOklch;
962
+ updateColorFromOklch(activePicker, { L, C, h: cur.h });
963
+ }
964
+
965
+ function onPlanePointerDown(e) {
966
+ isDraggingPlane = true;
967
+ e.currentTarget.setPointerCapture(e.pointerId);
968
+ handlePlanePointer(e);
969
+ }
970
+
971
+ function onPlanePointerMove(e) {
972
+ if (!isDraggingPlane) return;
973
+ handlePlanePointer(e);
974
+ }
975
+
976
+ function onPlanePointerUp(e) {
977
+ if (isDraggingPlane) {
978
+ isDraggingPlane = false;
979
+ try {
980
+ e.currentTarget.releasePointerCapture(e.pointerId);
981
+ } catch {
982
+ /* noop */
983
+ }
984
+ }
985
+ }
986
+
987
+ async function pickWithEyeDropper(role) {
988
+ if (!hasEyeDropper) return;
989
+ try {
990
+ const eyeDropper = new window.EyeDropper();
991
+ const result = await eyeDropper.open();
992
+ if (result?.sRGBHex) {
993
+ const parsed = parseCssColor(result.sRGBHex);
994
+ updateColorFromOklch(role, parsed);
995
+ }
996
+ } catch {
997
+ /* User dismissed eyedropper */
998
+ }
999
+ }
1000
+
1001
+ function applyPreset(role, val) {
1002
+ const target = getThemeTargetElement();
1003
+ const parsed = parseCssColor(val, target);
1004
+ updateColorFromOklch(role, parsed);
833
1005
  }
834
1006
 
835
1007
  // =========================================================================
@@ -837,7 +1009,11 @@
837
1009
  // =========================================================================
838
1010
  function onFontSizeInput(e) {
839
1011
  fontSize = parseFloat(e.target.value);
840
- writeVar("--font-size", `${fontSize}rem`);
1012
+ document.documentElement.style.removeProperty("--font-size");
1013
+ if (document.body?.hasAttribute("data-theme")) {
1014
+ document.body.style.removeProperty("--font-size");
1015
+ }
1016
+ writeVar("--font-text-size", `${fontSize}rem`);
841
1017
  }
842
1018
 
843
1019
  function onLineHeightInput(e) {
@@ -1093,49 +1269,207 @@
1093
1269
  {#if sectionsOpen.colors}
1094
1270
  <div class="lab-section-body" transition:slide>
1095
1271
  <div class="lab-field">
1096
- <label class="lab-field-label" for="lab-bg-text">Background</label
1097
- >
1272
+ <label class="lab-field-label" for="lab-bg-text">Background</label>
1098
1273
  <div class="lab-color-row">
1099
- <input
1100
- type="color"
1274
+ <button
1275
+ type="button"
1101
1276
  class="lab-swatch"
1102
- value={seedBgHex}
1103
- title="{scheme} background seed"
1104
- oninput={(e) => onColorSwatch("bg", e)}
1105
- />
1277
+ class:active={activePicker === "bg"}
1278
+ style="background-color: {seedBgHex};"
1279
+ title="Toggle OKLCH background picker"
1280
+ onclick={() => toggleColorPicker("bg")}
1281
+ aria-label="Pick background color"
1282
+ ></button>
1106
1283
  <input
1107
1284
  id="lab-bg-text"
1108
1285
  type="text"
1109
1286
  class="lab-text-input"
1110
1287
  value={seedBg}
1111
1288
  spellcheck="false"
1112
- placeholder="e.g. #fafafa"
1289
+ placeholder="e.g. oklch(0.23 0.02 67)"
1113
1290
  oninput={(e) => onColorText("bg", e)}
1114
1291
  />
1115
1292
  </div>
1116
1293
  </div>
1294
+
1117
1295
  <div class="lab-field">
1118
- <label class="lab-field-label" for="lab-fg-text">Foreground</label
1119
- >
1296
+ <label class="lab-field-label" for="lab-fg-text">Foreground</label>
1120
1297
  <div class="lab-color-row">
1121
- <input
1122
- type="color"
1298
+ <button
1299
+ type="button"
1123
1300
  class="lab-swatch"
1124
- value={seedFgHex}
1125
- title="{scheme} foreground seed"
1126
- oninput={(e) => onColorSwatch("fg", e)}
1127
- />
1301
+ class:active={activePicker === "fg"}
1302
+ style="background-color: {seedFgHex};"
1303
+ title="Toggle OKLCH foreground picker"
1304
+ onclick={() => toggleColorPicker("fg")}
1305
+ aria-label="Pick foreground color"
1306
+ ></button>
1128
1307
  <input
1129
1308
  id="lab-fg-text"
1130
1309
  type="text"
1131
1310
  class="lab-text-input"
1132
1311
  value={seedFg}
1133
1312
  spellcheck="false"
1134
- placeholder="e.g. #1a1a1a"
1313
+ placeholder="e.g. #262626"
1135
1314
  oninput={(e) => onColorText("fg", e)}
1136
1315
  />
1137
1316
  </div>
1138
1317
  </div>
1318
+
1319
+ <!-- Custom OKLCH Color Picker Panel -->
1320
+ {#if activePicker}
1321
+ {@const curOklch = activePicker === "bg" ? bgOklch : fgOklch}
1322
+ {@const curHex = activePicker === "bg" ? seedBgHex : seedFgHex}
1323
+ {@const presets = activePicker === "bg" ? BG_PRESETS : FG_PRESETS}
1324
+ <div class="lab-picker-panel" transition:slide>
1325
+ <header class="lab-picker-header">
1326
+ <div class="lab-picker-title">
1327
+ <span class="lab-picker-role"
1328
+ >{activePicker === "bg" ? "Background" : "Foreground"}</span
1329
+ >
1330
+ <span class="lab-picker-hex">{curHex}</span>
1331
+ </div>
1332
+ <div class="lab-picker-actions">
1333
+ <button
1334
+ type="button"
1335
+ class="smaller ghost"
1336
+ title="Toggle output format (OKLCH or Hex)"
1337
+ onclick={() => {
1338
+ pickerFormat =
1339
+ pickerFormat === "oklch" ? "hex" : "oklch";
1340
+ updateColorFromOklch(activePicker, curOklch);
1341
+ }}
1342
+ >
1343
+ {pickerFormat.toUpperCase()}
1344
+ </button>
1345
+ {#if hasEyeDropper}
1346
+ <button
1347
+ type="button"
1348
+ class="smaller icon ghost"
1349
+ title="Pick color from screen"
1350
+ onclick={() => pickWithEyeDropper(activePicker)}
1351
+ >
1352
+ <svg
1353
+ width="1em"
1354
+ height="1em"
1355
+ viewBox="0 0 24 24"
1356
+ fill="none"
1357
+ stroke="currentColor"
1358
+ stroke-width="2"
1359
+ stroke-linecap="round"
1360
+ stroke-linejoin="round"
1361
+ >
1362
+ <path
1363
+ d="m14 7 3 3m-8.5 8.5L4 20l1.5-4.5L14 7l3 3L8.5 18.5Z"
1364
+ ></path>
1365
+ </svg>
1366
+ </button>
1367
+ {/if}
1368
+ <button
1369
+ type="button"
1370
+ class="smaller icon ghost"
1371
+ title="Close color picker"
1372
+ onclick={() => (activePicker = null)}
1373
+ >
1374
+ ✕
1375
+ </button>
1376
+ </div>
1377
+ </header>
1378
+
1379
+ <!-- 2D Lightness x Chroma Color Plane -->
1380
+ <div
1381
+ class="lab-plane-wrapper"
1382
+ role="slider"
1383
+ tabindex="0"
1384
+ aria-label="Lightness and Chroma 2D plane"
1385
+ aria-valuemin="0"
1386
+ aria-valuemax="100"
1387
+ aria-valuenow={Math.round(curOklch.L * 100)}
1388
+ onpointerdown={onPlanePointerDown}
1389
+ onpointermove={onPlanePointerMove}
1390
+ onpointerup={onPlanePointerUp}
1391
+ >
1392
+ <canvas
1393
+ bind:this={pickerCanvasEl}
1394
+ width="240"
1395
+ height="110"
1396
+ class="lab-plane-canvas"
1397
+ ></canvas>
1398
+ <div
1399
+ class="lab-plane-thumb"
1400
+ style="left: {(curOklch.C / MAX_CHROMA) *
1401
+ 100}%; top: {(1 - curOklch.L) *
1402
+ 100}%; background-color: {curHex};"
1403
+ ></div>
1404
+ </div>
1405
+
1406
+ <!-- Perceptual OKLCH Sliders -->
1407
+ <div class="lab-picker-sliders">
1408
+ <!-- Lightness Slider -->
1409
+ <div class="lab-picker-slider-row">
1410
+ <span class="lab-slider-label">L</span>
1411
+ <input
1412
+ type="range"
1413
+ class="lab-range lab-range-l"
1414
+ min="0"
1415
+ max="1"
1416
+ step="0.005"
1417
+ value={curOklch.L}
1418
+ style="--slider-track: linear-gradient(to right, oklch(0 {curOklch.C} {curOklch.h}), oklch(0.5 {curOklch.C} {curOklch.h}), oklch(1 {curOklch.C} {curOklch.h}));"
1419
+ oninput={(e) => onSliderInput(activePicker, "L", e)}
1420
+ />
1421
+ <span class="lab-slider-val"
1422
+ >{Math.round(curOklch.L * 100)}%</span
1423
+ >
1424
+ </div>
1425
+
1426
+ <!-- Chroma Slider -->
1427
+ <div class="lab-picker-slider-row">
1428
+ <span class="lab-slider-label">C</span>
1429
+ <input
1430
+ type="range"
1431
+ class="lab-range lab-range-c"
1432
+ min="0"
1433
+ max={MAX_CHROMA}
1434
+ step="0.002"
1435
+ value={curOklch.C}
1436
+ style="--slider-track: linear-gradient(to right, oklch({curOklch.L} 0 {curOklch.h}), oklch({curOklch.L} {MAX_CHROMA *
1437
+ 0.5} {curOklch.h}), oklch({curOklch.L} {MAX_CHROMA} {curOklch.h}));"
1438
+ oninput={(e) => onSliderInput(activePicker, "C", e)}
1439
+ />
1440
+ <span class="lab-slider-val">{curOklch.C.toFixed(3)}</span>
1441
+ </div>
1442
+
1443
+ <!-- Hue Slider -->
1444
+ <div class="lab-picker-slider-row">
1445
+ <span class="lab-slider-label">H</span>
1446
+ <input
1447
+ type="range"
1448
+ class="lab-range lab-range-h"
1449
+ min="0"
1450
+ max="360"
1451
+ step="1"
1452
+ value={curOklch.h}
1453
+ oninput={(e) => onSliderInput(activePicker, "h", e)}
1454
+ />
1455
+ <span class="lab-slider-val">{Math.round(curOklch.h)}°</span>
1456
+ </div>
1457
+ </div>
1458
+
1459
+ <!-- Palette Presets -->
1460
+ <div class="lab-picker-presets">
1461
+ {#each presets as p}
1462
+ <button
1463
+ type="button"
1464
+ class="lab-preset-dot"
1465
+ title={p.label}
1466
+ style="background-color: {p.value};"
1467
+ onclick={() => applyPreset(activePicker, p.value)}
1468
+ ></button>
1469
+ {/each}
1470
+ </div>
1471
+ </div>
1472
+ {/if}
1139
1473
  </div>
1140
1474
  {/if}
1141
1475
  </section>
@@ -1168,13 +1502,13 @@
1168
1502
  emptyText="No fonts found."
1169
1503
  allowCustom={true}
1170
1504
  preview={true}
1171
- previewDebounce={120}
1505
+ previewDebounce={60}
1172
1506
  loadFont={(name) => loadGoogleFont(name)}
1173
1507
  class="lab-font-combobox"
1174
1508
  aria-controls={inputId}
1175
- onpreview={(e) => loadGoogleFont(e.detail?.value)}
1509
+ onpreview={(e) =>
1510
+ previewFont(token, e.detail?.value, currentValue)}
1176
1511
  onchange={(e) => {
1177
- // Combobox dispatches change with detail.value (base font name)
1178
1512
  selectFont(token, e.detail?.value);
1179
1513
  }}
1180
1514
  />
@@ -1546,14 +1880,16 @@
1546
1880
  }
1547
1881
  }
1548
1882
 
1549
- /* Reset children to Lab font */
1883
+ /* Reset children to Lab font, allowing explicit font-family previews to win */
1550
1884
  .lab-sidebar * {
1551
- font-family: var(--font-monospace) !important;
1552
1885
  box-sizing: border-box;
1553
1886
  /* Disable opacity transitions on child elements to prevent fading when opening the lab.
1554
1887
  Other property transitions for children remain allowed outside of opacity. */
1555
1888
  transition: opacity 0s !important;
1556
1889
  }
1890
+ .lab-sidebar *:not([style*="font-family"]) {
1891
+ font-family: var(--font-monospace);
1892
+ }
1557
1893
 
1558
1894
  /* ═══════════ HEADER ═══════════ */
1559
1895
  .lab-header {
@@ -1683,34 +2019,201 @@
1683
2019
  margin: 0 !important;
1684
2020
  }
1685
2021
 
1686
- /* ═══════════ COLOR ROW ═══════════ */
2022
+ /* ═══════════ COLOR ROW & SWATCH ═══════════ */
1687
2023
  .lab-color-row {
1688
2024
  display: flex;
1689
2025
  align-items: center;
1690
2026
  gap: var(--trim);
1691
2027
  }
1692
2028
  .lab-swatch {
2029
+ all: unset;
1693
2030
  width: var(--space);
1694
2031
  height: var(--space);
1695
2032
  border: var(--border);
1696
2033
  border-radius: var(--radius);
1697
2034
  cursor: pointer;
1698
- padding: 0px;
1699
- background: var(--color-surface);
2035
+ box-shadow: var(--shadow);
1700
2036
  flex-shrink: 0;
2037
+ box-sizing: border-box;
2038
+ transition: box-shadow var(--transition-fast), transform var(--transition-fast);
1701
2039
  }
1702
- .lab-swatch::-webkit-color-swatch-wrapper {
1703
- padding: 0;
2040
+ .lab-swatch:hover {
2041
+ transform: scale(1.05);
1704
2042
  }
1705
- .lab-swatch::-webkit-color-swatch {
1706
- border: none;
1707
- border-radius: var(--radius);
2043
+ .lab-swatch.active {
2044
+ box-shadow: 0 0 0 2px var(--color-accent);
1708
2045
  }
1709
2046
  .lab-color-row .lab-text-input {
1710
2047
  flex: 1;
1711
2048
  min-width: 0;
1712
2049
  }
1713
2050
 
2051
+ /* ═══════════ OKLCH COLOR PICKER ═══════════ */
2052
+ .lab-picker-panel {
2053
+ margin-top: var(--space-d2);
2054
+ margin-bottom: var(--space-d2);
2055
+ padding: var(--space-d3);
2056
+ background: var(--color-surface-sunken);
2057
+ border: var(--border);
2058
+ border-radius: var(--radius);
2059
+ box-shadow: var(--shadow-inset);
2060
+ display: flex;
2061
+ flex-direction: column;
2062
+ gap: var(--space-d3);
2063
+ }
2064
+
2065
+ .lab-picker-header {
2066
+ display: flex;
2067
+ align-items: center;
2068
+ justify-content: space-between;
2069
+ gap: var(--space-d4);
2070
+ }
2071
+
2072
+ .lab-picker-title {
2073
+ display: flex;
2074
+ align-items: baseline;
2075
+ gap: var(--space-d4);
2076
+ }
2077
+
2078
+ .lab-picker-role {
2079
+ font-size: var(--scale-d4);
2080
+ font-weight: 600;
2081
+ text-transform: uppercase;
2082
+ color: var(--color-muted);
2083
+ }
2084
+
2085
+ .lab-picker-hex {
2086
+ font-size: var(--scale-d4);
2087
+ color: var(--color-foreground);
2088
+ font-family: var(--font-monospace);
2089
+ }
2090
+
2091
+ .lab-picker-actions {
2092
+ display: flex;
2093
+ align-items: center;
2094
+ gap: var(--space-d6);
2095
+ }
2096
+
2097
+ .lab-plane-wrapper {
2098
+ position: relative;
2099
+ width: 100%;
2100
+ height: 110px;
2101
+ border-radius: var(--radius-sm);
2102
+ overflow: hidden;
2103
+ cursor: crosshair;
2104
+ user-select: none;
2105
+ touch-action: none;
2106
+ box-shadow: var(--shadow-inset);
2107
+ border: var(--border);
2108
+ }
2109
+
2110
+ .lab-plane-canvas {
2111
+ width: 100%;
2112
+ height: 100%;
2113
+ display: block;
2114
+ }
2115
+
2116
+ .lab-plane-thumb {
2117
+ position: absolute;
2118
+ width: var(--space-d2);
2119
+ height: var(--space-d2);
2120
+ border-radius: 50%;
2121
+ border: 2px solid white;
2122
+ box-shadow: 0 0 2px black;
2123
+ transform: translate(-50%, -50%);
2124
+ pointer-events: none;
2125
+ }
2126
+
2127
+ .lab-picker-sliders {
2128
+ display: flex;
2129
+ flex-direction: column;
2130
+ gap: var(--space-d4);
2131
+ }
2132
+
2133
+ .lab-picker-slider-row {
2134
+ display: grid;
2135
+ grid-template-columns: var(--space-d2) 1fr var(--space-2);
2136
+ align-items: center;
2137
+ gap: var(--space-d4);
2138
+ }
2139
+
2140
+ .lab-slider-label {
2141
+ font-size: var(--scale-d4);
2142
+ font-weight: 600;
2143
+ color: var(--color-muted);
2144
+ text-align: center;
2145
+ }
2146
+
2147
+ .lab-slider-val {
2148
+ font-size: var(--scale-d4);
2149
+ color: var(--color-foreground);
2150
+ text-align: right;
2151
+ font-family: var(--font-monospace);
2152
+ }
2153
+
2154
+ .lab-range-l,
2155
+ .lab-range-c,
2156
+ .lab-range-h {
2157
+ appearance: none;
2158
+ -webkit-appearance: none;
2159
+ width: 100%;
2160
+ height: var(--space-d4);
2161
+ border-radius: var(--radius-sm);
2162
+ outline: none;
2163
+ background: var(--slider-track, var(--color-surface));
2164
+ box-shadow: var(--shadow-inset);
2165
+ cursor: pointer;
2166
+ }
2167
+
2168
+ .lab-range-h {
2169
+ background: linear-gradient(
2170
+ to right,
2171
+ oklch(0.7 0.18 0),
2172
+ oklch(0.7 0.18 60),
2173
+ oklch(0.7 0.18 120),
2174
+ oklch(0.7 0.18 180),
2175
+ oklch(0.7 0.18 240),
2176
+ oklch(0.7 0.18 300),
2177
+ oklch(0.7 0.18 360)
2178
+ );
2179
+ }
2180
+
2181
+ .lab-range::-webkit-slider-thumb {
2182
+ appearance: none;
2183
+ -webkit-appearance: none;
2184
+ width: var(--space-d2);
2185
+ height: var(--space-d2);
2186
+ border-radius: 50%;
2187
+ background: var(--color-surface);
2188
+ border: 2px solid var(--color-foreground);
2189
+ box-shadow: var(--shadow);
2190
+ cursor: pointer;
2191
+ }
2192
+
2193
+ .lab-picker-presets {
2194
+ display: flex;
2195
+ flex-wrap: wrap;
2196
+ gap: var(--space-d4);
2197
+ padding-top: var(--space-d6);
2198
+ border-top: var(--border);
2199
+ }
2200
+
2201
+ .lab-preset-dot {
2202
+ all: unset;
2203
+ width: var(--space-d2);
2204
+ height: var(--space-d2);
2205
+ border-radius: var(--radius-sm);
2206
+ border: var(--border);
2207
+ cursor: pointer;
2208
+ box-shadow: var(--shadow);
2209
+ transition: transform var(--transition-fast);
2210
+ box-sizing: border-box;
2211
+ }
2212
+
2213
+ .lab-preset-dot:hover {
2214
+ transform: scale(1.15);
2215
+ }
2216
+
1714
2217
  /* ═══════════ SELECT ═══════════ */
1715
2218
  .lab-select {
1716
2219
  font-size: var(--scale-d4) !important;