@stnd/lab 1.6.0 → 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 +6 -0
  2. package/Lab.svelte +162 -67
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @stnd/lab
2
2
 
3
+ ## 1.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @stnd/ui@0.16.1
8
+
3
9
  ## 1.6.0
4
10
 
5
11
  ### Minor 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
  ];
@@ -917,16 +991,20 @@
917
991
  const oklchStr = formatOklch(L, C, h);
918
992
  const formatted = pickerFormat === "hex" ? hex : oklchStr;
919
993
 
920
- const token = role === "bg" ? bgToken : fgToken;
994
+ const token = tokenOf(role);
921
995
 
922
996
  if (role === "bg") {
923
997
  bgOklch = { L, C, h };
924
998
  seedBg = formatted;
925
999
  seedBgHex = hex;
926
- } else {
1000
+ } else if (role === "fg") {
927
1001
  fgOklch = { L, C, h };
928
1002
  seedFg = formatted;
929
1003
  seedFgHex = hex;
1004
+ } else {
1005
+ accentOklch = { L, C, h };
1006
+ seedAccent = formatted;
1007
+ seedAccentHex = hex;
930
1008
  }
931
1009
 
932
1010
  writeVar(token, formatted);
@@ -934,7 +1012,7 @@
934
1012
 
935
1013
  function onColorText(role, e) {
936
1014
  const val = e.target.value;
937
- const token = role === "bg" ? bgToken : fgToken;
1015
+ const token = tokenOf(role);
938
1016
  const target = getThemeTargetElement();
939
1017
  const parsed = parseCssColor(val, target);
940
1018
 
@@ -942,17 +1020,21 @@
942
1020
  seedBg = val;
943
1021
  seedBgHex = parsed.hex;
944
1022
  bgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
945
- } else {
1023
+ } else if (role === "fg") {
946
1024
  seedFg = val;
947
1025
  seedFgHex = parsed.hex;
948
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 };
949
1031
  }
950
1032
  writeVar(token, val);
951
1033
  }
952
1034
 
953
1035
  function onSliderInput(role, channel, e) {
954
1036
  const val = parseFloat(e.target.value);
955
- const cur = role === "bg" ? { ...bgOklch } : { ...fgOklch };
1037
+ const cur = { ...oklchOf(role) };
956
1038
  cur[channel] = val;
957
1039
  updateColorFromOklch(role, cur);
958
1040
  }
@@ -966,7 +1048,7 @@
966
1048
  const C = (x / rect.width) * MAX_CHROMA;
967
1049
  const L = 1 - y / rect.height;
968
1050
 
969
- const cur = activePicker === "bg" ? bgOklch : fgOklch;
1051
+ const cur = oklchOf(activePicker);
970
1052
  updateColorFromOklch(activePicker, { L, C, h: cur.h });
971
1053
  }
972
1054
 
@@ -1026,7 +1108,7 @@
1026
1108
 
1027
1109
  function onLineHeightInput(e) {
1028
1110
  lineHeight = parseFloat(e.target.value);
1029
- writeVar("--line-height", String(lineHeight));
1111
+ writeVar("--font-density", String(lineHeight));
1030
1112
  }
1031
1113
 
1032
1114
  function onRatioInput(e) {
@@ -1041,7 +1123,7 @@
1041
1123
 
1042
1124
  function onRhythmGapInput(e) {
1043
1125
  rhythmGap = parseFloat(e.target.value);
1044
- writeVar("--rhythm-gap-new-line", `calc(var(--space) * ${rhythmGap})`);
1126
+ writeVar("--rhythm-block-scale", String(rhythmGap));
1045
1127
  }
1046
1128
 
1047
1129
  function onRadiusInput(e) {
@@ -1143,6 +1225,7 @@
1143
1225
  <!-- ======================================================================
1144
1226
  TRIGGER / HANDLER
1145
1227
  ====================================================================== -->
1228
+ {#if handlerVisible}
1146
1229
  <div
1147
1230
  class="lab-handler-top m-0"
1148
1231
  role="button"
@@ -1170,6 +1253,7 @@
1170
1253
  >
1171
1254
  <div class="lab-handler-bar"></div>
1172
1255
  </div>
1256
+ {/if}
1173
1257
 
1174
1258
  <!-- ======================================================================
1175
1259
  SIDE PANEL
@@ -1324,16 +1408,50 @@
1324
1408
  </div>
1325
1409
  </div>
1326
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
+
1327
1435
  <!-- Custom OKLCH Color Picker Panel -->
1328
1436
  {#if activePicker}
1329
- {@const curOklch = activePicker === "bg" ? bgOklch : fgOklch}
1330
- {@const curHex = activePicker === "bg" ? seedBgHex : seedFgHex}
1331
- {@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}
1332
1450
  <div class="lab-picker-panel" transition:slide>
1333
1451
  <header class="lab-picker-header">
1334
1452
  <div class="lab-picker-title">
1335
1453
  <span class="lab-picker-role"
1336
- >{activePicker === "bg" ? "Background" : "Foreground"}</span
1454
+ >{ROLE_LABEL[activePicker]}</span
1337
1455
  >
1338
1456
  <span class="lab-picker-hex">{curHex}</span>
1339
1457
  </div>
@@ -1547,20 +1665,6 @@
1547
1665
  "--font-monospace",
1548
1666
  fontMono,
1549
1667
  )}
1550
- </div>
1551
- {/if}
1552
- </section>
1553
-
1554
- <!-- ─── SCALE ─── -->
1555
- <section class="lab-section">
1556
- <button class="lab-section-head" onclick={() => toggleSection("scale")}>
1557
- <span class="lab-section-arrow" class:open={sectionsOpen.scale}
1558
- >▶</span
1559
- >
1560
- <span class="lab-section-title">Scale</span>
1561
- </button>
1562
- {#if sectionsOpen.scale}
1563
- <div class="lab-section-body" transition:slide>
1564
1668
  <div class="lab-field">
1565
1669
  <label class="lab-field-label" for="lab-font-size"
1566
1670
  >Font Size</label
@@ -1581,16 +1685,16 @@
1581
1685
  </div>
1582
1686
  <div class="lab-field">
1583
1687
  <label class="lab-field-label" for="lab-line-height"
1584
- >Line Height</label
1688
+ >Font density</label
1585
1689
  >
1586
1690
  <div class="lab-slider-wrapper">
1587
1691
  <input
1588
- id="lab-line-height"
1692
+ id="lab-line-height" title="--font-density: sets --line-height, which stays between 1.35 and 1.75"
1589
1693
  type="range"
1590
1694
  class="lab-range"
1591
1695
  value={lineHeight}
1592
- min="1"
1593
- max="2"
1696
+ min="1.35"
1697
+ max="1.75"
1594
1698
  step="0.05"
1595
1699
  oninput={onLineHeightInput}
1596
1700
  />
@@ -1615,23 +1719,6 @@
1615
1719
  <span class="lab-readout">{ratio.toFixed(2)}</span>
1616
1720
  </div>
1617
1721
  </div>
1618
- </div>
1619
- {/if}
1620
- </section>
1621
-
1622
- <!-- ─── SPACING ─── -->
1623
- <section class="lab-section">
1624
- <button
1625
- class="lab-section-head"
1626
- onclick={() => toggleSection("spacing")}
1627
- >
1628
- <span class="lab-section-arrow" class:open={sectionsOpen.spacing}
1629
- >▶</span
1630
- >
1631
- <span class="lab-section-title">Spacing</span>
1632
- </button>
1633
- {#if sectionsOpen.spacing}
1634
- <div class="lab-section-body" transition:slide>
1635
1722
  <div class="lab-field">
1636
1723
  <label class="lab-field-label" for="lab-gap-body">Body Gap</label>
1637
1724
  <div class="lab-slider-wrapper">
@@ -1650,15 +1737,15 @@
1650
1737
  </div>
1651
1738
  <div class="lab-field">
1652
1739
  <label class="lab-field-label" for="lab-rhythm-gap"
1653
- >Rhythm Gap</label
1740
+ >Block rhythm</label
1654
1741
  >
1655
1742
  <div class="lab-slider-wrapper">
1656
1743
  <input
1657
- 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"
1658
1745
  type="range"
1659
1746
  class="lab-range"
1660
1747
  value={rhythmGap}
1661
- min="0"
1748
+ min="1"
1662
1749
  max="4"
1663
1750
  step="0.25"
1664
1751
  oninput={onRhythmGapInput}
@@ -1806,6 +1893,14 @@
1806
1893
  }
1807
1894
 
1808
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
+
1809
1904
  .lab-handler-top {
1810
1905
  position: fixed;
1811
1906
  z-index: 200;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stnd/lab",
3
- "version": "1.6.0",
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"