@hraness/design-kit 0.36.5 → 0.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/design-kit",
3
- "version": "0.36.5",
3
+ "version": "0.38.0",
4
4
  "description": "React components and CSS for Hraness apps and product sites: application shells, marketing sections, charts, themes, effects, and syntax highlighting. Built on @hraness/ui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -323,7 +323,7 @@
323
323
  "test": "bun test --preload ./scripts/register-stylex-test-transform.ts ./src",
324
324
  "browser:install": "bun ./node_modules/playwright-core/cli.js install chromium",
325
325
  "test:browser": "bun run test:browser:marketing && bun run test:browser:rest",
326
- "test:browser:marketing": "bun run ./scripts/product-marketing-browser.ts && bun run ./scripts/marketing-header-browser.ts",
326
+ "test:browser:marketing": "bun run ./scripts/product-marketing-browser.ts",
327
327
  "test:browser:rest": "bun run ./scripts/site-shell-browser.ts && bun run ./scripts/palette-browser.ts && bun run ./scripts/gallery-browser.ts && bun run ./scripts/global-error-browser.ts && bun run ./scripts/security-delivery-browser.ts && bun run ./scripts/paper-theme-browser.ts && bun run ./scripts/product-marketing-preset-browser.ts && bun run ./scripts/lantern-material-browser.ts && bun run ./scripts/foil-browser.ts && bun run ./scripts/hero-light-browser.ts && bun run ./scripts/status-page-browser.ts && bun run ./scripts/platform-install-browser.ts && bun run ./scripts/mockups-browser.ts && bun run ./scripts/agent-setup-browser.ts",
328
328
  "test:package": "bun run ./scripts/package-smoke.ts && bun run ./scripts/mockups-import.ts",
329
329
  "kb:refresh": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.24.0/hraness-wordcell-0.24.0.tgz wordcell refresh --root kb",
@@ -6,7 +6,7 @@
6
6
  "name": "@hraness/design-kit",
7
7
  "path": ".",
8
8
  "visibility": "public",
9
- "version": "0.36.5"
9
+ "version": "0.38.0"
10
10
  }
11
11
  ],
12
12
  "contract": "hraness.portfolio-inventory/v1",
@@ -15,4 +15,5 @@
15
15
  @import "./status-page.css";
16
16
  @import "./product-marketing-foundation.css";
17
17
  @import "./design-gallery.css";
18
+ @import "./mockups.css";
18
19
  @import "./lantern-material.css";
@@ -7,6 +7,18 @@
7
7
  }
8
8
 
9
9
  @layer components.hraness-design-kit.legacy {
10
+ .design-gallery__walkthrough-preview { display: grid; gap: 1rem; padding: 1.5rem; font-size: 0.875rem; }
11
+ .design-gallery__walkthrough-context { margin: 0; color: var(--hkm-muted); font-size: 0.75rem; letter-spacing: 0.06em; }
12
+ .design-gallery__walkthrough-title { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0; font-size: 1.125rem; font-weight: 600; }
13
+ .design-gallery__walkthrough-state { padding: 0.25rem 0.5rem; border: 1px solid var(--hkm-line); border-radius: 0.375rem; color: var(--hkm-muted); font-size: 0.75rem; font-weight: 500; }
14
+ .design-gallery__walkthrough-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 0; }
15
+ .design-gallery__walkthrough-facts dt { margin-block-end: 0.25rem; color: var(--hkm-muted); font-size: 0.75rem; }
16
+ .design-gallery__walkthrough-facts dd { margin: 0; }
17
+ .design-gallery__walkthrough-checks { margin: 0; padding: 0; list-style: none; }
18
+ .design-gallery__walkthrough-checks li { display: flex; justify-content: space-between; gap: 1rem; padding-block: 0.75rem; border-block-start: 1px solid var(--hkm-line); }
19
+ .design-gallery__walkthrough-checks li > :last-child { color: var(--hkm-muted); }
20
+ .design-gallery__walkthrough-result { display: grid; gap: 0.375rem; padding: 1rem; border: 1px solid var(--hkm-line); border-radius: 0.625rem; background: var(--hkm-surface); }
21
+ .design-gallery__walkthrough-result > span { color: var(--hkm-muted); font-size: 0.75rem; }
10
22
  .design-gallery__lantern-intro {
11
23
  margin: 0;
12
24
  max-inline-size: 60ch;
@@ -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 (