@stnd/lab 1.4.1 → 1.5.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 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,50 @@
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 = parseFloat(readVar("--font-size", target)) || 1;
363
+ lineHeight = parseFloat(readVar("--line-height", target)) || 1.414;
364
+ ratio = parseFloat(readVar("--optical-ratio", target)) || 1.414;
365
+
366
+ const rawGap = readVar("--page-padding", target);
321
367
  const pxSpace = resolvePx("var(--space)");
322
368
 
323
369
  // Calculate body gap multiplier
@@ -329,13 +375,16 @@
329
375
  rhythmGap = pxSpace ? pxRhythm / pxSpace : 0.5;
330
376
 
331
377
  // Calculate radius multiplier
332
- const rawRadius = readVar("--radius");
378
+ const rawRadius = readVar("--radius", target);
333
379
  const pxRadius = resolvePx(rawRadius);
334
380
  radius = pxSpace ? pxRadius / pxSpace : 0;
335
381
 
336
382
  debug = document.documentElement.classList.contains("stnd-debug");
337
383
  eink = document.documentElement.classList.contains("standard-eink");
338
- theme = document.documentElement.getAttribute("data-theme") || "";
384
+ theme =
385
+ document.documentElement.getAttribute("data-theme") ||
386
+ document.body?.getAttribute("data-theme") ||
387
+ "";
339
388
  viewportW = window.innerWidth;
340
389
  }
341
390
 
@@ -681,21 +730,35 @@
681
730
 
682
731
  function onGlobalClick(e) {
683
732
  if (view !== "open" || pinned) return;
684
- if (sidebarEl && sidebarEl.contains(e.target)) return;
733
+ if (sidebarEl && e.composedPath().includes(sidebarEl)) return;
685
734
  if (
686
- e.target.closest(".lab-handler") ||
687
- e.target.closest(".lab-handler-top")
735
+ e.target.closest?.(".lab-handler") ||
736
+ e.target.closest?.(".lab-handler-top")
688
737
  )
689
738
  return;
690
739
  view = "dormant";
691
740
  }
692
741
 
742
+ const domObserver = new MutationObserver(() => {
743
+ requestAnimationFrame(syncFromDOM);
744
+ });
745
+ domObserver.observe(document.documentElement, {
746
+ attributes: true,
747
+ attributeFilter: [
748
+ "data-theme",
749
+ "data-color-mode",
750
+ "class",
751
+ "data-lab-scheme",
752
+ ],
753
+ });
754
+
693
755
  document.addEventListener("keydown", onKey);
694
756
  document.addEventListener("click", onGlobalClick);
695
757
  window.addEventListener("resize", onResize);
696
758
  window.addEventListener("scroll", onScroll, { passive: true });
697
759
 
698
760
  return () => {
761
+ domObserver.disconnect();
699
762
  document.removeEventListener("keydown", onKey);
700
763
  document.removeEventListener("click", onGlobalClick);
701
764
  document.removeEventListener("astro:after-swap", restoreLabState);
@@ -741,6 +804,37 @@
741
804
  }
742
805
  });
743
806
 
807
+ // Render 2D color area on canvas whenever active picker, hue, or picker open state changes
808
+ $effect(() => {
809
+ if (!activePicker || !pickerCanvasEl) return;
810
+ const canvas = pickerCanvasEl;
811
+ const ctx = canvas.getContext("2d");
812
+ if (!ctx) return;
813
+
814
+ const width = canvas.width;
815
+ const height = canvas.height;
816
+ const imgData = ctx.createImageData(width, height);
817
+ const data = imgData.data;
818
+
819
+ const cur = activePicker === "bg" ? bgOklch : fgOklch;
820
+ const h = cur.h;
821
+
822
+ // Render 2D grid: X = Chroma (0 to MAX_CHROMA), Y = Lightness (1 at top to 0 at bottom)
823
+ for (let y = 0; y < height; y++) {
824
+ const L = 1 - y / (height - 1);
825
+ for (let x = 0; x < width; x++) {
826
+ const C = (x / (width - 1)) * MAX_CHROMA;
827
+ const rgb = oklchToRgb(L, C, h);
828
+ const idx = (y * width + x) * 4;
829
+ data[idx] = rgb.r;
830
+ data[idx + 1] = rgb.g;
831
+ data[idx + 2] = rgb.b;
832
+ data[idx + 3] = rgb.inGamut ? 255 : 180;
833
+ }
834
+ }
835
+ ctx.putImageData(imgData, 0, 0);
836
+ });
837
+
744
838
  // =========================================================================
745
839
  // HANDLERS — Theme
746
840
  // =========================================================================
@@ -786,50 +880,115 @@
786
880
  }
787
881
 
788
882
  // =========================================================================
789
- // HANDLERS — Seed Colors
883
+ // HANDLERS — Color Picker
790
884
  // =========================================================================
791
- function onColorSwatch(role, e) {
792
- const hex = e.target.value;
885
+ function toggleColorPicker(role) {
886
+ activePicker = activePicker === role ? null : role;
887
+ }
888
+
889
+ function updateColorFromOklch(role, newOklch) {
890
+ const L = clamp(newOklch.L, 0, 1);
891
+ const C = clamp(newOklch.C, 0, MAX_CHROMA);
892
+ const h = ((newOklch.h % 360) + 360) % 360;
893
+
894
+ const rgb = oklchToRgb(L, C, h);
895
+ const hex = rgbToHex(rgb);
896
+ const oklchStr = formatOklch(L, C, h);
897
+ const formatted = pickerFormat === "hex" ? hex : oklchStr;
898
+
793
899
  const token = role === "bg" ? bgToken : fgToken;
900
+
794
901
  if (role === "bg") {
795
- seedBg = hex;
902
+ bgOklch = { L, C, h };
903
+ seedBg = formatted;
796
904
  seedBgHex = hex;
797
905
  } else {
798
- seedFg = hex;
906
+ fgOklch = { L, C, h };
907
+ seedFg = formatted;
799
908
  seedFgHex = hex;
800
909
  }
801
- writeVar(token, hex);
910
+
911
+ writeVar(token, formatted);
802
912
  }
803
913
 
804
914
  function onColorText(role, e) {
805
915
  const val = e.target.value;
806
916
  const token = role === "bg" ? bgToken : fgToken;
917
+ const target = getThemeTargetElement();
918
+ const parsed = parseCssColor(val, target);
919
+
807
920
  if (role === "bg") {
808
921
  seedBg = val;
809
- seedBgHex = toHex(val);
922
+ seedBgHex = parsed.hex;
923
+ bgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
810
924
  } else {
811
925
  seedFg = val;
812
- seedFgHex = toHex(val);
926
+ seedFgHex = parsed.hex;
927
+ fgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
813
928
  }
814
929
  writeVar(token, val);
815
930
  }
816
931
 
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;
932
+ function onSliderInput(role, channel, e) {
933
+ const val = parseFloat(e.target.value);
934
+ const cur = role === "bg" ? { ...bgOklch } : { ...fgOklch };
935
+ cur[channel] = val;
936
+ updateColorFromOklch(role, cur);
937
+ }
938
+
939
+ function handlePlanePointer(e) {
940
+ if (!pickerCanvasEl || !activePicker) return;
941
+ const rect = pickerCanvasEl.getBoundingClientRect();
942
+ const x = clamp(e.clientX - rect.left, 0, rect.width);
943
+ const y = clamp(e.clientY - rect.top, 0, rect.height);
944
+
945
+ const C = (x / rect.width) * MAX_CHROMA;
946
+ const L = 1 - y / rect.height;
947
+
948
+ const cur = activePicker === "bg" ? bgOklch : fgOklch;
949
+ updateColorFromOklch(activePicker, { L, C, h: cur.h });
950
+ }
951
+
952
+ function onPlanePointerDown(e) {
953
+ isDraggingPlane = true;
954
+ e.currentTarget.setPointerCapture(e.pointerId);
955
+ handlePlanePointer(e);
956
+ }
957
+
958
+ function onPlanePointerMove(e) {
959
+ if (!isDraggingPlane) return;
960
+ handlePlanePointer(e);
961
+ }
962
+
963
+ function onPlanePointerUp(e) {
964
+ if (isDraggingPlane) {
965
+ isDraggingPlane = false;
966
+ try {
967
+ e.currentTarget.releasePointerCapture(e.pointerId);
968
+ } catch {
969
+ /* noop */
970
+ }
971
+ }
972
+ }
973
+
974
+ async function pickWithEyeDropper(role) {
975
+ if (!hasEyeDropper) return;
976
+ try {
977
+ const eyeDropper = new window.EyeDropper();
978
+ const result = await eyeDropper.open();
979
+ if (result?.sRGBHex) {
980
+ const parsed = parseCssColor(result.sRGBHex);
981
+ updateColorFromOklch(role, parsed);
982
+ }
983
+ } catch {
984
+ /* User dismissed eyedropper */
985
+ }
986
+ }
987
+
988
+ function applyPreset(role, val) {
989
+ const target = getThemeTargetElement();
990
+ const parsed = parseCssColor(val, target);
991
+ updateColorFromOklch(role, parsed);
833
992
  }
834
993
 
835
994
  // =========================================================================
@@ -1093,49 +1252,207 @@
1093
1252
  {#if sectionsOpen.colors}
1094
1253
  <div class="lab-section-body" transition:slide>
1095
1254
  <div class="lab-field">
1096
- <label class="lab-field-label" for="lab-bg-text">Background</label
1097
- >
1255
+ <label class="lab-field-label" for="lab-bg-text">Background</label>
1098
1256
  <div class="lab-color-row">
1099
- <input
1100
- type="color"
1257
+ <button
1258
+ type="button"
1101
1259
  class="lab-swatch"
1102
- value={seedBgHex}
1103
- title="{scheme} background seed"
1104
- oninput={(e) => onColorSwatch("bg", e)}
1105
- />
1260
+ class:active={activePicker === "bg"}
1261
+ style="background-color: {seedBgHex};"
1262
+ title="Toggle OKLCH background picker"
1263
+ onclick={() => toggleColorPicker("bg")}
1264
+ aria-label="Pick background color"
1265
+ ></button>
1106
1266
  <input
1107
1267
  id="lab-bg-text"
1108
1268
  type="text"
1109
1269
  class="lab-text-input"
1110
1270
  value={seedBg}
1111
1271
  spellcheck="false"
1112
- placeholder="e.g. #fafafa"
1272
+ placeholder="e.g. oklch(0.23 0.02 67)"
1113
1273
  oninput={(e) => onColorText("bg", e)}
1114
1274
  />
1115
1275
  </div>
1116
1276
  </div>
1277
+
1117
1278
  <div class="lab-field">
1118
- <label class="lab-field-label" for="lab-fg-text">Foreground</label
1119
- >
1279
+ <label class="lab-field-label" for="lab-fg-text">Foreground</label>
1120
1280
  <div class="lab-color-row">
1121
- <input
1122
- type="color"
1281
+ <button
1282
+ type="button"
1123
1283
  class="lab-swatch"
1124
- value={seedFgHex}
1125
- title="{scheme} foreground seed"
1126
- oninput={(e) => onColorSwatch("fg", e)}
1127
- />
1284
+ class:active={activePicker === "fg"}
1285
+ style="background-color: {seedFgHex};"
1286
+ title="Toggle OKLCH foreground picker"
1287
+ onclick={() => toggleColorPicker("fg")}
1288
+ aria-label="Pick foreground color"
1289
+ ></button>
1128
1290
  <input
1129
1291
  id="lab-fg-text"
1130
1292
  type="text"
1131
1293
  class="lab-text-input"
1132
1294
  value={seedFg}
1133
1295
  spellcheck="false"
1134
- placeholder="e.g. #1a1a1a"
1296
+ placeholder="e.g. #262626"
1135
1297
  oninput={(e) => onColorText("fg", e)}
1136
1298
  />
1137
1299
  </div>
1138
1300
  </div>
1301
+
1302
+ <!-- Custom OKLCH Color Picker Panel -->
1303
+ {#if activePicker}
1304
+ {@const curOklch = activePicker === "bg" ? bgOklch : fgOklch}
1305
+ {@const curHex = activePicker === "bg" ? seedBgHex : seedFgHex}
1306
+ {@const presets = activePicker === "bg" ? BG_PRESETS : FG_PRESETS}
1307
+ <div class="lab-picker-panel" transition:slide>
1308
+ <header class="lab-picker-header">
1309
+ <div class="lab-picker-title">
1310
+ <span class="lab-picker-role"
1311
+ >{activePicker === "bg" ? "Background" : "Foreground"}</span
1312
+ >
1313
+ <span class="lab-picker-hex">{curHex}</span>
1314
+ </div>
1315
+ <div class="lab-picker-actions">
1316
+ <button
1317
+ type="button"
1318
+ class="smaller ghost"
1319
+ title="Toggle output format (OKLCH or Hex)"
1320
+ onclick={() => {
1321
+ pickerFormat =
1322
+ pickerFormat === "oklch" ? "hex" : "oklch";
1323
+ updateColorFromOklch(activePicker, curOklch);
1324
+ }}
1325
+ >
1326
+ {pickerFormat.toUpperCase()}
1327
+ </button>
1328
+ {#if hasEyeDropper}
1329
+ <button
1330
+ type="button"
1331
+ class="smaller icon ghost"
1332
+ title="Pick color from screen"
1333
+ onclick={() => pickWithEyeDropper(activePicker)}
1334
+ >
1335
+ <svg
1336
+ width="1em"
1337
+ height="1em"
1338
+ viewBox="0 0 24 24"
1339
+ fill="none"
1340
+ stroke="currentColor"
1341
+ stroke-width="2"
1342
+ stroke-linecap="round"
1343
+ stroke-linejoin="round"
1344
+ >
1345
+ <path
1346
+ d="m14 7 3 3m-8.5 8.5L4 20l1.5-4.5L14 7l3 3L8.5 18.5Z"
1347
+ ></path>
1348
+ </svg>
1349
+ </button>
1350
+ {/if}
1351
+ <button
1352
+ type="button"
1353
+ class="smaller icon ghost"
1354
+ title="Close color picker"
1355
+ onclick={() => (activePicker = null)}
1356
+ >
1357
+ ✕
1358
+ </button>
1359
+ </div>
1360
+ </header>
1361
+
1362
+ <!-- 2D Lightness x Chroma Color Plane -->
1363
+ <div
1364
+ class="lab-plane-wrapper"
1365
+ role="slider"
1366
+ tabindex="0"
1367
+ aria-label="Lightness and Chroma 2D plane"
1368
+ aria-valuemin="0"
1369
+ aria-valuemax="100"
1370
+ aria-valuenow={Math.round(curOklch.L * 100)}
1371
+ onpointerdown={onPlanePointerDown}
1372
+ onpointermove={onPlanePointerMove}
1373
+ onpointerup={onPlanePointerUp}
1374
+ >
1375
+ <canvas
1376
+ bind:this={pickerCanvasEl}
1377
+ width="240"
1378
+ height="110"
1379
+ class="lab-plane-canvas"
1380
+ ></canvas>
1381
+ <div
1382
+ class="lab-plane-thumb"
1383
+ style="left: {(curOklch.C / MAX_CHROMA) *
1384
+ 100}%; top: {(1 - curOklch.L) *
1385
+ 100}%; background-color: {curHex};"
1386
+ ></div>
1387
+ </div>
1388
+
1389
+ <!-- Perceptual OKLCH Sliders -->
1390
+ <div class="lab-picker-sliders">
1391
+ <!-- Lightness Slider -->
1392
+ <div class="lab-picker-slider-row">
1393
+ <span class="lab-slider-label">L</span>
1394
+ <input
1395
+ type="range"
1396
+ class="lab-range lab-range-l"
1397
+ min="0"
1398
+ max="1"
1399
+ step="0.005"
1400
+ value={curOklch.L}
1401
+ 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}));"
1402
+ oninput={(e) => onSliderInput(activePicker, "L", e)}
1403
+ />
1404
+ <span class="lab-slider-val"
1405
+ >{Math.round(curOklch.L * 100)}%</span
1406
+ >
1407
+ </div>
1408
+
1409
+ <!-- Chroma Slider -->
1410
+ <div class="lab-picker-slider-row">
1411
+ <span class="lab-slider-label">C</span>
1412
+ <input
1413
+ type="range"
1414
+ class="lab-range lab-range-c"
1415
+ min="0"
1416
+ max={MAX_CHROMA}
1417
+ step="0.002"
1418
+ value={curOklch.C}
1419
+ style="--slider-track: linear-gradient(to right, oklch({curOklch.L} 0 {curOklch.h}), oklch({curOklch.L} {MAX_CHROMA *
1420
+ 0.5} {curOklch.h}), oklch({curOklch.L} {MAX_CHROMA} {curOklch.h}));"
1421
+ oninput={(e) => onSliderInput(activePicker, "C", e)}
1422
+ />
1423
+ <span class="lab-slider-val">{curOklch.C.toFixed(3)}</span>
1424
+ </div>
1425
+
1426
+ <!-- Hue Slider -->
1427
+ <div class="lab-picker-slider-row">
1428
+ <span class="lab-slider-label">H</span>
1429
+ <input
1430
+ type="range"
1431
+ class="lab-range lab-range-h"
1432
+ min="0"
1433
+ max="360"
1434
+ step="1"
1435
+ value={curOklch.h}
1436
+ oninput={(e) => onSliderInput(activePicker, "h", e)}
1437
+ />
1438
+ <span class="lab-slider-val">{Math.round(curOklch.h)}°</span>
1439
+ </div>
1440
+ </div>
1441
+
1442
+ <!-- Palette Presets -->
1443
+ <div class="lab-picker-presets">
1444
+ {#each presets as p}
1445
+ <button
1446
+ type="button"
1447
+ class="lab-preset-dot"
1448
+ title={p.label}
1449
+ style="background-color: {p.value};"
1450
+ onclick={() => applyPreset(activePicker, p.value)}
1451
+ ></button>
1452
+ {/each}
1453
+ </div>
1454
+ </div>
1455
+ {/if}
1139
1456
  </div>
1140
1457
  {/if}
1141
1458
  </section>
@@ -1168,13 +1485,13 @@
1168
1485
  emptyText="No fonts found."
1169
1486
  allowCustom={true}
1170
1487
  preview={true}
1171
- previewDebounce={120}
1488
+ previewDebounce={60}
1172
1489
  loadFont={(name) => loadGoogleFont(name)}
1173
1490
  class="lab-font-combobox"
1174
1491
  aria-controls={inputId}
1175
- onpreview={(e) => loadGoogleFont(e.detail?.value)}
1492
+ onpreview={(e) =>
1493
+ previewFont(token, e.detail?.value, currentValue)}
1176
1494
  onchange={(e) => {
1177
- // Combobox dispatches change with detail.value (base font name)
1178
1495
  selectFont(token, e.detail?.value);
1179
1496
  }}
1180
1497
  />
@@ -1546,14 +1863,16 @@
1546
1863
  }
1547
1864
  }
1548
1865
 
1549
- /* Reset children to Lab font */
1866
+ /* Reset children to Lab font, allowing explicit font-family previews to win */
1550
1867
  .lab-sidebar * {
1551
- font-family: var(--font-monospace) !important;
1552
1868
  box-sizing: border-box;
1553
1869
  /* Disable opacity transitions on child elements to prevent fading when opening the lab.
1554
1870
  Other property transitions for children remain allowed outside of opacity. */
1555
1871
  transition: opacity 0s !important;
1556
1872
  }
1873
+ .lab-sidebar *:not([style*="font-family"]) {
1874
+ font-family: var(--font-monospace);
1875
+ }
1557
1876
 
1558
1877
  /* ═══════════ HEADER ═══════════ */
1559
1878
  .lab-header {
@@ -1683,34 +2002,201 @@
1683
2002
  margin: 0 !important;
1684
2003
  }
1685
2004
 
1686
- /* ═══════════ COLOR ROW ═══════════ */
2005
+ /* ═══════════ COLOR ROW & SWATCH ═══════════ */
1687
2006
  .lab-color-row {
1688
2007
  display: flex;
1689
2008
  align-items: center;
1690
2009
  gap: var(--trim);
1691
2010
  }
1692
2011
  .lab-swatch {
2012
+ all: unset;
1693
2013
  width: var(--space);
1694
2014
  height: var(--space);
1695
2015
  border: var(--border);
1696
2016
  border-radius: var(--radius);
1697
2017
  cursor: pointer;
1698
- padding: 0px;
1699
- background: var(--color-surface);
2018
+ box-shadow: var(--shadow);
1700
2019
  flex-shrink: 0;
2020
+ box-sizing: border-box;
2021
+ transition: box-shadow var(--transition-fast), transform var(--transition-fast);
1701
2022
  }
1702
- .lab-swatch::-webkit-color-swatch-wrapper {
1703
- padding: 0;
2023
+ .lab-swatch:hover {
2024
+ transform: scale(1.05);
1704
2025
  }
1705
- .lab-swatch::-webkit-color-swatch {
1706
- border: none;
1707
- border-radius: var(--radius);
2026
+ .lab-swatch.active {
2027
+ box-shadow: 0 0 0 2px var(--color-accent);
1708
2028
  }
1709
2029
  .lab-color-row .lab-text-input {
1710
2030
  flex: 1;
1711
2031
  min-width: 0;
1712
2032
  }
1713
2033
 
2034
+ /* ═══════════ OKLCH COLOR PICKER ═══════════ */
2035
+ .lab-picker-panel {
2036
+ margin-top: var(--space-d2);
2037
+ margin-bottom: var(--space-d2);
2038
+ padding: var(--space-d3);
2039
+ background: var(--color-surface-sunken);
2040
+ border: var(--border);
2041
+ border-radius: var(--radius);
2042
+ box-shadow: var(--shadow-inset);
2043
+ display: flex;
2044
+ flex-direction: column;
2045
+ gap: var(--space-d3);
2046
+ }
2047
+
2048
+ .lab-picker-header {
2049
+ display: flex;
2050
+ align-items: center;
2051
+ justify-content: space-between;
2052
+ gap: var(--space-d4);
2053
+ }
2054
+
2055
+ .lab-picker-title {
2056
+ display: flex;
2057
+ align-items: baseline;
2058
+ gap: var(--space-d4);
2059
+ }
2060
+
2061
+ .lab-picker-role {
2062
+ font-size: var(--scale-d4);
2063
+ font-weight: 600;
2064
+ text-transform: uppercase;
2065
+ color: var(--color-muted);
2066
+ }
2067
+
2068
+ .lab-picker-hex {
2069
+ font-size: var(--scale-d4);
2070
+ color: var(--color-foreground);
2071
+ font-family: var(--font-monospace);
2072
+ }
2073
+
2074
+ .lab-picker-actions {
2075
+ display: flex;
2076
+ align-items: center;
2077
+ gap: var(--space-d6);
2078
+ }
2079
+
2080
+ .lab-plane-wrapper {
2081
+ position: relative;
2082
+ width: 100%;
2083
+ height: 110px;
2084
+ border-radius: var(--radius-sm);
2085
+ overflow: hidden;
2086
+ cursor: crosshair;
2087
+ user-select: none;
2088
+ touch-action: none;
2089
+ box-shadow: var(--shadow-inset);
2090
+ border: var(--border);
2091
+ }
2092
+
2093
+ .lab-plane-canvas {
2094
+ width: 100%;
2095
+ height: 100%;
2096
+ display: block;
2097
+ }
2098
+
2099
+ .lab-plane-thumb {
2100
+ position: absolute;
2101
+ width: var(--space-d2);
2102
+ height: var(--space-d2);
2103
+ border-radius: 50%;
2104
+ border: 2px solid white;
2105
+ box-shadow: 0 0 2px black;
2106
+ transform: translate(-50%, -50%);
2107
+ pointer-events: none;
2108
+ }
2109
+
2110
+ .lab-picker-sliders {
2111
+ display: flex;
2112
+ flex-direction: column;
2113
+ gap: var(--space-d4);
2114
+ }
2115
+
2116
+ .lab-picker-slider-row {
2117
+ display: grid;
2118
+ grid-template-columns: var(--space-d2) 1fr var(--space-2);
2119
+ align-items: center;
2120
+ gap: var(--space-d4);
2121
+ }
2122
+
2123
+ .lab-slider-label {
2124
+ font-size: var(--scale-d4);
2125
+ font-weight: 600;
2126
+ color: var(--color-muted);
2127
+ text-align: center;
2128
+ }
2129
+
2130
+ .lab-slider-val {
2131
+ font-size: var(--scale-d4);
2132
+ color: var(--color-foreground);
2133
+ text-align: right;
2134
+ font-family: var(--font-monospace);
2135
+ }
2136
+
2137
+ .lab-range-l,
2138
+ .lab-range-c,
2139
+ .lab-range-h {
2140
+ appearance: none;
2141
+ -webkit-appearance: none;
2142
+ width: 100%;
2143
+ height: var(--space-d4);
2144
+ border-radius: var(--radius-sm);
2145
+ outline: none;
2146
+ background: var(--slider-track, var(--color-surface));
2147
+ box-shadow: var(--shadow-inset);
2148
+ cursor: pointer;
2149
+ }
2150
+
2151
+ .lab-range-h {
2152
+ background: linear-gradient(
2153
+ to right,
2154
+ oklch(0.7 0.18 0),
2155
+ oklch(0.7 0.18 60),
2156
+ oklch(0.7 0.18 120),
2157
+ oklch(0.7 0.18 180),
2158
+ oklch(0.7 0.18 240),
2159
+ oklch(0.7 0.18 300),
2160
+ oklch(0.7 0.18 360)
2161
+ );
2162
+ }
2163
+
2164
+ .lab-range::-webkit-slider-thumb {
2165
+ appearance: none;
2166
+ -webkit-appearance: none;
2167
+ width: var(--space-d2);
2168
+ height: var(--space-d2);
2169
+ border-radius: 50%;
2170
+ background: var(--color-surface);
2171
+ border: 2px solid var(--color-foreground);
2172
+ box-shadow: var(--shadow);
2173
+ cursor: pointer;
2174
+ }
2175
+
2176
+ .lab-picker-presets {
2177
+ display: flex;
2178
+ flex-wrap: wrap;
2179
+ gap: var(--space-d4);
2180
+ padding-top: var(--space-d6);
2181
+ border-top: var(--border);
2182
+ }
2183
+
2184
+ .lab-preset-dot {
2185
+ all: unset;
2186
+ width: var(--space-d2);
2187
+ height: var(--space-d2);
2188
+ border-radius: var(--radius-sm);
2189
+ border: var(--border);
2190
+ cursor: pointer;
2191
+ box-shadow: var(--shadow);
2192
+ transition: transform var(--transition-fast);
2193
+ box-sizing: border-box;
2194
+ }
2195
+
2196
+ .lab-preset-dot:hover {
2197
+ transform: scale(1.15);
2198
+ }
2199
+
1714
2200
  /* ═══════════ SELECT ═══════════ */
1715
2201
  .lab-select {
1716
2202
  font-size: var(--scale-d4) !important;