@stnd/lab 1.5.1 → 1.6.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/Lab.svelte +170 -67
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @stnd/lab
2
2
 
3
+ ## 1.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @stnd/ui@0.16.1
8
+
9
+ ## 1.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Auto-bumped @stnd/lab due to modified code.
14
+
3
15
  ## 1.5.1
4
16
 
5
17
  ### Patch Changes
package/Lab.svelte CHANGED
@@ -57,6 +57,15 @@
57
57
  { label: "Deep Slate", value: "oklch(0.19 0.015 255)" },
58
58
  { label: "Carbon", value: "#141413" },
59
59
  ];
60
+ const ACCENT_PRESETS = [
61
+ { label: "Signal Red", value: "oklch(0.6 0.2 27)" },
62
+ { label: "Amber", value: "oklch(0.5 0.12 55)" },
63
+ { label: "Forest", value: "oklch(0.5 0.12 150)" },
64
+ { label: "Teal", value: "oklch(0.6 0.1 195)" },
65
+ { label: "Ocean", value: "oklch(0.55 0.15 245)" },
66
+ { label: "Violet", value: "oklch(0.5 0.18 300)" },
67
+ { label: "Candy Pink", value: "oklch(0.65 0.22 355)" },
68
+ ];
60
69
  const FG_PRESETS = [
61
70
  { label: "Warm Ink", value: "#262626" },
62
71
  { label: "Espresso", value: "#4a341c" },
@@ -72,7 +81,28 @@
72
81
  // =========================================================================
73
82
  // Props. `open` opens the panel on mount and `pin` keeps it open: the Studio
74
83
  // page embeds the Lab that way, so it works in production too.
75
- let { open = false, pin = false } = $props();
84
+ // `showHandler` controls whether the floating edge pastille/handler is rendered.
85
+ let { open = false, pin = false, showHandler = true } = $props();
86
+
87
+ let handlerVisible = $state(
88
+ showHandler &&
89
+ (typeof document === "undefined" ||
90
+ (document.documentElement.getAttribute("data-lab-handler") !== "false" &&
91
+ !document.documentElement.hasAttribute("data-lab-no-handler"))),
92
+ );
93
+
94
+ function syncHandlerVisibility() {
95
+ if (
96
+ showHandler === false ||
97
+ (typeof document !== "undefined" &&
98
+ (document.documentElement.getAttribute("data-lab-handler") === "false" ||
99
+ document.documentElement.hasAttribute("data-lab-no-handler")))
100
+ ) {
101
+ handlerVisible = false;
102
+ } else {
103
+ handlerVisible = true;
104
+ }
105
+ }
76
106
 
77
107
  // One Lab per page. The dev module mounts it on every page and the Studio
78
108
  // mounts its own; whichever comes second renders nothing and asks the first
@@ -89,10 +119,11 @@
89
119
  let sidebarEl = $state();
90
120
 
91
121
  // Custom OKLCH color picker state
92
- let activePicker = $state(null); // null | "bg" | "fg"
122
+ let activePicker = $state(null); // null | "bg" | "fg" | "accent"
93
123
  let pickerFormat = $state("oklch"); // "oklch" | "hex"
94
124
  let bgOklch = $state({ L: 1, C: 0, h: 0 });
95
125
  let fgOklch = $state({ L: 0.2, C: 0, h: 0 });
126
+ let accentOklch = $state({ L: 0.6, C: 0.15, h: 30 });
96
127
  let pickerCanvasEl = $state();
97
128
  let isDraggingPlane = $state(false);
98
129
  const hasEyeDropper =
@@ -159,11 +190,9 @@
159
190
 
160
191
  // Collapsible sections
161
192
  let sectionsOpen = $state({
162
- theme: false,
163
- colors: false,
164
- typography: false,
165
- scale: false,
166
- spacing: false,
193
+ theme: true,
194
+ colors: true,
195
+ typography: true,
167
196
  debug: false,
168
197
  links: false,
169
198
  });
@@ -223,17 +252,28 @@
223
252
  let fgToken = $derived(
224
253
  scheme === "dark" ? "--color-dark-foreground" : "--color-light-foreground",
225
254
  );
255
+ let accentToken = $derived(
256
+ scheme === "dark" ? "--color-dark-accent" : "--color-light-accent",
257
+ );
258
+ const oklchOf = (role) =>
259
+ role === "bg" ? bgOklch : role === "fg" ? fgOklch : accentOklch;
260
+ const tokenOf = (role) =>
261
+ role === "bg" ? bgToken : role === "fg" ? fgToken : accentToken;
262
+ const ROLE_LABEL = { bg: "Background", fg: "Foreground", accent: "Accent" };
226
263
 
227
264
  // Quick panel seeds (scheme-aware)
228
265
  let seedBg = $state("");
229
266
  let seedFg = $state("");
230
267
  let seedBgHex = $state("#ffffff");
231
268
  let seedFgHex = $state("#262626");
269
+ let seedAccent = $state("");
270
+ let seedAccentHex = $state("transparent");
232
271
  let fontText = $state("");
233
272
  let fontHeader = $state("");
234
273
  let fontInterface = $state("");
235
274
  let fontMono = $state("");
236
275
  let fontSize = $state(1);
276
+ /** Font density: the theme-authoring knob. `--line-height` is derived from it, clamped to 1.35–1.75. */
237
277
  let lineHeight = $state(1.414);
238
278
  let rhythmGap = $state(1);
239
279
  let ratio = $state(1.414);
@@ -355,6 +395,20 @@
355
395
  bgOklch = { L: parsedBg.L, C: parsedBg.C, h: parsedBg.h };
356
396
  fgOklch = { L: parsedFg.L, C: parsedFg.C, h: parsedFg.h };
357
397
 
398
+ const rawAccent = readVar(accentToken, target);
399
+ const parsedAccent = parseCssColor(
400
+ rawAccent && !/\b(from|light-dark|var)\b/i.test(rawAccent)
401
+ ? rawAccent
402
+ : `var(${accentToken}, var(--color-accent))`,
403
+ target,
404
+ );
405
+ seedAccent =
406
+ rawAccent && !/\b(from|light-dark|var|calc|color-mix)\b/i.test(rawAccent)
407
+ ? rawAccent
408
+ : parsedAccent.css;
409
+ seedAccentHex = parsedAccent.hex;
410
+ accentOklch = { L: parsedAccent.L, C: parsedAccent.C, h: parsedAccent.h };
411
+
358
412
  fontText = readVar("--font-text", target);
359
413
  fontHeader = readVar("--font-header", target);
360
414
  fontInterface = readVar("--font-interface", target);
@@ -363,7 +417,10 @@
363
417
  parseFloat(readVar("--font-text-size", target)) ||
364
418
  parseFloat(readVar("--font-size", target)) ||
365
419
  1;
366
- lineHeight = parseFloat(readVar("--line-height", target)) || 1.414;
420
+ lineHeight = Math.min(
421
+ 1.75,
422
+ Math.max(1.35, parseFloat(readVar("--density-base", target)) || parseFloat(readVar("--line-height", target)) || 1.414),
423
+ );
367
424
  ratio = parseFloat(readVar("--optical-ratio", target)) || 1.414;
368
425
 
369
426
  const rawGap = readVar("--page-padding", target);
@@ -373,9 +430,8 @@
373
430
  const pxGap = resolvePx(rawGap);
374
431
  gapBody = pxSpace ? pxGap / pxSpace : 1;
375
432
 
376
- // Calculate rhythm gap multiplier
377
- const pxRhythm = resolvePx("var(--rhythm-gap-new-line)");
378
- rhythmGap = pxSpace ? pxRhythm / pxSpace : 0.5;
433
+ // The rhythm: how much heavier than a text gap the space around a block is (`--rhythm-block-scale`).
434
+ rhythmGap = parseFloat(readVar("--rhythm-block-scale", target)) || 2;
379
435
 
380
436
  // Calculate radius multiplier
381
437
  const rawRadius = readVar("--radius", target);
@@ -398,15 +454,17 @@
398
454
  "--color-light-foreground",
399
455
  "--color-dark-background",
400
456
  "--color-dark-foreground",
457
+ "--color-light-accent",
458
+ "--color-dark-accent",
401
459
  "--font-text",
402
460
  "--font-header",
403
461
  "--font-interface",
404
462
  "--font-monospace",
405
463
  "--font-text-size",
406
- "--line-height",
464
+ "--font-density",
407
465
  "--optical-ratio",
408
466
  "--page-padding",
409
- "--rhythm-gap-new-line",
467
+ "--rhythm-block-scale",
410
468
  "--radius",
411
469
  ];
412
470
  for (const t of tokens) {
@@ -458,15 +516,17 @@
458
516
  "--color-light-foreground",
459
517
  "--color-dark-background",
460
518
  "--color-dark-foreground",
519
+ "--color-light-accent",
520
+ "--color-dark-accent",
461
521
  "--font-text",
462
522
  "--font-header",
463
523
  "--font-interface",
464
524
  "--font-monospace",
465
525
  "--font-text-size",
466
- "--line-height",
526
+ "--font-density",
467
527
  "--optical-ratio",
468
528
  "--page-padding",
469
- "--rhythm-gap-new-line",
529
+ "--rhythm-block-scale",
470
530
  "--radius",
471
531
  ];
472
532
  const css = tokens
@@ -670,9 +730,13 @@
670
730
  };
671
731
 
672
732
  restoreLabState();
733
+ syncHandlerVisibility();
673
734
  if (open) view = "open";
674
735
  if (pin) pinned = true;
675
- document.addEventListener("astro:after-swap", restoreLabState);
736
+ document.addEventListener("astro:after-swap", () => {
737
+ restoreLabState();
738
+ syncHandlerVisibility();
739
+ });
676
740
 
677
741
  // Global API for dev toolbar compatibility
678
742
  window.StandardLab = {
@@ -705,15 +769,18 @@
705
769
  if (mod && !e.shiftKey && e.key.toLowerCase() === "s") {
706
770
  e.preventDefault();
707
771
  view = view === "dormant" ? "open" : "dormant";
772
+ if (view === "open") pinned = true;
708
773
  }
709
774
 
710
775
  if (e.shiftKey && mod && e.key.toLowerCase() === "s") {
711
776
  e.preventDefault();
712
777
  view = view === "dormant" ? "open" : "dormant";
778
+ if (view === "open") pinned = true;
713
779
  }
714
780
  if (mod && !e.shiftKey && e.key.toLowerCase() === "d") {
715
781
  e.preventDefault();
716
782
  view = view === "dormant" ? "open" : "dormant";
783
+ if (view === "open") pinned = true;
717
784
  }
718
785
  if (e.shiftKey && mod && e.key.toLowerCase() === "d") {
719
786
  e.preventDefault();
@@ -752,6 +819,7 @@
752
819
  }
753
820
 
754
821
  const domObserver = new MutationObserver(() => {
822
+ syncHandlerVisibility();
755
823
  requestAnimationFrame(syncFromDOM);
756
824
  });
757
825
  domObserver.observe(document.documentElement, {
@@ -761,6 +829,8 @@
761
829
  "data-color-mode",
762
830
  "class",
763
831
  "data-lab-scheme",
832
+ "data-lab-handler",
833
+ "data-lab-no-handler",
764
834
  ],
765
835
  });
766
836
 
@@ -828,7 +898,7 @@
828
898
  const imgData = ctx.createImageData(width, height);
829
899
  const data = imgData.data;
830
900
 
831
- const cur = activePicker === "bg" ? bgOklch : fgOklch;
901
+ const cur = oklchOf(activePicker);
832
902
  const h = cur.h;
833
903
 
834
904
  // Render 2D grid: X = Chroma (0 to MAX_CHROMA), Y = Lightness (1 at top to 0 at bottom)
@@ -860,15 +930,19 @@
860
930
  "--color-light-foreground",
861
931
  "--color-dark-background",
862
932
  "--color-dark-foreground",
933
+ "--color-light-accent",
934
+ "--color-dark-accent",
863
935
  "--font-text",
864
936
  "--font-header",
865
937
  "--font-interface",
866
938
  "--font-monospace",
867
939
  "--font-text-size",
868
940
  "--font-size",
941
+ "--font-density",
869
942
  "--line-height",
870
943
  "--optical-ratio",
871
944
  "--page-padding",
945
+ "--rhythm-block-scale",
872
946
  "--rhythm-gap-new-line",
873
947
  "--radius",
874
948
  ];
@@ -879,8 +953,16 @@
879
953
 
880
954
  if (t) {
881
955
  document.documentElement.setAttribute("data-theme", t);
956
+ try {
957
+ document.cookie = `stnd_theme=${encodeURIComponent(t)}; Path=/; Max-Age=31536000; SameSite=Lax`;
958
+ localStorage.setItem("std_theme", t);
959
+ } catch (e) {}
882
960
  } else {
883
961
  document.documentElement.removeAttribute("data-theme");
962
+ try {
963
+ document.cookie = `stnd_theme=; Path=/; Max-Age=0; SameSite=Lax`;
964
+ localStorage.removeItem("std_theme");
965
+ } catch (e) {}
884
966
  }
885
967
  sessionStorage.setItem("stnd-theme", t);
886
968
 
@@ -909,16 +991,20 @@
909
991
  const oklchStr = formatOklch(L, C, h);
910
992
  const formatted = pickerFormat === "hex" ? hex : oklchStr;
911
993
 
912
- const token = role === "bg" ? bgToken : fgToken;
994
+ const token = tokenOf(role);
913
995
 
914
996
  if (role === "bg") {
915
997
  bgOklch = { L, C, h };
916
998
  seedBg = formatted;
917
999
  seedBgHex = hex;
918
- } else {
1000
+ } else if (role === "fg") {
919
1001
  fgOklch = { L, C, h };
920
1002
  seedFg = formatted;
921
1003
  seedFgHex = hex;
1004
+ } else {
1005
+ accentOklch = { L, C, h };
1006
+ seedAccent = formatted;
1007
+ seedAccentHex = hex;
922
1008
  }
923
1009
 
924
1010
  writeVar(token, formatted);
@@ -926,7 +1012,7 @@
926
1012
 
927
1013
  function onColorText(role, e) {
928
1014
  const val = e.target.value;
929
- const token = role === "bg" ? bgToken : fgToken;
1015
+ const token = tokenOf(role);
930
1016
  const target = getThemeTargetElement();
931
1017
  const parsed = parseCssColor(val, target);
932
1018
 
@@ -934,17 +1020,21 @@
934
1020
  seedBg = val;
935
1021
  seedBgHex = parsed.hex;
936
1022
  bgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
937
- } else {
1023
+ } else if (role === "fg") {
938
1024
  seedFg = val;
939
1025
  seedFgHex = parsed.hex;
940
1026
  fgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
1027
+ } else {
1028
+ seedAccent = val;
1029
+ seedAccentHex = parsed.hex;
1030
+ accentOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
941
1031
  }
942
1032
  writeVar(token, val);
943
1033
  }
944
1034
 
945
1035
  function onSliderInput(role, channel, e) {
946
1036
  const val = parseFloat(e.target.value);
947
- const cur = role === "bg" ? { ...bgOklch } : { ...fgOklch };
1037
+ const cur = { ...oklchOf(role) };
948
1038
  cur[channel] = val;
949
1039
  updateColorFromOklch(role, cur);
950
1040
  }
@@ -958,7 +1048,7 @@
958
1048
  const C = (x / rect.width) * MAX_CHROMA;
959
1049
  const L = 1 - y / rect.height;
960
1050
 
961
- const cur = activePicker === "bg" ? bgOklch : fgOklch;
1051
+ const cur = oklchOf(activePicker);
962
1052
  updateColorFromOklch(activePicker, { L, C, h: cur.h });
963
1053
  }
964
1054
 
@@ -1018,7 +1108,7 @@
1018
1108
 
1019
1109
  function onLineHeightInput(e) {
1020
1110
  lineHeight = parseFloat(e.target.value);
1021
- writeVar("--line-height", String(lineHeight));
1111
+ writeVar("--font-density", String(lineHeight));
1022
1112
  }
1023
1113
 
1024
1114
  function onRatioInput(e) {
@@ -1033,7 +1123,7 @@
1033
1123
 
1034
1124
  function onRhythmGapInput(e) {
1035
1125
  rhythmGap = parseFloat(e.target.value);
1036
- writeVar("--rhythm-gap-new-line", `calc(var(--space) * ${rhythmGap})`);
1126
+ writeVar("--rhythm-block-scale", String(rhythmGap));
1037
1127
  }
1038
1128
 
1039
1129
  function onRadiusInput(e) {
@@ -1135,6 +1225,7 @@
1135
1225
  <!-- ======================================================================
1136
1226
  TRIGGER / HANDLER
1137
1227
  ====================================================================== -->
1228
+ {#if handlerVisible}
1138
1229
  <div
1139
1230
  class="lab-handler-top m-0"
1140
1231
  role="button"
@@ -1162,6 +1253,7 @@
1162
1253
  >
1163
1254
  <div class="lab-handler-bar"></div>
1164
1255
  </div>
1256
+ {/if}
1165
1257
 
1166
1258
  <!-- ======================================================================
1167
1259
  SIDE PANEL
@@ -1316,16 +1408,50 @@
1316
1408
  </div>
1317
1409
  </div>
1318
1410
 
1411
+ <div class="lab-field">
1412
+ <label class="lab-field-label" for="lab-accent-text">Accent</label>
1413
+ <div class="lab-color-row">
1414
+ <button
1415
+ type="button"
1416
+ class="lab-swatch"
1417
+ class:active={activePicker === "accent"}
1418
+ style="background-color: {seedAccentHex};"
1419
+ title="Toggle OKLCH accent picker"
1420
+ onclick={() => toggleColorPicker("accent")}
1421
+ aria-label="Pick accent color"
1422
+ ></button>
1423
+ <input
1424
+ id="lab-accent-text"
1425
+ type="text"
1426
+ class="lab-text-input"
1427
+ value={seedAccent}
1428
+ spellcheck="false"
1429
+ placeholder="e.g. oklch(0.65 0.22 355)"
1430
+ oninput={(e) => onColorText("accent", e)}
1431
+ />
1432
+ </div>
1433
+ </div>
1434
+
1319
1435
  <!-- Custom OKLCH Color Picker Panel -->
1320
1436
  {#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}
1437
+ {@const curOklch = oklchOf(activePicker)}
1438
+ {@const curHex =
1439
+ activePicker === "bg"
1440
+ ? seedBgHex
1441
+ : activePicker === "fg"
1442
+ ? seedFgHex
1443
+ : seedAccentHex}
1444
+ {@const presets =
1445
+ activePicker === "bg"
1446
+ ? BG_PRESETS
1447
+ : activePicker === "fg"
1448
+ ? FG_PRESETS
1449
+ : ACCENT_PRESETS}
1324
1450
  <div class="lab-picker-panel" transition:slide>
1325
1451
  <header class="lab-picker-header">
1326
1452
  <div class="lab-picker-title">
1327
1453
  <span class="lab-picker-role"
1328
- >{activePicker === "bg" ? "Background" : "Foreground"}</span
1454
+ >{ROLE_LABEL[activePicker]}</span
1329
1455
  >
1330
1456
  <span class="lab-picker-hex">{curHex}</span>
1331
1457
  </div>
@@ -1539,20 +1665,6 @@
1539
1665
  "--font-monospace",
1540
1666
  fontMono,
1541
1667
  )}
1542
- </div>
1543
- {/if}
1544
- </section>
1545
-
1546
- <!-- ─── SCALE ─── -->
1547
- <section class="lab-section">
1548
- <button class="lab-section-head" onclick={() => toggleSection("scale")}>
1549
- <span class="lab-section-arrow" class:open={sectionsOpen.scale}
1550
- >▶</span
1551
- >
1552
- <span class="lab-section-title">Scale</span>
1553
- </button>
1554
- {#if sectionsOpen.scale}
1555
- <div class="lab-section-body" transition:slide>
1556
1668
  <div class="lab-field">
1557
1669
  <label class="lab-field-label" for="lab-font-size"
1558
1670
  >Font Size</label
@@ -1573,16 +1685,16 @@
1573
1685
  </div>
1574
1686
  <div class="lab-field">
1575
1687
  <label class="lab-field-label" for="lab-line-height"
1576
- >Line Height</label
1688
+ >Font density</label
1577
1689
  >
1578
1690
  <div class="lab-slider-wrapper">
1579
1691
  <input
1580
- id="lab-line-height"
1692
+ id="lab-line-height" title="--font-density: sets --line-height, which stays between 1.35 and 1.75"
1581
1693
  type="range"
1582
1694
  class="lab-range"
1583
1695
  value={lineHeight}
1584
- min="1"
1585
- max="2"
1696
+ min="1.35"
1697
+ max="1.75"
1586
1698
  step="0.05"
1587
1699
  oninput={onLineHeightInput}
1588
1700
  />
@@ -1607,23 +1719,6 @@
1607
1719
  <span class="lab-readout">{ratio.toFixed(2)}</span>
1608
1720
  </div>
1609
1721
  </div>
1610
- </div>
1611
- {/if}
1612
- </section>
1613
-
1614
- <!-- ─── SPACING ─── -->
1615
- <section class="lab-section">
1616
- <button
1617
- class="lab-section-head"
1618
- onclick={() => toggleSection("spacing")}
1619
- >
1620
- <span class="lab-section-arrow" class:open={sectionsOpen.spacing}
1621
- >▶</span
1622
- >
1623
- <span class="lab-section-title">Spacing</span>
1624
- </button>
1625
- {#if sectionsOpen.spacing}
1626
- <div class="lab-section-body" transition:slide>
1627
1722
  <div class="lab-field">
1628
1723
  <label class="lab-field-label" for="lab-gap-body">Body Gap</label>
1629
1724
  <div class="lab-slider-wrapper">
@@ -1642,15 +1737,15 @@
1642
1737
  </div>
1643
1738
  <div class="lab-field">
1644
1739
  <label class="lab-field-label" for="lab-rhythm-gap"
1645
- >Rhythm Gap</label
1740
+ >Block rhythm</label
1646
1741
  >
1647
1742
  <div class="lab-slider-wrapper">
1648
1743
  <input
1649
- id="lab-rhythm-gap"
1744
+ id="lab-rhythm-gap" title="--rhythm-block-scale: the space around tables, cards and figures, as a multiple of the text gap"
1650
1745
  type="range"
1651
1746
  class="lab-range"
1652
1747
  value={rhythmGap}
1653
- min="0"
1748
+ min="1"
1654
1749
  max="4"
1655
1750
  step="0.25"
1656
1751
  oninput={onRhythmGapInput}
@@ -1798,6 +1893,14 @@
1798
1893
  }
1799
1894
 
1800
1895
  /* ═══════════ HANDLER TRIGGER ═══════════ */
1896
+ :global(:root[data-lab-handler="false"]) .lab-handler,
1897
+ :global(:root[data-lab-handler="false"]) .lab-handler-top,
1898
+ :global(:root[data-lab-no-handler]) .lab-handler,
1899
+ :global(:root[data-lab-no-handler]) .lab-handler-top {
1900
+ display: none !important;
1901
+ pointer-events: none !important;
1902
+ }
1903
+
1801
1904
  .lab-handler-top {
1802
1905
  position: fixed;
1803
1906
  z-index: 200;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stnd/lab",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "svelte": "./Lab.svelte",
6
6
  "author": "Francis Fontaine",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "bezier-easing": "^3.0.1",
18
18
  "svelte": "5.57.2",
19
- "@stnd/ui": "0.16.0"
19
+ "@stnd/ui": "0.16.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "astro": ">=5.0.0"