@stnd/lab 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @stnd/lab
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Auto-bumped @stnd/lab due to modified code.
8
+
9
+ ## 1.6.1
10
+
11
+ ### Patch Changes
12
+
13
+ - @stnd/ui@0.16.1
14
+
3
15
  ## 1.6.0
4
16
 
5
17
  ### Minor Changes
package/Lab.svelte CHANGED
@@ -24,7 +24,7 @@
24
24
  } from "./color.js";
25
25
 
26
26
  // Sidebar transition parameters (defaults)
27
- let sidebarDuration = $state(2000); // milliseconds
27
+ let sidebarDuration = $state(220); // milliseconds
28
28
  let customEasing = $state(bezier(0.22, 1, 0.36, 1));
29
29
 
30
30
  // =========================================================================
@@ -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 =
@@ -126,12 +157,27 @@
126
157
  }
127
158
  }
128
159
 
160
+ let openTimer;
161
+
129
162
  function openLab() {
130
163
  clearTimeout(closeTimer);
164
+ clearTimeout(openTimer);
131
165
  view = "open";
132
166
  }
133
167
 
168
+ function hoverOpenLab() {
169
+ if (typeof window !== "undefined" && !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
170
+ return;
171
+ }
172
+ clearTimeout(closeTimer);
173
+ clearTimeout(openTimer);
174
+ openTimer = setTimeout(() => {
175
+ view = "open";
176
+ }, 120);
177
+ }
178
+
134
179
  function requestCloseLab() {
180
+ clearTimeout(openTimer);
135
181
  if (pinned || activePicker || isDraggingPlane) return;
136
182
  // Keep open if user is interacting with inputs or popovers inside
137
183
  if (
@@ -146,24 +192,38 @@
146
192
  clearTimeout(closeTimer);
147
193
  closeTimer = setTimeout(() => {
148
194
  view = "dormant";
149
- }, 100);
195
+ }, 280);
150
196
  }
151
197
 
152
198
  function cancelCloseLab() {
199
+ clearTimeout(openTimer);
153
200
  clearTimeout(closeTimer);
154
201
  }
155
202
 
203
+ function togglePinned() {
204
+ pinned = !pinned;
205
+ try {
206
+ sessionStorage.setItem("stnd-pinned", String(pinned));
207
+ } catch {}
208
+ }
209
+
210
+ function collapseLab() {
211
+ pinned = false;
212
+ try {
213
+ sessionStorage.setItem("stnd-pinned", "false");
214
+ } catch {}
215
+ view = "dormant";
216
+ }
217
+
156
218
  let modified = $state(new Set());
157
219
  let version = $state("");
158
220
  let viewportW = $state(0);
159
221
 
160
222
  // Collapsible sections
161
223
  let sectionsOpen = $state({
162
- theme: false,
163
- colors: false,
164
- typography: false,
165
- scale: false,
166
- spacing: false,
224
+ theme: true,
225
+ colors: true,
226
+ typography: true,
167
227
  debug: false,
168
228
  links: false,
169
229
  });
@@ -223,17 +283,28 @@
223
283
  let fgToken = $derived(
224
284
  scheme === "dark" ? "--color-dark-foreground" : "--color-light-foreground",
225
285
  );
286
+ let accentToken = $derived(
287
+ scheme === "dark" ? "--color-dark-accent" : "--color-light-accent",
288
+ );
289
+ const oklchOf = (role) =>
290
+ role === "bg" ? bgOklch : role === "fg" ? fgOklch : accentOklch;
291
+ const tokenOf = (role) =>
292
+ role === "bg" ? bgToken : role === "fg" ? fgToken : accentToken;
293
+ const ROLE_LABEL = { bg: "Background", fg: "Foreground", accent: "Accent" };
226
294
 
227
295
  // Quick panel seeds (scheme-aware)
228
296
  let seedBg = $state("");
229
297
  let seedFg = $state("");
230
298
  let seedBgHex = $state("#ffffff");
231
299
  let seedFgHex = $state("#262626");
300
+ let seedAccent = $state("");
301
+ let seedAccentHex = $state("transparent");
232
302
  let fontText = $state("");
233
303
  let fontHeader = $state("");
234
304
  let fontInterface = $state("");
235
305
  let fontMono = $state("");
236
306
  let fontSize = $state(1);
307
+ /** Font density: the theme-authoring knob. `--line-height` is derived from it, clamped to 1.35–1.75. */
237
308
  let lineHeight = $state(1.414);
238
309
  let rhythmGap = $state(1);
239
310
  let ratio = $state(1.414);
@@ -355,6 +426,20 @@
355
426
  bgOklch = { L: parsedBg.L, C: parsedBg.C, h: parsedBg.h };
356
427
  fgOklch = { L: parsedFg.L, C: parsedFg.C, h: parsedFg.h };
357
428
 
429
+ const rawAccent = readVar(accentToken, target);
430
+ const parsedAccent = parseCssColor(
431
+ rawAccent && !/\b(from|light-dark|var)\b/i.test(rawAccent)
432
+ ? rawAccent
433
+ : `var(${accentToken}, var(--color-accent))`,
434
+ target,
435
+ );
436
+ seedAccent =
437
+ rawAccent && !/\b(from|light-dark|var|calc|color-mix)\b/i.test(rawAccent)
438
+ ? rawAccent
439
+ : parsedAccent.css;
440
+ seedAccentHex = parsedAccent.hex;
441
+ accentOklch = { L: parsedAccent.L, C: parsedAccent.C, h: parsedAccent.h };
442
+
358
443
  fontText = readVar("--font-text", target);
359
444
  fontHeader = readVar("--font-header", target);
360
445
  fontInterface = readVar("--font-interface", target);
@@ -363,7 +448,10 @@
363
448
  parseFloat(readVar("--font-text-size", target)) ||
364
449
  parseFloat(readVar("--font-size", target)) ||
365
450
  1;
366
- lineHeight = parseFloat(readVar("--line-height", target)) || 1.414;
451
+ lineHeight = Math.min(
452
+ 1.75,
453
+ Math.max(1.35, parseFloat(readVar("--density-base", target)) || parseFloat(readVar("--line-height", target)) || 1.414),
454
+ );
367
455
  ratio = parseFloat(readVar("--optical-ratio", target)) || 1.414;
368
456
 
369
457
  const rawGap = readVar("--page-padding", target);
@@ -373,9 +461,8 @@
373
461
  const pxGap = resolvePx(rawGap);
374
462
  gapBody = pxSpace ? pxGap / pxSpace : 1;
375
463
 
376
- // Calculate rhythm gap multiplier
377
- const pxRhythm = resolvePx("var(--rhythm-gap-new-line)");
378
- rhythmGap = pxSpace ? pxRhythm / pxSpace : 0.5;
464
+ // The rhythm: how much heavier than a text gap the space around a block is (`--rhythm-block-scale`).
465
+ rhythmGap = parseFloat(readVar("--rhythm-block-scale", target)) || 2;
379
466
 
380
467
  // Calculate radius multiplier
381
468
  const rawRadius = readVar("--radius", target);
@@ -398,15 +485,17 @@
398
485
  "--color-light-foreground",
399
486
  "--color-dark-background",
400
487
  "--color-dark-foreground",
488
+ "--color-light-accent",
489
+ "--color-dark-accent",
401
490
  "--font-text",
402
491
  "--font-header",
403
492
  "--font-interface",
404
493
  "--font-monospace",
405
494
  "--font-text-size",
406
- "--line-height",
495
+ "--font-density",
407
496
  "--optical-ratio",
408
497
  "--page-padding",
409
- "--rhythm-gap-new-line",
498
+ "--rhythm-block-scale",
410
499
  "--radius",
411
500
  ];
412
501
  for (const t of tokens) {
@@ -458,15 +547,17 @@
458
547
  "--color-light-foreground",
459
548
  "--color-dark-background",
460
549
  "--color-dark-foreground",
550
+ "--color-light-accent",
551
+ "--color-dark-accent",
461
552
  "--font-text",
462
553
  "--font-header",
463
554
  "--font-interface",
464
555
  "--font-monospace",
465
556
  "--font-text-size",
466
- "--line-height",
557
+ "--font-density",
467
558
  "--optical-ratio",
468
559
  "--page-padding",
469
- "--rhythm-gap-new-line",
560
+ "--rhythm-block-scale",
470
561
  "--radius",
471
562
  ];
472
563
  const css = tokens
@@ -670,9 +761,13 @@
670
761
  };
671
762
 
672
763
  restoreLabState();
764
+ syncHandlerVisibility();
673
765
  if (open) view = "open";
674
766
  if (pin) pinned = true;
675
- document.addEventListener("astro:after-swap", restoreLabState);
767
+ document.addEventListener("astro:after-swap", () => {
768
+ restoreLabState();
769
+ syncHandlerVisibility();
770
+ });
676
771
 
677
772
  // Global API for dev toolbar compatibility
678
773
  window.StandardLab = {
@@ -700,20 +795,40 @@
700
795
  // Keyboard shortcuts
701
796
  function onKey(e) {
702
797
  const mod = navigator.platform.includes("Mac") ? e.metaKey : e.ctrlKey;
798
+ const el = document.activeElement;
799
+ const typing =
800
+ !!el && (["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) || el.isContentEditable);
801
+
802
+ // Right-sidebar shortcut `]` when not typing
803
+ if (!typing && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === "]") {
804
+ e.preventDefault();
805
+ if (view === "dormant") {
806
+ view = "open";
807
+ pinned = true;
808
+ } else if (!pinned) {
809
+ pinned = true;
810
+ } else {
811
+ collapseLab();
812
+ }
813
+ return;
814
+ }
703
815
 
704
816
  // Toggle lab with Cmd/Ctrl+S (override default save shortcut)
705
817
  if (mod && !e.shiftKey && e.key.toLowerCase() === "s") {
706
818
  e.preventDefault();
707
819
  view = view === "dormant" ? "open" : "dormant";
820
+ if (view === "open") pinned = true;
708
821
  }
709
822
 
710
823
  if (e.shiftKey && mod && e.key.toLowerCase() === "s") {
711
824
  e.preventDefault();
712
825
  view = view === "dormant" ? "open" : "dormant";
826
+ if (view === "open") pinned = true;
713
827
  }
714
828
  if (mod && !e.shiftKey && e.key.toLowerCase() === "d") {
715
829
  e.preventDefault();
716
830
  view = view === "dormant" ? "open" : "dormant";
831
+ if (view === "open") pinned = true;
717
832
  }
718
833
  if (e.shiftKey && mod && e.key.toLowerCase() === "d") {
719
834
  e.preventDefault();
@@ -744,6 +859,7 @@
744
859
  if (view !== "open" || pinned) return;
745
860
  if (sidebarEl && e.composedPath().includes(sidebarEl)) return;
746
861
  if (
862
+ e.target.closest?.(".lab-open-btn") ||
747
863
  e.target.closest?.(".lab-handler") ||
748
864
  e.target.closest?.(".lab-handler-top")
749
865
  )
@@ -752,6 +868,7 @@
752
868
  }
753
869
 
754
870
  const domObserver = new MutationObserver(() => {
871
+ syncHandlerVisibility();
755
872
  requestAnimationFrame(syncFromDOM);
756
873
  });
757
874
  domObserver.observe(document.documentElement, {
@@ -761,6 +878,8 @@
761
878
  "data-color-mode",
762
879
  "class",
763
880
  "data-lab-scheme",
881
+ "data-lab-handler",
882
+ "data-lab-no-handler",
764
883
  ],
765
884
  });
766
885
 
@@ -786,13 +905,12 @@
786
905
  // EFFECTS
787
906
  // =========================================================================
788
907
 
789
- // Persist panel state + dispatch event for dev toolbar
790
- // Shrink the page when the sidebar is open (like DevTools)
908
+ // Shrink the page only when the Lab is pinned open on wide screens
791
909
  $effect(() => {
792
910
  if (duplicate) return;
793
911
  if (typeof document === "undefined") return;
794
912
  const el = document.documentElement;
795
- if (view === "open") {
913
+ if (view === "open" && pinned) {
796
914
  el.classList.add("lab-open");
797
915
  } else {
798
916
  el.classList.remove("lab-open");
@@ -828,7 +946,7 @@
828
946
  const imgData = ctx.createImageData(width, height);
829
947
  const data = imgData.data;
830
948
 
831
- const cur = activePicker === "bg" ? bgOklch : fgOklch;
949
+ const cur = oklchOf(activePicker);
832
950
  const h = cur.h;
833
951
 
834
952
  // Render 2D grid: X = Chroma (0 to MAX_CHROMA), Y = Lightness (1 at top to 0 at bottom)
@@ -860,15 +978,19 @@
860
978
  "--color-light-foreground",
861
979
  "--color-dark-background",
862
980
  "--color-dark-foreground",
981
+ "--color-light-accent",
982
+ "--color-dark-accent",
863
983
  "--font-text",
864
984
  "--font-header",
865
985
  "--font-interface",
866
986
  "--font-monospace",
867
987
  "--font-text-size",
868
988
  "--font-size",
989
+ "--font-density",
869
990
  "--line-height",
870
991
  "--optical-ratio",
871
992
  "--page-padding",
993
+ "--rhythm-block-scale",
872
994
  "--rhythm-gap-new-line",
873
995
  "--radius",
874
996
  ];
@@ -917,16 +1039,20 @@
917
1039
  const oklchStr = formatOklch(L, C, h);
918
1040
  const formatted = pickerFormat === "hex" ? hex : oklchStr;
919
1041
 
920
- const token = role === "bg" ? bgToken : fgToken;
1042
+ const token = tokenOf(role);
921
1043
 
922
1044
  if (role === "bg") {
923
1045
  bgOklch = { L, C, h };
924
1046
  seedBg = formatted;
925
1047
  seedBgHex = hex;
926
- } else {
1048
+ } else if (role === "fg") {
927
1049
  fgOklch = { L, C, h };
928
1050
  seedFg = formatted;
929
1051
  seedFgHex = hex;
1052
+ } else {
1053
+ accentOklch = { L, C, h };
1054
+ seedAccent = formatted;
1055
+ seedAccentHex = hex;
930
1056
  }
931
1057
 
932
1058
  writeVar(token, formatted);
@@ -934,7 +1060,7 @@
934
1060
 
935
1061
  function onColorText(role, e) {
936
1062
  const val = e.target.value;
937
- const token = role === "bg" ? bgToken : fgToken;
1063
+ const token = tokenOf(role);
938
1064
  const target = getThemeTargetElement();
939
1065
  const parsed = parseCssColor(val, target);
940
1066
 
@@ -942,17 +1068,21 @@
942
1068
  seedBg = val;
943
1069
  seedBgHex = parsed.hex;
944
1070
  bgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
945
- } else {
1071
+ } else if (role === "fg") {
946
1072
  seedFg = val;
947
1073
  seedFgHex = parsed.hex;
948
1074
  fgOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
1075
+ } else {
1076
+ seedAccent = val;
1077
+ seedAccentHex = parsed.hex;
1078
+ accentOklch = { L: parsed.L, C: parsed.C, h: parsed.h };
949
1079
  }
950
1080
  writeVar(token, val);
951
1081
  }
952
1082
 
953
1083
  function onSliderInput(role, channel, e) {
954
1084
  const val = parseFloat(e.target.value);
955
- const cur = role === "bg" ? { ...bgOklch } : { ...fgOklch };
1085
+ const cur = { ...oklchOf(role) };
956
1086
  cur[channel] = val;
957
1087
  updateColorFromOklch(role, cur);
958
1088
  }
@@ -966,7 +1096,7 @@
966
1096
  const C = (x / rect.width) * MAX_CHROMA;
967
1097
  const L = 1 - y / rect.height;
968
1098
 
969
- const cur = activePicker === "bg" ? bgOklch : fgOklch;
1099
+ const cur = oklchOf(activePicker);
970
1100
  updateColorFromOklch(activePicker, { L, C, h: cur.h });
971
1101
  }
972
1102
 
@@ -1026,7 +1156,7 @@
1026
1156
 
1027
1157
  function onLineHeightInput(e) {
1028
1158
  lineHeight = parseFloat(e.target.value);
1029
- writeVar("--line-height", String(lineHeight));
1159
+ writeVar("--font-density", String(lineHeight));
1030
1160
  }
1031
1161
 
1032
1162
  function onRatioInput(e) {
@@ -1041,7 +1171,7 @@
1041
1171
 
1042
1172
  function onRhythmGapInput(e) {
1043
1173
  rhythmGap = parseFloat(e.target.value);
1044
- writeVar("--rhythm-gap-new-line", `calc(var(--space) * ${rhythmGap})`);
1174
+ writeVar("--rhythm-block-scale", String(rhythmGap));
1045
1175
  }
1046
1176
 
1047
1177
  function onRadiusInput(e) {
@@ -1141,59 +1271,81 @@
1141
1271
 
1142
1272
  {#if !duplicate}
1143
1273
  <!-- ======================================================================
1144
- TRIGGER / HANDLER
1274
+ TOP-RIGHT STICKY FLASK TRIGGER (Right Sidebar Minimized Mode)
1145
1275
  ====================================================================== -->
1146
- <div
1147
- class="lab-handler-top m-0"
1148
- role="button"
1149
- tabindex="0"
1150
- aria-label="Open Design Lab"
1151
- onmouseenter={openLab}
1152
- onmouseleave={requestCloseLab}
1153
- onclick={() => (view = view === "dormant" ? "open" : "dormant")}
1154
- onkeydown={(e) => e.key === "Enter" && (view = "open")}
1155
- >
1156
- <div class="lab-dot"></div>
1157
- </div>
1158
-
1159
- <div
1160
- class="lab-handler m-0"
1161
- class:scrolling={isScrolling}
1276
+ {#if handlerVisible && view === "dormant"}
1277
+ <button
1278
+ type="button"
1279
+ class="item lab-open-btn m-0"
1162
1280
  class:modified={modCount > 0}
1163
- onmouseenter={openLab}
1164
- onmouseleave={requestCloseLab}
1165
- role="button"
1166
- tabindex="0"
1167
1281
  aria-label="Open Design Lab"
1168
- onclick={() => (view = view === "dormant" ? "open" : "dormant")}
1169
- onkeydown={(e) => e.key === "Enter" && (view = "open")}
1282
+ title="Open Design Lab ( ] )"
1283
+ onpointerenter={hoverOpenLab}
1284
+ onpointerleave={requestCloseLab}
1285
+ onclick={() => {
1286
+ clearTimeout(openTimer);
1287
+ view = "open";
1288
+ pinned = true;
1289
+ try {
1290
+ sessionStorage.setItem("stnd-pinned", "true");
1291
+ } catch {}
1292
+ }}
1170
1293
  >
1171
- <div class="lab-handler-bar"></div>
1172
- </div>
1294
+ <svg
1295
+ xmlns="http://www.w3.org/2000/svg"
1296
+ width="1.25em"
1297
+ height="1.25em"
1298
+ viewBox="0 0 256 256"
1299
+ fill="currentColor"
1300
+ aria-hidden="true"
1301
+ >
1302
+ <path
1303
+ d="M221.6,199.77,160,96.92V40h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V96.92L34.4,199.77A16,16,0,0,0,48.13,224H207.87a16,16,0,0,0,13.73-24.23ZM110.86,103.25A7.93,7.93,0,0,0,112,99.14V40h32V99.14a7.93,7.93,0,0,0,1.14,4.11L183.36,167c-12.19,2.31-26.07-.37-41.19-8.19-19-9.82-35.87-12.48-50.25-8.23ZM48.13,208l24.51-40.91c11.79-4.76,25.75-2.66,43.1,6.31,15.65,8.09,30.12,11.66,43.18,10.67a63.36,63.36,0,0,0,15.46-3L207.87,208Z"
1304
+ />
1305
+ </svg>
1306
+ {#if modCount > 0}
1307
+ <span class="lab-mod-dot" title="{modCount} active override(s)"></span>
1308
+ {/if}
1309
+ </button>
1310
+ {/if}
1173
1311
 
1174
1312
  <!-- ======================================================================
1175
- SIDE PANEL
1313
+ RIGHT SIDEBAR PANE
1176
1314
  ====================================================================== -->
1177
1315
  {#if view === "open"}
1178
1316
  <div
1179
1317
  bind:this={sidebarEl}
1180
1318
  transition:fly={{
1181
- x: 320,
1319
+ x: 340,
1182
1320
  duration: sidebarDuration,
1183
1321
  easing: customEasing,
1184
1322
  opacity: false,
1185
1323
  }}
1186
- class="lab-sidebar no-rhythm mt-0"
1324
+ class="pane lab-sidebar no-rhythm mt-0"
1325
+ data-pinned={pinned ? "" : undefined}
1187
1326
  role="dialog"
1188
1327
  tabindex="-1"
1189
1328
  aria-label="Design Lab"
1190
- onmouseenter={cancelCloseLab}
1191
- onmouseleave={requestCloseLab}
1329
+ onpointerenter={cancelCloseLab}
1330
+ onpointerleave={requestCloseLab}
1192
1331
  >
1193
1332
  <!-- ═══════════ HEADER ═══════════ -->
1194
1333
  <header class="lab-header">
1195
1334
  <div class="lab-header-title">
1196
- <h2>stnd</h2>
1335
+ <svg
1336
+ xmlns="http://www.w3.org/2000/svg"
1337
+ width="1.15em"
1338
+ height="1.15em"
1339
+ viewBox="0 0 256 256"
1340
+ fill="currentColor"
1341
+ class="lab-flask-mark"
1342
+ aria-hidden="true"
1343
+ >
1344
+ <path
1345
+ d="M221.6,199.77,160,96.92V40h8a8,8,0,0,0,0-16H88a8,8,0,0,0,0,16h8V96.92L34.4,199.77A16,16,0,0,0,48.13,224H207.87a16,16,0,0,0,13.73-24.23ZM110.86,103.25A7.93,7.93,0,0,0,112,99.14V40h32V99.14a7.93,7.93,0,0,0,1.14,4.11L183.36,167c-12.19,2.31-26.07-.37-41.19-8.19-19-9.82-35.87-12.48-50.25-8.23ZM48.13,208l24.51-40.91c11.79-4.76,25.75-2.66,43.1,6.31,15.65,8.09,30.12,11.66,43.18,10.67a63.36,63.36,0,0,0,15.46-3L207.87,208Z"
1346
+ />
1347
+ </svg>
1348
+ <h2>stnd lab</h2>
1197
1349
  {#if version}<span class="lab-version">v{version}</span>{/if}
1198
1350
  </div>
1199
1351
  <div class="lab-header-meta">
@@ -1216,16 +1368,17 @@
1216
1368
 
1217
1369
  <button
1218
1370
  class="smaller icon ghost"
1219
- onclick={() => (pinned = !pinned)}
1220
- aria-label={pinned ? "Unpin Lab" : "Pin Lab"}
1221
- title={pinned ? "Unpin Lab" : "Pin Lab"}
1371
+ onclick={togglePinned}
1372
+ aria-label={pinned ? "Unpin Lab (hover peek mode)" : "Pin Lab open"}
1373
+ title={pinned ? "Pinned open — click to switch to hover peek" : "Hover peek — click to pin open"}
1222
1374
  >
1223
1375
  {pinned ? "●" : "○"}
1224
1376
  </button>
1225
1377
  <button
1226
1378
  class="smaller icon ghost"
1227
- onclick={() => (view = "dormant")}
1228
- aria-label="Close Lab"
1379
+ onclick={collapseLab}
1380
+ aria-label="Minimize Lab to top-right icon"
1381
+ title="Collapse Lab ( ] )"
1229
1382
  >
1230
1383
  ✕
1231
1384
  </button>
@@ -1324,16 +1477,50 @@
1324
1477
  </div>
1325
1478
  </div>
1326
1479
 
1480
+ <div class="lab-field">
1481
+ <label class="lab-field-label" for="lab-accent-text">Accent</label>
1482
+ <div class="lab-color-row">
1483
+ <button
1484
+ type="button"
1485
+ class="lab-swatch"
1486
+ class:active={activePicker === "accent"}
1487
+ style="background-color: {seedAccentHex};"
1488
+ title="Toggle OKLCH accent picker"
1489
+ onclick={() => toggleColorPicker("accent")}
1490
+ aria-label="Pick accent color"
1491
+ ></button>
1492
+ <input
1493
+ id="lab-accent-text"
1494
+ type="text"
1495
+ class="lab-text-input"
1496
+ value={seedAccent}
1497
+ spellcheck="false"
1498
+ placeholder="e.g. oklch(0.65 0.22 355)"
1499
+ oninput={(e) => onColorText("accent", e)}
1500
+ />
1501
+ </div>
1502
+ </div>
1503
+
1327
1504
  <!-- Custom OKLCH Color Picker Panel -->
1328
1505
  {#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}
1506
+ {@const curOklch = oklchOf(activePicker)}
1507
+ {@const curHex =
1508
+ activePicker === "bg"
1509
+ ? seedBgHex
1510
+ : activePicker === "fg"
1511
+ ? seedFgHex
1512
+ : seedAccentHex}
1513
+ {@const presets =
1514
+ activePicker === "bg"
1515
+ ? BG_PRESETS
1516
+ : activePicker === "fg"
1517
+ ? FG_PRESETS
1518
+ : ACCENT_PRESETS}
1332
1519
  <div class="lab-picker-panel" transition:slide>
1333
1520
  <header class="lab-picker-header">
1334
1521
  <div class="lab-picker-title">
1335
1522
  <span class="lab-picker-role"
1336
- >{activePicker === "bg" ? "Background" : "Foreground"}</span
1523
+ >{ROLE_LABEL[activePicker]}</span
1337
1524
  >
1338
1525
  <span class="lab-picker-hex">{curHex}</span>
1339
1526
  </div>
@@ -1547,20 +1734,6 @@
1547
1734
  "--font-monospace",
1548
1735
  fontMono,
1549
1736
  )}
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
1737
  <div class="lab-field">
1565
1738
  <label class="lab-field-label" for="lab-font-size"
1566
1739
  >Font Size</label
@@ -1581,16 +1754,16 @@
1581
1754
  </div>
1582
1755
  <div class="lab-field">
1583
1756
  <label class="lab-field-label" for="lab-line-height"
1584
- >Line Height</label
1757
+ >Font density</label
1585
1758
  >
1586
1759
  <div class="lab-slider-wrapper">
1587
1760
  <input
1588
- id="lab-line-height"
1761
+ id="lab-line-height" title="--font-density: sets --line-height, which stays between 1.35 and 1.75"
1589
1762
  type="range"
1590
1763
  class="lab-range"
1591
1764
  value={lineHeight}
1592
- min="1"
1593
- max="2"
1765
+ min="1.35"
1766
+ max="1.75"
1594
1767
  step="0.05"
1595
1768
  oninput={onLineHeightInput}
1596
1769
  />
@@ -1615,23 +1788,6 @@
1615
1788
  <span class="lab-readout">{ratio.toFixed(2)}</span>
1616
1789
  </div>
1617
1790
  </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
1791
  <div class="lab-field">
1636
1792
  <label class="lab-field-label" for="lab-gap-body">Body Gap</label>
1637
1793
  <div class="lab-slider-wrapper">
@@ -1650,15 +1806,15 @@
1650
1806
  </div>
1651
1807
  <div class="lab-field">
1652
1808
  <label class="lab-field-label" for="lab-rhythm-gap"
1653
- >Rhythm Gap</label
1809
+ >Block rhythm</label
1654
1810
  >
1655
1811
  <div class="lab-slider-wrapper">
1656
1812
  <input
1657
- id="lab-rhythm-gap"
1813
+ id="lab-rhythm-gap" title="--rhythm-block-scale: the space around tables, cards and figures, as a multiple of the text gap"
1658
1814
  type="range"
1659
1815
  class="lab-range"
1660
1816
  value={rhythmGap}
1661
- min="0"
1817
+ min="1"
1662
1818
  max="4"
1663
1819
  step="0.25"
1664
1820
  oninput={onRhythmGapInput}
@@ -1790,10 +1946,12 @@
1790
1946
  STYLES — Fully scoped, isolated from design tokens
1791
1947
  ====================================================================== -->
1792
1948
  <style>
1793
- /* ═══════════ PAGE SHRINK (like DevTools) ═══════════ */
1794
- :global(html.lab-open) {
1795
- margin-right: var(--lab-sidebar-w, 320px);
1796
- transition: margin-right var(--transition);
1949
+ /* ═══════════ PAGE SHRINK (only when pinned on wide screens) ═══════════ */
1950
+ @media (min-width: 1025px) {
1951
+ :global(html.lab-open) {
1952
+ margin-right: calc(var(--lab-sidebar-w, 304px) + var(--window-inset, 8px) * 2);
1953
+ transition: margin-right var(--transition);
1954
+ }
1797
1955
  }
1798
1956
  :global(html:not(.lab-open)) {
1799
1957
  transition: margin-right var(--transition);
@@ -1802,81 +1960,82 @@
1802
1960
  /* ═══════════ DESIGN CONSTANTS ═══════════ */
1803
1961
  :root {
1804
1962
  --lab-font-size: 12px;
1805
- --lab-sidebar-w: 320px;
1963
+ --lab-sidebar-w: 304px;
1806
1964
  }
1807
1965
 
1808
- /* ═══════════ HANDLER TRIGGER ═══════════ */
1809
- .lab-handler-top {
1810
- position: fixed;
1811
- z-index: 200;
1812
- top: 0;
1813
- right: 0;
1814
- padding: var(--space-2);
1815
- cursor: pointer;
1816
- opacity: 0.1;
1817
- transition: opacity var(--transition);
1818
- display: none;
1819
- }
1820
- .lab-handler-top:hover {
1821
- opacity: 1;
1822
- }
1823
- .lab-dot {
1824
- width: var(--space-d4);
1825
- height: var(--space-d4);
1826
- background: var(--color-foreground);
1827
- border-radius: 50%;
1966
+ /* ═══════════ TOP-RIGHT STICKY FLASK TRIGGER ═══════════ */
1967
+ :global(:root[data-lab-handler="false"]) .lab-open-btn,
1968
+ :global(:root[data-lab-no-handler]) .lab-open-btn {
1969
+ display: none !important;
1970
+ pointer-events: none !important;
1828
1971
  }
1829
1972
 
1830
- .lab-handler {
1973
+ .lab-open-btn {
1831
1974
  position: fixed;
1832
- z-index: 200;
1833
- top: 20%;
1834
- right: 0;
1835
- padding: var(--space); /* Bigger hit area */
1836
- padding-right: 20px;
1837
- cursor: normal;
1838
- display: flex;
1839
- flex-direction: row;
1975
+ z-index: 9991;
1976
+ top: calc(var(--window-inset, 8px) + env(safe-area-inset-top, 0px));
1977
+ right: calc(var(--window-inset, 8px) + env(safe-area-inset-right, 0px));
1978
+ display: inline-flex;
1840
1979
  align-items: center;
1841
- transition: all var(--transition);
1980
+ justify-content: center;
1981
+ width: calc(var(--space) * 1.5);
1982
+ height: calc(var(--space) * 1.5);
1983
+ padding: 0;
1984
+ border: none;
1985
+ border-radius: var(--radius-sm);
1986
+ background: var(--color-surface-raised);
1987
+ color: var(--color-muted);
1988
+ box-shadow: var(--shadow), var(--pane-edge);
1989
+ cursor: pointer;
1990
+ transition:
1991
+ color var(--transition-fast),
1992
+ background var(--transition-fast),
1993
+ transform var(--transition-fast),
1994
+ box-shadow var(--transition-fast);
1842
1995
  }
1843
- .lab-handler.scrolling .lab-handler-bar {
1844
- background: oklch(from var(--color-foreground) l c h / 0.3);
1996
+ .lab-open-btn:hover {
1997
+ color: var(--color-foreground);
1998
+ background: var(--color-surface-overlay);
1999
+ box-shadow: var(--shadow-lg), var(--pane-edge);
1845
2000
  }
1846
- .lab-handler-bar {
1847
- width: var(--space-d4);
1848
- height: var(--space-2);
1849
- background: oklch(from var(--color-foreground) l c h / 0);
1850
- border-radius: var(--radius);
1851
- box-shadow: var(--shadow-inset) var(--shadow-ring);
1852
- transition: all var(--transition);
1853
- cursor: normal;
1854
- backdrop-filter: var(--filter-glass);
2001
+ .lab-open-btn.modified {
2002
+ color: var(--color-accent);
1855
2003
  }
1856
- .lab-handler.modified .lab-handler-bar {
1857
- background: oklch(from var(--color-accent) l c h / 0.1);
2004
+ .lab-mod-dot {
2005
+ position: absolute;
2006
+ top: 4px;
2007
+ right: 4px;
2008
+ width: 6px;
2009
+ height: 6px;
2010
+ border-radius: 50%;
2011
+ background: var(--color-accent);
1858
2012
  }
1859
2013
 
1860
- /* ═══════════ SIDEBAR ═══════════ */
2014
+ /* ═══════════ RIGHT SIDEBAR PANE (Reveal Dev Panel geometry) ═══════════ */
1861
2015
  .lab-sidebar {
1862
2016
  position: fixed;
1863
2017
  z-index: 9992;
1864
- top: 0;
1865
- right: 0;
2018
+ top: calc(var(--window-inset, 8px) + env(safe-area-inset-top, 0px));
2019
+ right: calc(var(--window-inset, 8px) + env(safe-area-inset-right, 0px));
1866
2020
  width: var(--lab-sidebar-w);
1867
- height: 100dvh;
2021
+ height: calc(
2022
+ 100dvh - 2 * var(--window-inset, 8px) - env(safe-area-inset-top, 0px) -
2023
+ env(safe-area-inset-bottom, 0px)
2024
+ );
2025
+ margin: 0;
2026
+ padding: 0;
1868
2027
 
1869
2028
  color: var(--color-foreground);
1870
2029
  font: var(--lab-font-size) / 1.5 var(--font-monospace);
1871
- border-left: var(--border);
2030
+ border-radius: var(--pane-radius, 9px);
1872
2031
  display: flex;
1873
2032
  flex-direction: column;
1874
- box-shadow: var(--shadow-ring), var(--shadow-glow);
1875
- background: var(--color-surface);
1876
- backdrop-filter: var(--filter-blur);
2033
+ box-shadow: var(--shadow-overlay), var(--pane-edge);
2034
+ background: var(--color-surface-raised);
2035
+ backdrop-filter: var(--filter-glass);
2036
+ overflow: hidden;
1877
2037
  /* Ensure the sidebar stays fully opaque and never animates opacity */
1878
2038
  opacity: 1 !important;
1879
- /* Explicitly disable opacity animation while preserving other transitions */
1880
2039
  transition:
1881
2040
  transform var(--transition),
1882
2041
  box-shadow var(--transition),
@@ -1891,8 +2050,6 @@
1891
2050
  /* Reset children to Lab font, allowing explicit font-family previews to win */
1892
2051
  .lab-sidebar * {
1893
2052
  box-sizing: border-box;
1894
- /* Disable opacity transitions on child elements to prevent fading when opening the lab.
1895
- Other property transitions for children remain allowed outside of opacity. */
1896
2053
  transition: opacity 0s !important;
1897
2054
  }
1898
2055
  .lab-sidebar *:not([style*="font-family"]) {
@@ -1905,19 +2062,22 @@
1905
2062
  flex-wrap: wrap;
1906
2063
  align-items: center;
1907
2064
  gap: 0;
1908
- padding: var(--space-d6) var(--space-d2);
2065
+ padding: var(--space-d4) var(--space-d2);
1909
2066
  border-bottom: var(--border);
1910
2067
  flex-shrink: 0;
1911
-
1912
2068
  background: var(--color-surface-raised);
1913
2069
  }
1914
2070
  .lab-header-title {
1915
2071
  display: flex;
1916
2072
  align-items: center;
1917
- gap: 0px;
2073
+ gap: var(--space-d5);
1918
2074
  flex: 1;
1919
2075
  min-width: 0;
1920
2076
  }
2077
+ .lab-flask-mark {
2078
+ color: var(--color-accent);
2079
+ flex-shrink: 0;
2080
+ }
1921
2081
  .lab-header h2 {
1922
2082
  font-size: var(--scale-d2) !important;
1923
2083
  line-height: 1 !important;
@@ -2276,11 +2436,15 @@
2276
2436
  /* ═══════════ RESPONSIVE ═══════════ */
2277
2437
  @media (max-width: 480px) {
2278
2438
  .lab-sidebar {
2439
+ top: 0;
2440
+ right: 0;
2279
2441
  width: 100vw;
2442
+ height: 100dvh;
2443
+ border-radius: 0;
2280
2444
  }
2281
2445
  }
2282
2446
  @media print {
2283
- .lab-handler,
2447
+ .lab-open-btn,
2284
2448
  .lab-sidebar {
2285
2449
  display: none !important;
2286
2450
  }
package/README.md CHANGED
@@ -14,7 +14,7 @@ status: active
14
14
  tags:
15
15
  - package
16
16
  - stnd
17
- theme: kernel
17
+ theme: maker
18
18
  type: package
19
19
  visibility: public
20
20
  garden-url: https://standard.garden/@francis/readme
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stnd/lab",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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"