@hraness/design-kit 0.36.5 → 0.39.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.
Files changed (59) hide show
  1. package/MARKETING_COPY.md +2 -0
  2. package/README.md +77 -11
  3. package/dist/{chunk-xa28y4fg.js → chunk-1dgm85ta.js} +191 -55
  4. package/dist/chunk-6mx403v5.js +286 -0
  5. package/dist/{chunk-thv5t11w.js → chunk-dc8v05zh.js} +6 -137
  6. package/dist/{chunk-evb02bc1.js → chunk-fcs5qekr.js} +48 -1
  7. package/dist/chunk-fxwed3e5.js +775 -0
  8. package/dist/chunk-vy0pt84a.js +1807 -0
  9. package/dist/{chunk-sabcr66p.js → chunk-w1y1m3m7.js} +300 -1
  10. package/dist/index.js +24 -12
  11. package/dist/mockups/client.js +6 -654
  12. package/dist/mockups/index.js +29 -1797
  13. package/dist/provider-marks.js +1 -1
  14. package/dist/react/index.js +190 -5
  15. package/dist/react/server.js +5 -3
  16. package/dist/stylex-manifest.json +1 -1
  17. package/package.json +9 -5
  18. package/portfolio-inventory.json +1 -1
  19. package/src/compiler-foundation.css +2 -0
  20. package/src/design-gallery.css +12 -0
  21. package/src/index.ts +1 -0
  22. package/src/marketing-marquee.css +281 -0
  23. package/src/marketing-marquee.ts +206 -0
  24. package/src/mockups/client.tsx +157 -66
  25. package/src/mockups/core.tsx +30 -0
  26. package/src/mockups.css +158 -23
  27. package/src/provider-marks.generated.ts +92 -0
  28. package/src/provider-marks.ts +29 -4
  29. package/src/react/design-gallery.tsx +53 -0
  30. package/src/react/index.ts +1 -0
  31. package/src/react/marketing-marquee.tsx +71 -0
  32. package/src/react/server.ts +1 -0
  33. package/src/styles.css +2 -0
  34. package/vendor/provider-marks/BOOTSTRAP-ICONS-LICENSE +21 -0
  35. package/vendor/provider-marks/UPSTREAM.md +28 -0
  36. package/vendor/provider-marks/bootstrap-icons/calendar-event-fill.svg +3 -0
  37. package/vendor/provider-marks/bootstrap-icons/filetype-csv.svg +3 -0
  38. package/vendor/provider-marks/bootstrap-icons/globe2.svg +3 -0
  39. package/vendor/provider-marks/bootstrap-icons/linkedin.svg +3 -0
  40. package/vendor/provider-marks/bootstrap-icons/microsoft.svg +3 -0
  41. package/vendor/provider-marks/bootstrap-icons/person-vcard-fill.svg +3 -0
  42. package/vendor/provider-marks/bootstrap-icons/shop.svg +3 -0
  43. package/vendor/provider-marks/simple-icons/apple.svg +1 -0
  44. package/vendor/provider-marks/simple-icons/bluesky.svg +1 -0
  45. package/vendor/provider-marks/simple-icons/facebook.svg +1 -0
  46. package/vendor/provider-marks/simple-icons/github.svg +1 -0
  47. package/vendor/provider-marks/simple-icons/gmail.svg +1 -0
  48. package/vendor/provider-marks/simple-icons/google.svg +1 -0
  49. package/vendor/provider-marks/simple-icons/instagram.svg +1 -0
  50. package/vendor/provider-marks/simple-icons/reddit.svg +1 -0
  51. package/vendor/provider-marks/simple-icons/substack.svg +1 -0
  52. package/vendor/provider-marks/simple-icons/telegram.svg +1 -0
  53. package/vendor/provider-marks/simple-icons/threads.svg +1 -0
  54. package/vendor/provider-marks/simple-icons/tiktok.svg +1 -0
  55. package/vendor/provider-marks/simple-icons/twitch.svg +1 -0
  56. package/vendor/provider-marks/simple-icons/x.svg +1 -0
  57. package/vendor/provider-marks/simple-icons/ycombinator.svg +1 -0
  58. package/vendor/provider-marks/simple-icons/youtube.svg +1 -0
  59. package/dist/chunk-gmea6p0d.js +0 -13
@@ -9,6 +9,7 @@ import {
9
9
  useMemo,
10
10
  useRef,
11
11
  useState,
12
+ version,
12
13
  } from "react";
13
14
 
14
15
  import { joinMockupClasses, type MockupTheme } from "./core.js";
@@ -23,6 +24,18 @@ import { joinMockupClasses, type MockupTheme } from "./core.js";
23
24
  // measure only in the browser.
24
25
  const useIsomorphicLayoutEffect: typeof useLayoutEffect = typeof document === "undefined" ? () => undefined : useLayoutEffect;
25
26
 
27
+ // React 18 drops unknown boolean attributes; React 19 recognizes inert as
28
+ // boolean. Keep the actual HTML attribute in both supported runtimes. The
29
+ // record describes compatibility attributes without claiming React 18's
30
+ // HTMLAttributes (or React 19's boolean-only inert prop) accepts both forms.
31
+ const inertAttribute: Readonly<Record<string, true | "">> = {
32
+ inert: Number.parseInt(version, 10) >= 19 ? true : "",
33
+ };
34
+ const noInertAttribute: Readonly<Record<string, true | "">> = {};
35
+ function inertAttributes(inactive: boolean): Readonly<Record<string, true | "">> {
36
+ return inactive ? inertAttribute : noInertAttribute;
37
+ }
38
+
26
39
  function assertUniqueIds(items: readonly Readonly<{ id: string }>[], what: string): void {
27
40
  if (items.length === 0) throw new RangeError(`${what} needs at least one entry.`);
28
41
  const seen = new Set<string>();
@@ -128,6 +141,8 @@ export type ShowcaseState<M extends string, O extends string = string> = Readonl
128
141
  export type ShowcaseSurface<S extends string, M extends string, O extends string = string> = Readonly<{
129
142
  id: S;
130
143
  label: string;
144
+ /** Optional explanation of what this surface illustrates. */
145
+ hint?: string;
131
146
  render: (state: ShowcaseState<M, O>) => ReactNode;
132
147
  }>;
133
148
 
@@ -208,7 +223,7 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
208
223
  // Pure, nonanimated authored states reserve one maximum before any choice.
209
224
  // They stay hidden and inert; only a detached measurement clone reveals them.
210
225
  const measurements = useMemo(() => fit !== "fill" ? null : surfaces.flatMap((entry) => modes.flatMap((choice) => (options ?? [{ id: undefined }]).map((optionChoice) => (
211
- <div aria-hidden="true" className="hkm-mode-surface" data-hkm-measurement="" hidden inert key={JSON.stringify([entry.id, choice.id, optionChoice.id])}>
226
+ <div aria-hidden="true" className="hkm-mode-surface" data-hkm-measurement="" hidden {...inertAttributes(true)} key={JSON.stringify([entry.id, choice.id, optionChoice.id])}>
212
227
  <div className="hkm-fit"><div className="hkm-fit-inner">
213
228
  {entry.render({ animated: false, mode: choice.id, option: optionChoice.id, previousMode: choice.id, theme })}
214
229
  </div></div>
@@ -238,10 +253,12 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
238
253
  };
239
254
 
240
255
  const statusNode = status?.({ mode, option, surface: surface.id });
241
- const hint = [modeChoice.hint, optionInactive ? undefined : optionChoice?.hint]
242
- .map((text) => optionalText(text, "ModeShowcase hint"))
243
- .filter((text) => text !== undefined)
244
- .join(" ");
256
+ const describeChoice = (surfaceHint: string | undefined, modeHint: string | undefined, optionHint: string | undefined) =>
257
+ [surfaceHint, modeHint, optionHint].map((text) => optionalText(text, "ModeShowcase hint")).filter((text) => text !== undefined).join(" ");
258
+ const hint = describeChoice(surface.hint, modeChoice.hint, optionInactive ? undefined : optionChoice?.hint);
259
+ const hints = [...new Set(surfaces.flatMap((entry) => modes.flatMap((choice) =>
260
+ (optionInactiveModes.includes(choice.id) ? [undefined] : (options ?? [undefined])).map((optionEntry) =>
261
+ describeChoice(entry.hint, choice.hint, optionEntry?.hint)))))].filter((text) => text !== "");
245
262
  const hasStatus = statusNode !== undefined && statusNode !== null && statusNode !== false && statusNode !== "";
246
263
  return (
247
264
  <figure
@@ -307,7 +324,12 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
307
324
  </div>
308
325
  )}
309
326
  </div>
310
- {hint === "" ? null : <p className="hkm-showcase-hint">{hint}</p>}
327
+ {hints.length === 0 ? null : (
328
+ <div className="hkm-showcase-hints">
329
+ <p className="hkm-showcase-hint">{hint}</p>
330
+ {hints.filter((text) => text !== hint).map((text) => <p aria-hidden="true" className="hkm-showcase-hint" data-hkm-reserving="" {...inertAttributes(true)} key={text}>{text}</p>)}
331
+ </div>
332
+ )}
311
333
  </div>
312
334
  <div className="hkm-mode-stage" ref={stage}>
313
335
  {measurements}
@@ -317,7 +339,7 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
317
339
  className="hkm-mode-surface"
318
340
  data-hkm-animated={entry.id === surface.id && animated ? "" : undefined}
319
341
  data-hkm-from={entry.id === surface.id && animated ? previousMode : undefined}
320
- inert={entry.id !== surface.id}
342
+ {...inertAttributes(entry.id !== surface.id)}
321
343
  key={entry.id}
322
344
  >
323
345
  <FitToWidth minWidth={fit === "fill" ? 1 : minWidth}>
@@ -345,7 +367,7 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
345
367
  export type ThroughStep = Readonly<{
346
368
  id: string;
347
369
  label: string;
348
- /** Optional accessible context; the visual keeps its own explanation. */
370
+ /** Optional explanation beside the label, and below the preview on compact screens. */
349
371
  hint?: string;
350
372
  render: (state: Readonly<{ animated: boolean; theme: MockupTheme | undefined }>) => ReactNode;
351
373
  }>;
@@ -416,14 +438,14 @@ function fitShowcaseStage(stage: HTMLDivElement, minimumHeight: number): void {
416
438
  // The tallest complete slide establishes the floor. Fitted type never feeds
417
439
  // back into it, and changing the active tab never requests a measurement.
418
440
  stage.style.setProperty("--hkm-showcase-fill-height", `${naturalHeight}px`);
419
- for (const fixture of probe.querySelectorAll("[data-hkm-measurement]")) fixture.remove();
420
441
  probe.style.blockSize = `${naturalHeight}px`;
421
442
  probe.removeAttribute("data-hkm-measuring");
422
443
  const bodies = presentationBodies(stage).filter((body) => body.closest("[data-hkm-measurement]") === null);
423
444
  const copies = presentationBodies(probe);
424
- for (const [index, copy] of copies.entries()) {
425
- const body = bodies[index];
426
- if (body === undefined) continue;
445
+ // Every authored state uses one readable size. A short terminal must not
446
+ // become a different typographic scale when the next tab has more lines.
447
+ let sharedSize = 4;
448
+ for (const copy of copies) {
427
449
  const lines = [...copy.querySelectorAll<HTMLElement>(".hkm-terminal-line")];
428
450
  const wrappedRows = (line: HTMLElement) => Math.ceil((line.getBoundingClientRect().height - 0.5) / Number.parseFloat(getComputedStyle(line).lineHeight));
429
451
  const baselineRows = lines.map(wrappedRows);
@@ -435,8 +457,9 @@ function fitShowcaseStage(stage: HTMLDivElement, minimumHeight: number): void {
435
457
  if (copy.scrollHeight <= copy.clientHeight + 1 && copy.scrollWidth <= copy.clientWidth + 1 && lines.every((line, index) => { const baseline = baselineRows[index]; return baseline !== undefined && wrappedRows(line) <= baseline; })) low = candidate;
436
458
  else high = candidate;
437
459
  }
438
- body.style.setProperty("--hkm-terminal-presentation-size", `${Math.floor(low * 1000) / 1000}rem`);
460
+ sharedSize = Math.min(sharedSize, low);
439
461
  }
462
+ for (const body of bodies) body.style.setProperty("--hkm-terminal-presentation-size", `${Math.floor(sharedSize * 1000) / 1000}rem`);
440
463
  stage.setAttribute("data-hkm-fitted", "");
441
464
  } finally {
442
465
  probe.remove();
@@ -485,9 +508,12 @@ function useFittedShowcaseStage(fit: "natural" | "fill", source: unknown, variat
485
508
  }
486
509
 
487
510
  /**
488
- * Folder tabs and chevron controls walk a flow. All render functions stay mounted
489
- * to reserve the tallest panel; inactive panels are inert and visually hidden.
490
- * The keyboard model matches `ModeShowcase`.
511
+ * A tabbed walkthrough: the steps and the preview share one edge. Compact
512
+ * layouts attach a tab strip to the preview's top edge between Back and Next;
513
+ * at 720px and wider the steps stack on its start edge with their
514
+ * explanations. All render functions stay mounted to reserve the tallest
515
+ * panel; inactive panels are inert and visually hidden. The keyboard model
516
+ * matches `ModeShowcase`.
491
517
  */
492
518
  export function StepThrough({
493
519
  caption,
@@ -500,7 +526,7 @@ export function StepThrough({
500
526
  theme,
501
527
  }: Readonly<{
502
528
  steps: readonly ThroughStep[];
503
- /** Fill the tallest natural slide and fit presentation terminals without scaling. */
529
+ /** Fill the tallest natural slide and fit presentation terminals at one shared size. */
504
530
  fit?: "natural" | "fill";
505
531
  caption?: string;
506
532
  initial?: string;
@@ -516,17 +542,68 @@ export function StepThrough({
516
542
  const id = useId();
517
543
  const [index, setIndex] = useState(() => Math.max(0, steps.findIndex((step) => step.id === initial)));
518
544
  const [animated, setAnimated] = useState(false);
545
+ const [vertical, setVertical] = useState(false);
546
+ const layout = useRef<HTMLDivElement>(null);
547
+ const tablist = useRef<HTMLDivElement>(null);
519
548
  const tabs = useRef(new Map<number, HTMLButtonElement>());
520
549
  const current = Math.min(index, steps.length - 1);
521
- const step = itemAt(steps, current, "StepThrough steps");
550
+ const hints = steps.map((entry) => optionalText(entry.hint, "StepThrough hint"));
551
+ const currentHint = hints[current];
552
+
553
+ useIsomorphicLayoutEffect(() => {
554
+ const node = layout.current;
555
+ if (node === null || typeof ResizeObserver === "undefined") return undefined;
556
+ const measure = () => setVertical(node.clientWidth >= 720);
557
+ measure();
558
+ const observer = new ResizeObserver(measure);
559
+ observer.observe(node);
560
+ return () => observer.disconnect();
561
+ }, []);
562
+
563
+ // A strip that cannot show every label in full keeps only the selected
564
+ // label visible; the rest show their numbers and keep their accessible names.
565
+ useIsomorphicLayoutEffect(() => {
566
+ const list = tablist.current;
567
+ if (list === null || typeof ResizeObserver === "undefined") return undefined;
568
+ let disposed = false;
569
+ const measure = () => {
570
+ if (disposed) return;
571
+ list.removeAttribute("data-hkm-compact");
572
+ if (vertical) return;
573
+ const truncated = [...list.querySelectorAll<HTMLElement>(".hkm-step-label")].some((label) => label.scrollWidth > label.clientWidth + 1);
574
+ if (truncated) list.setAttribute("data-hkm-compact", "");
575
+ };
576
+ measure();
577
+ const observer = new ResizeObserver(measure);
578
+ observer.observe(list);
579
+ void document.fonts.ready.then(measure);
580
+ return () => {
581
+ disposed = true;
582
+ observer.disconnect();
583
+ list.removeAttribute("data-hkm-compact");
584
+ };
585
+ }, [current, vertical]);
586
+
587
+ useIsomorphicLayoutEffect(() => {
588
+ const list = tablist.current;
589
+ const selected = tabs.current.get(current);
590
+ if (vertical || list === null || selected === undefined) return;
591
+ const bounds = list.getBoundingClientRect();
592
+ const active = selected.getBoundingClientRect();
593
+ // Scroll only the owned strip, without moving the surrounding page.
594
+ if (active.left < bounds.left) list.scrollLeft += active.left - bounds.left;
595
+ else if (active.right > bounds.right) list.scrollLeft += active.right - bounds.right;
596
+ }, [current, vertical]);
522
597
 
523
598
  const go = (next: number, focus = false) => {
524
599
  if (next < 0 || next >= steps.length || next === current) return;
525
600
  setIndex(next);
526
601
  setAnimated(true);
527
- if (focus) tabs.current.get(next)?.focus();
602
+ if (focus) tabs.current.get(next)?.focus({ preventScroll: true });
528
603
  };
529
604
  const onTabKey = (event: KeyboardEvent<HTMLButtonElement>) => {
605
+ if (vertical && (event.key === "ArrowLeft" || event.key === "ArrowRight")) return;
606
+ if (!vertical && (event.key === "ArrowUp" || event.key === "ArrowDown")) return;
530
607
  const next = nextTabIndex(event.key, current, steps.length);
531
608
  if (next === undefined) return;
532
609
  event.preventDefault();
@@ -535,61 +612,75 @@ export function StepThrough({
535
612
 
536
613
  return (
537
614
  <figure aria-label={`Illustration: ${label.toLowerCase()}`} className={joinMockupClasses("hkm-showcase", "hkm-steps", className)} data-hkm-fit={fit === "fill" ? fit : undefined} data-hkm-theme={theme} data-nosnippet="">
538
- <div className="hkm-showcase-controls">
539
- <div aria-label={label} className="hkm-tabs hkm-folder-tabs hkm-step-tabs" role="tablist">
615
+ <div className="hkm-step-layout" ref={layout}>
616
+ <div className="hkm-showcase-controls hkm-step-controls">
617
+ <div className="hkm-step-nav">
618
+ <button aria-label="Back" className="hkm-step-button" disabled={current === 0} onClick={() => go(current - 1)} type="button">
619
+ <svg aria-hidden="true" focusable="false" height="20" viewBox="0 0 24 24" width="20"><path d="m14.5 5-7 7 7 7" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.75" /></svg>
620
+ </button>
621
+ <span aria-hidden="true" className="hkm-step-count">{current + 1} / {steps.length}</span>
622
+ <div aria-label={label} aria-orientation={vertical ? "vertical" : "horizontal"} className="hkm-tabs hkm-step-tabs" ref={tablist} role="tablist">
623
+ {steps.map((entry, position) => (
624
+ <button
625
+ aria-controls={`${id}-panel-${entry.id}`}
626
+ aria-describedby={hints[position] === undefined ? undefined : `${id}-hint-${entry.id}`}
627
+ aria-label={entry.label}
628
+ aria-selected={position === current}
629
+ className="hkm-tab"
630
+ data-hkm-done={position < current ? "" : undefined}
631
+ id={`${id}-tab-${entry.id}`}
632
+ key={entry.id}
633
+ onClick={() => go(position)}
634
+ onKeyDown={onTabKey}
635
+ ref={(element) => {
636
+ if (element === null) tabs.current.delete(position);
637
+ else tabs.current.set(position, element);
638
+ }}
639
+ role="tab"
640
+ tabIndex={position === current ? 0 : -1}
641
+ type="button"
642
+ >
643
+ <span aria-hidden="true" className="hkm-step-number">{position + 1}</span>
644
+ <span className="hkm-step-copy">
645
+ <span className="hkm-step-label">{entry.label}</span>
646
+ {hints[position] === undefined ? null : <span className="hkm-step-hint" id={`${id}-hint-${entry.id}`}>{hints[position]}</span>}
647
+ </span>
648
+ </button>
649
+ ))}
650
+ </div>
651
+ <button aria-label="Next" className="hkm-step-button" disabled={current === steps.length - 1} onClick={() => go(current + 1)} type="button">
652
+ <svg aria-hidden="true" focusable="false" height="20" viewBox="0 0 24 24" width="20"><path d="m9.5 5 7 7-7 7" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.75" /></svg>
653
+ </button>
654
+ </div>
655
+ </div>
656
+ <div className="hkm-showcase-stage hkm-step-stage" ref={stage}>
540
657
  {steps.map((entry, position) => (
541
- <button
542
- aria-controls={`${id}-panel-${entry.id}`}
543
- aria-selected={position === current}
544
- className="hkm-tab"
545
- data-hkm-done={position < current ? "" : undefined}
546
- id={`${id}-tab-${entry.id}`}
658
+ <div
659
+ aria-hidden={position !== current}
660
+ aria-labelledby={`${id}-tab-${entry.id}`}
661
+ className="hkm-step-panel"
662
+ data-hkm-animated={position === current && animated ? "" : undefined}
663
+ id={`${id}-panel-${entry.id}`}
664
+ {...inertAttributes(position !== current)}
547
665
  key={entry.id}
548
- onClick={() => go(position)}
549
- onKeyDown={onTabKey}
550
- ref={(element) => {
551
- if (element === null) tabs.current.delete(position);
552
- else tabs.current.set(position, element);
553
- }}
554
- role="tab"
666
+ role="tabpanel"
555
667
  tabIndex={position === current ? 0 : -1}
556
- type="button"
557
668
  >
558
- <span aria-hidden="true" className="hkm-step-number">{position + 1}</span>
559
- {entry.label}
560
- </button>
669
+ {fit === "fill"
670
+ ? <div className="hkm-fit"><div className="hkm-fit-inner">{entry.render({ animated: position === current && animated, theme })}</div></div>
671
+ : <FitToWidth minWidth={minWidth}>{entry.render({ animated: position === current && animated, theme })}</FitToWidth>}
672
+ </div>
561
673
  ))}
562
674
  </div>
563
- </div>
564
- <div className="hkm-showcase-stage hkm-step-stage" ref={stage}>
565
- {steps.map((entry, position) => (
566
- <div
567
- aria-hidden={position !== current}
568
- aria-labelledby={`${id}-tab-${entry.id}`}
569
- className="hkm-step-panel"
570
- data-hkm-animated={position === current && animated ? "" : undefined}
571
- id={`${id}-panel-${entry.id}`}
572
- inert={position !== current}
573
- key={entry.id}
574
- role="tabpanel"
575
- >
576
- {fit === "fill"
577
- ? <div className="hkm-fit"><div className="hkm-fit-inner">{entry.render({ animated: position === current && animated, theme })}</div></div>
578
- : <FitToWidth minWidth={minWidth}>{entry.render({ animated: position === current && animated, theme })}</FitToWidth>}
675
+ {hints.some((hint) => hint !== undefined) ? (
676
+ <div aria-hidden="true" className="hkm-step-descriptions">
677
+ {steps.map((entry, position) => <p className="hkm-step-description" data-hkm-active={position === current ? "" : undefined} key={entry.id}>{hints[position]}</p>)}
579
678
  </div>
580
- ))}
581
- </div>
582
- <div className="hkm-step-nav">
583
- <button aria-label="Back" className="hkm-step-button" disabled={current === 0} onClick={() => go(current - 1)} type="button">
584
- <svg aria-hidden="true" focusable="false" height="28" viewBox="0 0 24 24" width="28"><path d="m14.5 5-7 7 7 7" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.75" /></svg>
585
- </button>
586
- <span aria-live="polite" className="hkm-showcase-status hkm-step-announcement">
587
- Step {current + 1} of {steps.length}{step.hint === undefined ? "" : `. ${step.hint}`}
588
- </span>
589
- <button aria-label="Next" className="hkm-step-button" data-hkm-primary="" disabled={current === steps.length - 1} onClick={() => go(current + 1)} type="button">
590
- <svg aria-hidden="true" focusable="false" height="28" viewBox="0 0 24 24" width="28"><path d="m9.5 5 7 7-7 7" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.75" /></svg>
591
- </button>
679
+ ) : null}
592
680
  </div>
681
+ <span aria-live="polite" className="hkm-showcase-status hkm-step-announcement">
682
+ Step {current + 1} of {steps.length}{currentHint === undefined ? "" : `. ${currentHint}`}
683
+ </span>
593
684
  {captionText === undefined ? null : (
594
685
  <figcaption className="hkm-showcase-caption"><span>{captionText}</span></figcaption>
595
686
  )}
@@ -1,5 +1,7 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
 
3
+ import { providerMark, providerMarkMonogram, providerMarkOnAccent } from "../provider-marks.js";
4
+
3
5
  /*
4
6
  * Shared pieces for the code-built mockups. Server-safe plain React: no hooks,
5
7
  * no context, no StyleX, and no runtime dependency other than React, so a film
@@ -306,6 +308,34 @@ export function MockupGlyph({
306
308
  );
307
309
  }
308
310
 
311
+ /**
312
+ * A product's brand mark from the shared provider-mark registry, such as
313
+ * "Apple Contacts", "LinkedIn", or "iMessage". `tile` draws the vendor glyph
314
+ * in readable ink on the brand color, like an app icon; `glyph` follows the
315
+ * surrounding text color. Names without a registered mark get a neutral
316
+ * monogram tile, never an invented logo. Decorative: always aria-hidden, so
317
+ * name the product in nearby text.
318
+ */
319
+ export function MockupBrandMark({
320
+ className,
321
+ name,
322
+ size = 32,
323
+ variant = "tile",
324
+ }: Readonly<{ name: string; size?: number; variant?: "tile" | "glyph"; className?: string }>) {
325
+ if (!(size > 0)) throw new RangeError("MockupBrandMark size must be positive.");
326
+ const mark = providerMark(name);
327
+ const style = { "--hkm-mark-size": `${String(size)}px`, ...(mark === undefined || variant !== "tile" ? {} : { "--hkm-mark-accent": mark.accent, "--hkm-mark-ink": providerMarkOnAccent(mark) }) } as CSSProperties;
328
+ if (mark === undefined) {
329
+ return <span aria-hidden="true" className={joinMockupClasses("hkm-brand-mark", className)} data-hkm-monogram="" style={style}>{providerMarkMonogram(name)}</span>;
330
+ }
331
+ return (
332
+ <span aria-hidden="true" className={joinMockupClasses("hkm-brand-mark", className)} data-hkm-mark={mark.id} data-hkm-variant={variant} style={style}>
333
+ {/* Generated from vendored, sanitized artwork in provider-marks.generated.ts. */}
334
+ <svg dangerouslySetInnerHTML={{ __html: mark.glyph.body }} fill="currentColor" focusable="false" viewBox={mark.glyph.viewBox} />
335
+ </span>
336
+ );
337
+ }
338
+
309
339
  /** The three window buttons at the start of a title bar. Decorative. */
310
340
  export function WindowLights() {
311
341
  return (
package/src/mockups.css CHANGED
@@ -107,6 +107,39 @@
107
107
  flex: none;
108
108
  }
109
109
 
110
+ /* Brand marks are app-icon tiles: the vendor glyph in readable ink on the
111
+ * vendor color. The glyph variant follows the surrounding text instead. */
112
+ .hkm-root .hkm-brand-mark {
113
+ display: inline-grid;
114
+ flex: none;
115
+ place-items: center;
116
+ inline-size: var(--hkm-mark-size, 32px);
117
+ block-size: var(--hkm-mark-size, 32px);
118
+ border-radius: calc(var(--hkm-mark-size, 32px) * 0.26);
119
+ background: var(--hkm-mark-accent, var(--hkm-field));
120
+ color: var(--hkm-mark-ink, var(--hkm-muted));
121
+ box-shadow: inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.1));
122
+ font: 650 calc(var(--hkm-mark-size, 32px) * 0.36)/1 var(--hkm-font);
123
+ letter-spacing: 0.01em;
124
+ }
125
+
126
+ .hkm-root .hkm-brand-mark svg {
127
+ display: block;
128
+ inline-size: 56%;
129
+ block-size: 56%;
130
+ }
131
+
132
+ .hkm-root .hkm-brand-mark[data-hkm-variant="glyph"] {
133
+ background: none;
134
+ box-shadow: none;
135
+ color: inherit;
136
+ }
137
+
138
+ .hkm-root .hkm-brand-mark[data-hkm-variant="glyph"] svg {
139
+ inline-size: 100%;
140
+ block-size: 100%;
141
+ }
142
+
110
143
  .hkm-root .hkm-avatar {
111
144
  display: inline-grid;
112
145
  flex: none;
@@ -2329,22 +2362,13 @@
2329
2362
  }
2330
2363
 
2331
2364
  .hkm-showcase .hkm-step-number {
2332
- display: inline-grid;
2333
- place-items: center;
2334
- inline-size: 20px;
2335
- block-size: 20px;
2336
- border: 1px solid currentColor;
2337
- border-radius: 50%;
2338
- font-size: 11px;
2365
+ flex: none;
2366
+ color: var(--hkm-sc-muted);
2367
+ font-size: 0.875rem;
2368
+ line-height: 1.5;
2339
2369
  font-variant-numeric: tabular-nums;
2340
2370
  }
2341
2371
 
2342
- .hkm-showcase .hkm-tab[data-hkm-done] .hkm-step-number {
2343
- border-color: var(--hkm-accent);
2344
- background: var(--hkm-accent);
2345
- color: var(--hkm-accent-foreground);
2346
- }
2347
-
2348
2372
  .hkm-showcase .hkm-showcase-settings {
2349
2373
  display: flex;
2350
2374
  flex-wrap: wrap;
@@ -2412,11 +2436,26 @@
2412
2436
  outline-color: var(--hkm-accent-foreground);
2413
2437
  }
2414
2438
 
2439
+ .hkm-showcase .hkm-showcase-hints {
2440
+ display: grid;
2441
+ min-inline-size: 0;
2442
+ }
2443
+
2444
+ .hkm-showcase .hkm-showcase-hints > .hkm-showcase-hint {
2445
+ grid-area: 1 / 1;
2446
+ }
2447
+
2448
+ .hkm-showcase .hkm-showcase-hint[data-hkm-reserving] {
2449
+ visibility: hidden;
2450
+ pointer-events: none;
2451
+ }
2452
+
2415
2453
  .hkm-showcase .hkm-showcase-hint {
2416
2454
  min-block-size: 2.9em;
2417
2455
  margin: 0;
2418
2456
  color: var(--hkm-sc-muted);
2419
2457
  font-size: 13px;
2458
+ overflow-wrap: anywhere;
2420
2459
  }
2421
2460
 
2422
2461
  .hkm-showcase .hkm-showcase-stage {
@@ -2512,14 +2551,14 @@
2512
2551
  }
2513
2552
  }
2514
2553
 
2515
- /* Folder tabs share an edge with each showcase panel. */
2554
+ /* Mode selectors retain a shared edge with their preview panel. */
2516
2555
  .hkm-showcase:is(.hkm-steps, .hkm-modes) { gap: 0; }
2517
2556
  .hkm-showcase.hkm-steps .hkm-showcase-controls { padding: 0; border: 0; border-radius: 0; background: transparent; }
2518
2557
  .hkm-showcase .hkm-folder-tabs { gap: 6px; margin: 0; padding: 0; border: 0; position: relative; z-index: 1; }
2519
2558
  .hkm-showcase .hkm-folder-tabs .hkm-tab { min-block-size: 48px; padding: 12px 18px; border: 1px solid var(--hkm-sc-line); border-block-end: 0; border-radius: 12px 12px 0 0; background: var(--hkm-sc-field); color: var(--hkm-sc-muted); }
2520
2559
  .hkm-showcase .hkm-folder-tabs .hkm-tab[aria-selected="true"] { background: var(--hkm-sc-bg); color: var(--hkm-sc-fg); }
2521
2560
  .hkm-showcase .hkm-folder-tabs .hkm-tab:hover { color: var(--hkm-sc-fg); }
2522
- .hkm-showcase.hkm-steps .hkm-step-stage { display: grid; border: 1px solid var(--hkm-sc-line); border-radius: 0 12px 12px 12px; background: var(--hkm-sc-bg); margin-block-start: -1px; overflow: clip; }
2561
+ .hkm-showcase.hkm-steps .hkm-step-stage { display: grid; min-inline-size: 0; border: 1px solid var(--hkm-sc-line); border-radius: var(--hkm-step-radius, 12px); background: var(--hkm-sc-bg); overflow: clip; }
2523
2562
  .hkm-showcase.hkm-modes .hkm-mode-panel { border: 1px solid var(--hkm-sc-line); border-radius: 12px; background: var(--hkm-sc-bg); overflow: clip; }
2524
2563
  .hkm-showcase.hkm-modes .hkm-folder-tabs + .hkm-mode-panel { border-start-start-radius: 0; margin-block-start: -1px; }
2525
2564
  .hkm-showcase.hkm-modes .hkm-showcase-controls { border: 0; border-block-end: 1px solid var(--hkm-sc-line); border-radius: 0; }
@@ -2527,10 +2566,13 @@
2527
2566
  .hkm-mode-surface { grid-area: 1 / 1; min-inline-size: 0; align-self: start; }
2528
2567
  .hkm-mode-surface[aria-hidden="true"] { visibility: hidden; pointer-events: none; }
2529
2568
  .hkm-showcase.hkm-modes .hkm-mode-surface > .hkm-fit > .hkm-fit-inner > .hkm-root,
2530
- .hkm-showcase.hkm-modes .hkm-mode-surface > .hkm-fit > .hkm-fit-inner > .hkm-root > .hkm-window { border: 0; border-radius: 0; box-shadow: none; }
2569
+ .hkm-showcase.hkm-modes .hkm-mode-surface > .hkm-fit > .hkm-fit-inner > .hkm-root > .hkm-window,
2570
+ .hkm-showcase.hkm-steps .hkm-step-panel > .hkm-fit > .hkm-fit-inner > .hkm-root,
2571
+ .hkm-showcase.hkm-steps .hkm-step-panel > .hkm-fit > .hkm-fit-inner > .hkm-root > .hkm-window { border: 0; border-radius: 0; box-shadow: none; }
2531
2572
  .hkm-step-panel { grid-area: 1 / 1; min-inline-size: 0; align-self: start; }
2532
2573
  .hkm-step-panel[aria-hidden="true"] { visibility: hidden; pointer-events: none; }
2533
- .hkm-showcase.hkm-steps .hkm-step-panel .hkm-root { border-start-start-radius: 0; border-start-end-radius: 0; box-shadow: none; }
2574
+ /* Browser and app titles use the same chrome geometry inside a sequence. */
2575
+ .hkm-showcase .hkm-root :is(.hkm-title-bar, .hkm-browser-bar) { block-size: 44px; font-size: 13px; }
2534
2576
  /* Fill uses the tallest complete slide as its natural height. It does not
2535
2577
  * scale text or ask fitted font sizes to determine the stage height. */
2536
2578
  .hkm-showcase[data-hkm-fit="fill"] [data-hkm-measuring] [data-hkm-fill-frame] > .hkm-window > .hkm-page { max-block-size: none; }
@@ -2567,11 +2609,75 @@
2567
2609
  flex: none;
2568
2610
  }
2569
2611
 
2570
- .hkm-showcase.hkm-steps .hkm-step-nav { justify-content: space-between; padding-block-start: 18px; gap: 16px; }
2571
- .hkm-showcase.hkm-steps .hkm-step-button { display: inline-grid; place-items: center; min-inline-size: 52px; min-block-size: 52px; padding: 10px; border: 0; border-radius: 12px; background: transparent; color: var(--hkm-sc-fg); }
2572
- .hkm-showcase.hkm-steps .hkm-step-button[data-hkm-primary] { background: var(--hkm-sc-fg); color: var(--hkm-sc-bg); }
2573
- .hkm-showcase.hkm-steps .hkm-step-button:disabled { color: var(--hkm-sc-muted); background: transparent; }
2574
- .hkm-showcase.hkm-steps .hkm-step-button[data-hkm-primary]:disabled { background: var(--hkm-sc-field); }
2612
+ /* A walkthrough is one tabbed selector: the steps and the preview share an
2613
+ * edge. Compact layouts attach a tab strip to the preview's top edge between
2614
+ * Back and Next. Wide layouts stack the steps on its start edge, flush with
2615
+ * a square corner, and put Back and Next under them. The selected tab takes
2616
+ * the window-chrome color so it reads as part of the frame below it. */
2617
+ .hkm-showcase.hkm-steps {
2618
+ --hkm-step-radius: 12px;
2619
+ --hkm-step-tab-radius: 10px;
2620
+ --hkm-step-tab-selected: var(--hkm-chrome);
2621
+ --hkm-step-tab-hover: color-mix(in srgb, var(--hkm-chrome) 55%, transparent);
2622
+ font-size: 1rem;
2623
+ line-height: 1.5;
2624
+ }
2625
+ .hkm-showcase .hkm-step-layout { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "controls" "stage" "descriptions"; align-items: start; min-inline-size: 0; }
2626
+ .hkm-showcase.hkm-steps .hkm-step-controls { grid-area: controls; position: relative; z-index: 1; min-inline-size: 0; margin-block-end: -1px; }
2627
+ .hkm-showcase.hkm-steps .hkm-step-stage { grid-area: stage; }
2628
+ .hkm-showcase.hkm-steps .hkm-step-nav { display: grid; grid-template-columns: 44px minmax(0, 1fr) 44px; grid-template-areas: "back tabs next"; align-items: end; gap: 6px; min-inline-size: 0; padding: 0; }
2629
+ .hkm-showcase.hkm-steps .hkm-step-button { display: inline-grid; place-items: center; inline-size: 44px; min-inline-size: 44px; min-block-size: 44px; margin-block-end: 1px; padding: 0; border: 0; border-radius: var(--hkm-step-tab-radius); background: transparent; color: var(--hkm-sc-fg); }
2630
+ .hkm-showcase.hkm-steps .hkm-step-button:first-child { grid-area: back; }
2631
+ .hkm-showcase.hkm-steps .hkm-step-button:last-child { grid-area: next; }
2632
+ .hkm-showcase.hkm-steps .hkm-step-button:hover:enabled { background: var(--hkm-step-tab-hover); }
2633
+ .hkm-showcase.hkm-steps .hkm-step-button:disabled { background: transparent; color: var(--hkm-sc-muted); cursor: default; }
2634
+ .hkm-showcase .hkm-step-count { display: none; grid-area: count; color: var(--hkm-sc-muted); font: 500 0.8125rem/1 var(--hkm-mono); font-variant-numeric: tabular-nums; text-align: center; }
2635
+ .hkm-showcase.hkm-steps .hkm-step-tabs { grid-area: tabs; display: flex; gap: 2px; min-inline-size: 0; margin: 0; padding: 0; overflow-x: auto; overflow-y: hidden; border: 0; scrollbar-width: none; }
2636
+ .hkm-showcase.hkm-steps .hkm-step-tabs::-webkit-scrollbar { display: none; }
2637
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab { display: inline-flex; flex: 0 1 auto; align-items: center; gap: 10px; min-inline-size: 44px; min-block-size: 44px; padding: 10px 16px; border: 1px solid transparent; border-block-end: 0; border-radius: var(--hkm-step-tab-radius) var(--hkm-step-tab-radius) 0 0; background: transparent; color: var(--hkm-sc-muted); font-size: 0.9375rem; line-height: 1.4; font-weight: 550; text-align: start; white-space: nowrap; }
2638
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab:hover { background: var(--hkm-step-tab-hover); color: var(--hkm-sc-fg); }
2639
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab[aria-selected="true"] { border-color: var(--hkm-sc-line); background: var(--hkm-step-tab-selected); color: var(--hkm-sc-fg); }
2640
+ /* A preview pinned to its own theme lends that theme to the selected tab,
2641
+ * so a dark window on a light page still reads as one frame. */
2642
+ .hkm-showcase.hkm-steps:has(.hkm-step-panel[aria-hidden="false"] .hkm-root[data-hkm-theme="dark"]) .hkm-step-tabs .hkm-tab[aria-selected="true"] { color-scheme: dark; }
2643
+ .hkm-showcase.hkm-steps:has(.hkm-step-panel[aria-hidden="false"] .hkm-root[data-hkm-theme="light"]) .hkm-step-tabs .hkm-tab[aria-selected="true"] { color-scheme: light; }
2644
+ .hkm-showcase.hkm-steps:has(.hkm-step-panel[aria-hidden="false"] .hkm-root[data-hkm-theme]) .hkm-step-tabs .hkm-tab[aria-selected="true"] { color: var(--hkm-fg); }
2645
+ .hkm-showcase.hkm-steps:has(.hkm-step-panel[aria-hidden="false"] .hkm-root[data-hkm-theme]) .hkm-step-tabs .hkm-tab[aria-selected="true"] :is(.hkm-step-number, .hkm-step-label) { color: var(--hkm-fg); }
2646
+ .hkm-showcase.hkm-steps:has(.hkm-step-panel[aria-hidden="false"] .hkm-root[data-hkm-theme]) .hkm-step-tabs .hkm-tab[aria-selected="true"] .hkm-step-hint { color: var(--hkm-muted); }
2647
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab:focus-visible { outline-offset: -3px; }
2648
+ .hkm-showcase.hkm-steps .hkm-step-number { color: var(--hkm-sc-muted); font: 500 0.8125rem/1.4 var(--hkm-mono); }
2649
+ .hkm-showcase.hkm-steps .hkm-tab[aria-selected="true"] .hkm-step-number { color: var(--hkm-sc-fg); }
2650
+ .hkm-showcase .hkm-step-copy { display: flex; flex-direction: column; gap: 4px; min-inline-size: 0; }
2651
+ .hkm-showcase .hkm-step-label { overflow: hidden; font: inherit; text-overflow: ellipsis; }
2652
+ .hkm-showcase .hkm-step-hint { display: none; color: var(--hkm-sc-muted); font-size: 0.875rem; line-height: 1.45; font-weight: 400; }
2653
+ .hkm-showcase .hkm-step-descriptions { grid-area: descriptions; display: grid; padding: 12px 4px 0; }
2654
+ .hkm-showcase .hkm-step-description { grid-area: 1 / 1; margin: 0; color: var(--hkm-sc-muted); font-size: 0.875rem; line-height: 1.5; }
2655
+ .hkm-showcase .hkm-step-description:not([data-hkm-active]) { visibility: hidden; }
2656
+ .hkm-showcase .hkm-step-panel:focus-visible { outline: 2px solid var(--hkm-accent); outline-offset: -4px; }
2657
+ /* A strip too narrow for every label keeps the selected label and shows
2658
+ * numbers for the rest; each label stays the tab's accessible name. */
2659
+ .hkm-showcase.hkm-steps .hkm-step-tabs[data-hkm-compact] .hkm-tab:not([aria-selected="true"]) { flex: none; justify-content: center; padding-inline: 12px; }
2660
+ .hkm-showcase.hkm-steps .hkm-step-tabs[data-hkm-compact] .hkm-tab:not([aria-selected="true"]) .hkm-step-copy { display: none; }
2661
+ @container hkm-showcase (max-width: 519px) {
2662
+ .hkm-showcase.hkm-steps .hkm-step-nav { grid-template-columns: 40px minmax(0, 1fr) 40px; gap: 4px; }
2663
+ .hkm-showcase.hkm-steps .hkm-step-button { inline-size: 40px; min-inline-size: 40px; }
2664
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab { gap: 8px; padding-inline: 12px; font-size: 0.875rem; }
2665
+ }
2666
+ @container hkm-showcase (min-width: 720px) {
2667
+ .hkm-showcase .hkm-step-layout { grid-template-columns: minmax(200px, 0.72fr) minmax(0, 1.6fr); grid-template-areas: "controls stage"; align-items: stretch; }
2668
+ .hkm-showcase.hkm-steps .hkm-step-controls { margin-block-end: 0; margin-inline-end: -1px; }
2669
+ .hkm-showcase.hkm-steps .hkm-step-stage { align-self: stretch; border-start-start-radius: 0; }
2670
+ .hkm-showcase.hkm-steps .hkm-step-nav { grid-template-areas: "tabs tabs tabs" "back count next"; align-items: center; row-gap: 14px; column-gap: 6px; }
2671
+ .hkm-showcase.hkm-steps .hkm-step-button { margin: 0; }
2672
+ .hkm-showcase .hkm-step-count { display: block; }
2673
+ .hkm-showcase.hkm-steps .hkm-step-tabs { flex-direction: column; gap: 2px; overflow: visible; }
2674
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab { display: grid; flex: none; grid-template-columns: 2.5ch minmax(0, 1fr); align-items: start; gap: 12px; padding: 14px 16px; border: 1px solid transparent; border-inline-end: 0; border-radius: var(--hkm-step-tab-radius) 0 0 var(--hkm-step-tab-radius); white-space: normal; }
2675
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab[aria-selected="true"] { border-color: var(--hkm-sc-line); }
2676
+ .hkm-showcase.hkm-steps .hkm-step-number { padding-block-start: 0.1rem; }
2677
+ .hkm-showcase .hkm-step-label { overflow: visible; }
2678
+ .hkm-showcase .hkm-step-hint { display: block; }
2679
+ .hkm-showcase .hkm-step-descriptions { display: none; }
2680
+ }
2575
2681
  .hkm-showcase.hkm-steps .hkm-step-announcement { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
2576
2682
  .hkm-showcase:is(.hkm-steps, .hkm-modes) .hkm-showcase-caption { margin-block-start: 1rem; }
2577
2683
 
@@ -2679,7 +2785,8 @@
2679
2785
 
2680
2786
  .hkm-showcase .hkm-tab[aria-selected="true"],
2681
2787
  .hkm-showcase .hkm-segmented button[aria-pressed="true"],
2682
- .hkm-showcase .hkm-tab[data-hkm-done] .hkm-step-number,
2788
+ .hkm-showcase.hkm-steps .hkm-tab[aria-selected="true"],
2789
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab[aria-selected="true"],
2683
2790
  .hkm-root .hkm-popover-item[data-hkm-selected],
2684
2791
  .hkm-root .hkm-work-mark {
2685
2792
  forced-color-adjust: none;
@@ -2687,6 +2794,19 @@
2687
2794
  color: HighlightText;
2688
2795
  }
2689
2796
 
2797
+ .hkm-showcase.hkm-steps .hkm-tab[aria-selected="true"] :is(.hkm-step-number, .hkm-step-hint) {
2798
+ color: HighlightText;
2799
+ }
2800
+
2801
+ /* Fill stages reserve their authored field for the live preview. Keep the
2802
+ * selected tab in the system pair even when that fill rule is authored by
2803
+ * a consumer layer with a stronger declaration. */
2804
+ .hkm-showcase[data-hkm-fit="fill"].hkm-steps .hkm-tab[aria-selected="true"] {
2805
+ forced-color-adjust: none;
2806
+ background: Highlight !important;
2807
+ color: HighlightText !important;
2808
+ }
2809
+
2690
2810
  .hkm-showcase .hkm-segmented button[aria-pressed="true"]:disabled {
2691
2811
  background: ButtonFace;
2692
2812
  color: GrayText;
@@ -2708,3 +2828,18 @@
2708
2828
  }
2709
2829
  }
2710
2830
  }
2831
+
2832
+ /* Consumer layers and palette variable chains can add stronger declarations
2833
+ * after the legacy component layer. Keep the system selection pair as the
2834
+ * final forced-colors contract. */
2835
+ @media (forced-colors: active) {
2836
+ .hkm-showcase.hkm-steps .hkm-step-tabs .hkm-tab[aria-selected="true"] {
2837
+ forced-color-adjust: none;
2838
+ background: Highlight !important;
2839
+ color: HighlightText !important;
2840
+ }
2841
+
2842
+ .hkm-showcase.hkm-steps .hkm-tab[aria-selected="true"] :is(.hkm-step-number, .hkm-step-hint) {
2843
+ color: HighlightText !important;
2844
+ }
2845
+ }