@hraness/design-kit 0.39.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stylex.css CHANGED
@@ -4031,6 +4031,10 @@
4031
4031
  .x1k5gbb1.x1k5gbb1 {
4032
4032
  color: highlighttext;
4033
4033
  }
4034
+
4035
+ .x1ljrylj.x1ljrylj {
4036
+ color: linktext;
4037
+ }
4034
4038
  }
4035
4039
 
4036
4040
  @media (width <= 48rem) {
package/dist/testing.js CHANGED
@@ -237,14 +237,34 @@ function assertFakeHandles(html, allowedHandles, label = "markup") {
237
237
  fail(`${label} links to ${match[1] ?? ""}, which is not a reserved example domain.`);
238
238
  }
239
239
  }
240
+ var WALKTHROUGH_COPY_LIMITS = Object.freeze({
241
+ labelWords: 3,
242
+ hintCharacters: 60
243
+ });
244
+ function assertWalkthroughCopy(steps, label = "walkthrough") {
245
+ for (const step of steps) {
246
+ const words = step.label.trim().split(/\s+/u).filter(Boolean).length;
247
+ if (words === 0 || words > WALKTHROUGH_COPY_LIMITS.labelWords)
248
+ fail(`${label} step "${step.id}" label has ${String(words)} words; use one to ${String(WALKTHROUGH_COPY_LIMITS.labelWords)}.`);
249
+ const hint = step.hint?.trim();
250
+ if (hint === undefined || hint === "")
251
+ continue;
252
+ if (hint.length > WALKTHROUGH_COPY_LIMITS.hintCharacters)
253
+ fail(`${label} step "${step.id}" hint is ${String(hint.length)} characters; keep it within ${String(WALKTHROUGH_COPY_LIMITS.hintCharacters)}.`);
254
+ if ((hint.match(/[.!?](\s|$)/gu) ?? []).length > 1)
255
+ fail(`${label} step "${step.id}" hint has more than one sentence.`);
256
+ }
257
+ }
240
258
  export {
241
259
  stripMockupSamples,
242
260
  renderMatrix,
243
261
  htmlText,
244
262
  blogConformance,
263
+ assertWalkthroughCopy,
245
264
  assertRoleImgWithLabel,
246
265
  assertNoHeadings,
247
266
  assertFakeHandles,
267
+ WALKTHROUGH_COPY_LIMITS,
248
268
  ConformanceError,
249
269
  BLOG_CONFORMANCE_LIMITS
250
270
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/design-kit",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
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",
@@ -6,7 +6,7 @@
6
6
  "name": "@hraness/design-kit",
7
7
  "path": ".",
8
8
  "visibility": "public",
9
- "version": "0.39.0"
9
+ "version": "0.40.1"
10
10
  }
11
11
  ],
12
12
  "contract": "hraness.portfolio-inventory/v1",
@@ -373,17 +373,61 @@ export type ThroughStep = Readonly<{
373
373
  }>;
374
374
 
375
375
  /** Fill owns the outer shell and its top-level frames, never nested width-fit graphics. */
376
- function fillFrames(stage: HTMLElement): HTMLElement[] {
377
- const frames: HTMLElement[] = [];
376
+ function panelFillFrames(stage: HTMLElement): HTMLElement[][] {
377
+ const panels: HTMLElement[][] = [];
378
378
  for (const panel of stage.querySelectorAll<HTMLElement>(":scope > .hkm-step-panel, :scope > .hkm-mode-surface")) {
379
379
  const fit = panel.querySelector<HTMLElement>(":scope > .hkm-fit");
380
380
  if (fit === null) continue;
381
+ const frames: HTMLElement[] = [];
381
382
  for (const root of fit.querySelectorAll<HTMLElement>(".hkm-root")) {
382
383
  const enclosingRoot = root.parentElement?.closest(".hkm-root");
383
384
  if (root.closest(".hkm-fit") === fit && (enclosingRoot === null || enclosingRoot === undefined || !fit.contains(enclosingRoot))) frames.push(root);
384
385
  }
386
+ panels.push(frames);
387
+ }
388
+ return panels;
389
+ }
390
+
391
+ function fillFrames(stage: HTMLElement): HTMLElement[] {
392
+ return panelFillFrames(stage).flat();
393
+ }
394
+
395
+ /**
396
+ * Mark each panel's frame, and the product wrappers between it and the fit,
397
+ * so a frame wrapped in product markup still grows to the full stage instead
398
+ * of leaving an empty band under a shorter slide. A panel with several
399
+ * top-level frames keeps its own arrangement.
400
+ */
401
+ function markFillFrames(stage: HTMLElement): void {
402
+ for (const frames of panelFillFrames(stage)) {
403
+ for (const frame of frames) frame.setAttribute("data-hkm-fill-frame", "");
404
+ const [frame] = frames;
405
+ if (frames.length !== 1 || frame === undefined) continue;
406
+ const path: HTMLElement[] = [];
407
+ for (let node = frame.parentElement; node !== null; node = node.parentElement) {
408
+ path.push(node);
409
+ if (node.classList.contains("hkm-fit-inner")) break;
410
+ }
411
+ // Only single-column wrappers become a stack; a side-by-side product
412
+ // layout keeps its own arrangement.
413
+ if (path.every(stacksVertically)) for (const node of path) node.setAttribute("data-hkm-fill-path", "");
414
+ }
415
+ }
416
+
417
+ function stacksVertically(node: HTMLElement): boolean {
418
+ if (node.hasAttribute("data-hkm-fill-path")) return true;
419
+ const style = getComputedStyle(node);
420
+ if (style.display === "block" || style.display === "flow-root") return true;
421
+ if (style.display === "grid") return style.gridTemplateColumns.trim().split(/\s+/u).length === 1;
422
+ if (style.display === "flex") return style.flexDirection.startsWith("column");
423
+ return false;
424
+ }
425
+
426
+ function clearFillFrames(stage: HTMLElement): void {
427
+ for (const node of stage.querySelectorAll("[data-hkm-fill-frame], [data-hkm-fill-path]")) {
428
+ node.removeAttribute("data-hkm-fill-frame");
429
+ node.removeAttribute("data-hkm-fill-path");
385
430
  }
386
- return frames;
387
431
  }
388
432
 
389
433
  function presentationBodies(stage: HTMLElement): HTMLElement[] {
@@ -413,7 +457,7 @@ function measureNestedFits(stage: HTMLElement): void {
413
457
  function fitShowcaseStage(stage: HTMLDivElement, minimumHeight: number): void {
414
458
  const owner = stage.parentElement;
415
459
  if (owner === null || stage.clientWidth <= 0) return;
416
- for (const frame of fillFrames(stage)) frame.setAttribute("data-hkm-fill-frame", "");
460
+ markFillFrames(stage);
417
461
  const probe = stage.cloneNode(true) as HTMLDivElement;
418
462
  probe.setAttribute("aria-hidden", "true");
419
463
  probe.setAttribute("inert", "");
@@ -501,7 +545,7 @@ function useFittedShowcaseStage(fit: "natural" | "fill", source: unknown, variat
501
545
  node.style.removeProperty("--hkm-showcase-fill-height");
502
546
  node.removeAttribute("data-hkm-fitted");
503
547
  for (const body of presentationBodies(node)) body.style.removeProperty("--hkm-terminal-presentation-size");
504
- for (const frame of fillFrames(node)) frame.removeAttribute("data-hkm-fill-frame");
548
+ clearFillFrames(node);
505
549
  };
506
550
  }, [fit, source, variation, minimumHeight]);
507
551
  return stage;
@@ -538,6 +582,14 @@ export function StepThrough({
538
582
  assertUniqueIds(steps, "StepThrough step");
539
583
  if (fit !== "natural" && fit !== "fill") throw new RangeError("StepThrough fit must be natural or fill.");
540
584
  const stage = useFittedShowcaseStage(fit, steps);
585
+ // Natural walkthroughs fit by width only, but still stretch each slide's
586
+ // frame to the shared stage height. Fill walkthroughs mark frames while fitting.
587
+ useIsomorphicLayoutEffect(() => {
588
+ const node = stage.current;
589
+ if (fit === "fill" || node === null) return undefined;
590
+ markFillFrames(node);
591
+ return () => clearFillFrames(node);
592
+ }, [fit, steps]);
541
593
  const captionText = optionalText(caption, "StepThrough");
542
594
  const id = useId();
543
595
  const [index, setIndex] = useState(() => Math.max(0, steps.findIndex((step) => step.id === initial)));
package/src/mockups.css CHANGED
@@ -2593,11 +2593,37 @@
2593
2593
  min-block-size: 0;
2594
2594
  }
2595
2595
 
2596
- .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-frame] > .hkm-window:has(> .hkm-page) {
2596
+ /* A slide never leaves an empty band under a shorter frame. Each panel's
2597
+ * single top-level frame grows to the stage through any product wrappers
2598
+ * (marked as the fill path), and its window body takes the remaining height. */
2599
+ /* Important so a product wrapper's own single-column grid or block layout
2600
+ * cannot leave the extra height in its rows instead of the frame. */
2601
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-path] {
2602
+ display: flex !important;
2603
+ flex-direction: column !important;
2604
+ }
2605
+
2606
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-path] > :is([data-hkm-fill-path], [data-hkm-fill-frame]) {
2607
+ flex: 1 1 auto;
2608
+ block-size: auto;
2609
+ min-block-size: 0;
2610
+ }
2611
+
2612
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-path] > [data-hkm-fill-frame] > .hkm-window {
2613
+ block-size: 100%;
2614
+ }
2615
+
2616
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-frame] > .hkm-window:has(> .hkm-page),
2617
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-path] > [data-hkm-fill-frame] > .hkm-window {
2597
2618
  display: flex;
2598
2619
  flex-direction: column;
2599
2620
  }
2600
2621
 
2622
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-path] > [data-hkm-fill-frame] > .hkm-window > :last-child:not(.hkm-title-bar, .hkm-browser-bar) {
2623
+ flex: 1 1 auto;
2624
+ min-block-size: 0;
2625
+ }
2626
+
2601
2627
  .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-frame] > .hkm-window > .hkm-page {
2602
2628
  flex: 1 1 auto;
2603
2629
  block-size: auto;
@@ -2605,10 +2631,35 @@
2605
2631
  max-block-size: none;
2606
2632
  }
2607
2633
 
2608
- .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-frame] > .hkm-window > :is(.hkm-title-bar, .hkm-browser-bar) {
2634
+ .hkm-showcase :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) [data-hkm-fill-frame] > .hkm-window > :is(.hkm-title-bar, .hkm-browser-bar) {
2609
2635
  flex: none;
2610
2636
  }
2611
2637
 
2638
+ /* The panel draws the frame, so a wrapped window drops its own edge too. */
2639
+ .hkm-showcase :is(.hkm-step-panel, .hkm-mode-surface) [data-hkm-fill-path] > [data-hkm-fill-frame],
2640
+ .hkm-showcase :is(.hkm-step-panel, .hkm-mode-surface) [data-hkm-fill-path] > [data-hkm-fill-frame] > .hkm-window {
2641
+ border: 0;
2642
+ border-radius: 0;
2643
+ box-shadow: none;
2644
+ }
2645
+
2646
+ /* A walkthrough stage is at least as tall as its tallest slide and its step
2647
+ * list, so a selected step never hangs past the preview's edge. Natural
2648
+ * walkthroughs stretch their unscaled slides the same way. */
2649
+ .hkm-showcase.hkm-steps[data-hkm-fit="fill"] .hkm-step-stage:not([data-hkm-measuring]) {
2650
+ block-size: auto;
2651
+ min-block-size: var(--hkm-showcase-fill-height, 0px);
2652
+ }
2653
+
2654
+ .hkm-showcase.hkm-steps:not([data-hkm-fit="fill"]) .hkm-step-stage > .hkm-step-panel {
2655
+ align-self: stretch;
2656
+ }
2657
+
2658
+ .hkm-showcase.hkm-steps:not([data-hkm-fit="fill"]) .hkm-step-panel > .hkm-fit:not(:has(> [data-hkm-scaled])),
2659
+ .hkm-showcase.hkm-steps:not([data-hkm-fit="fill"]) .hkm-step-panel > .hkm-fit > .hkm-fit-inner:not([data-hkm-scaled]) {
2660
+ block-size: 100%;
2661
+ }
2662
+
2612
2663
  /* A walkthrough is one tabbed selector: the steps and the preview share an
2613
2664
  * edge. Compact layouts attach a tab strip to the preview's top edge between
2614
2665
  * Back and Next. Wide layouts stack the steps on its start edge, flush with
@@ -25,7 +25,7 @@
25
25
  flex: 1 0 auto;
26
26
  }
27
27
 
28
- :where(.plain-site.plain-publication a:not(.hraness-design-skip-link)) {
28
+ :where(.plain-site.plain-publication a:not(.hraness-design-skip-link)):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
29
29
  color: var(--plain-link);
30
30
  overflow-wrap: anywhere;
31
31
  text-decoration: none;
@@ -36,12 +36,12 @@
36
36
  :where(
37
37
  .plain-site.plain-publication
38
38
  a:not(.hraness-design-skip-link):is(:hover, :focus-visible)
39
- ) {
39
+ ):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
40
40
  text-decoration-line: underline;
41
41
  text-decoration-style: dotted;
42
42
  }
43
43
 
44
- :where(.plain-site.plain-publication a:not(.hraness-design-skip-link):focus-visible) {
44
+ :where(.plain-site.plain-publication a:not(.hraness-design-skip-link):focus-visible):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
45
45
  border-radius: 1px;
46
46
  outline: 1px dotted currentColor;
47
47
  outline-offset: 3px;
@@ -50,7 +50,7 @@
50
50
  :where(
51
51
  .plain-site.plain-publication
52
52
  a:not(.hraness-design-skip-link):focus:not(:focus-visible)
53
- ) {
53
+ ):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
54
54
  outline: none;
55
55
  }
56
56
 
@@ -726,6 +726,7 @@
726
726
  }
727
727
 
728
728
  @media (forced-colors: none) {
729
+ .plain-site.plain-publication.plain-publication--embedded .plain-publication__byline a:is(:hover, :focus-visible),
729
730
  .plain-site.plain-publication.plain-publication--embedded .plain-publication__article-body a:is(:hover, :focus-visible) {
730
731
  text-decoration-color: currentColor;
731
732
  }
@@ -111,7 +111,7 @@
111
111
  box-sizing: border-box;
112
112
  }
113
113
 
114
- :where(.plain-header a, .plain-page a, .plain-footer a) {
114
+ :where(.plain-header a, .plain-page a, .plain-footer a):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
115
115
  color: var(--plain-link);
116
116
  overflow-wrap: anywhere;
117
117
  text-decoration: none;
@@ -123,7 +123,7 @@
123
123
  .plain-header a:is(:hover, :focus-visible),
124
124
  .plain-page a:is(:hover, :focus-visible),
125
125
  .plain-footer a:is(:hover, :focus-visible)
126
- ) {
126
+ ):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
127
127
  text-decoration: underline;
128
128
  }
129
129
 
@@ -131,7 +131,7 @@
131
131
  .plain-header a:focus-visible,
132
132
  .plain-page a:focus-visible,
133
133
  .plain-footer a:focus-visible
134
- ) {
134
+ ):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
135
135
  border-radius: 1px;
136
136
  outline: 1px dotted currentColor;
137
137
  outline-offset: 3px;
@@ -141,7 +141,7 @@
141
141
  .plain-header a:focus:not(:focus-visible),
142
142
  .plain-page a:focus:not(:focus-visible),
143
143
  .plain-footer a:focus:not(:focus-visible)
144
- ) {
144
+ ):not(:where(.hraness-marketing-account__primary, .hraness-marketing-account__sign-in)) {
145
145
  outline: none;
146
146
  }
147
147
 
@@ -309,6 +309,16 @@ export function DesignSystemGallery({
309
309
  </p>
310
310
  </div>
311
311
  </div>
312
+ <div
313
+ aria-label="Plain site account actions"
314
+ className="design-gallery__plain-theme plain-site plain-publication"
315
+ >
316
+ <div className="plain-page">
317
+ <MarketingAccount id="plain-account" summary="Keep your preferences across devices.">
318
+ <MarketingAccountActions primary={{ href: "#plain-account", label: "Create account" }} signIn={{ href: "#plain-account" }} />
319
+ </MarketingAccount>
320
+ </div>
321
+ </div>
312
322
  </section>
313
323
 
314
324
  <section className="design-gallery__section" id="paper-theme">
@@ -468,9 +478,9 @@ export function DesignSystemGallery({
468
478
  </MarketingSection>
469
479
  <MarketingSection heading="Show the result at each step." headingId="gallery-walkthrough" headingLevel={3} label="Walkthrough" summary="Use short labels and visible explanations beside a compact illustration of what changes.">
470
480
  <StepThrough fit="fill" label="Run a Relay job" steps={[
471
- { id: "choose", label: "Choose a job", hint: "Pick a saved job and review the file it reads before starting the run.", render: () => <GalleryJobPreview state="ready" /> },
472
- { id: "run", label: "Watch the run", hint: "See the input check, transformation, and saved output as separate stages.", render: () => <GalleryJobPreview state="running" /> },
473
- { id: "inspect", label: "Read the result", hint: "Open the saved report with the run ID and source file beside it.", render: () => <GalleryJobPreview state="saved" /> },
481
+ { id: "choose", label: "Choose a job", hint: "Pick a saved job and check the file it reads.", render: () => <GalleryJobPreview state="ready" /> },
482
+ { id: "run", label: "Watch the run", hint: "The input check, the transform, and the saved output.", render: () => <GalleryJobPreview state="running" /> },
483
+ { id: "inspect", label: "Read the result", hint: "Open the report beside its run ID and source.", render: () => <GalleryJobPreview state="saved" /> },
474
484
  ]} />
475
485
  </MarketingSection>
476
486
  <MarketingPrimitives
@@ -56,7 +56,7 @@ export const marketingAccountStyles = stylex.create({
56
56
  },
57
57
  signIn: {
58
58
  alignItems: "center",
59
- color: "var(--foreground)",
59
+ color: { default: "var(--foreground)", "@media (forced-colors: active)": "LinkText" },
60
60
  display: "inline-flex",
61
61
  fontFamily: "var(--font-text)",
62
62
  fontSize: "1.0625rem",
@@ -67,7 +67,7 @@ export const marketingAccountStyles = stylex.create({
67
67
  outlineOffset: { default: null, ":focus-visible": "3px" },
68
68
  textDecorationLine: "underline",
69
69
  textDecorationStyle: "dotted",
70
- textDecorationColor: { default: "color-mix(in srgb, currentColor 45%, transparent)", ":hover": "currentColor", "@media (forced-colors: active)": "LinkText" },
70
+ textDecorationColor: { default: "color-mix(in srgb, currentColor 45%, transparent)", ":hover": "currentColor", ":focus-visible": "currentColor", "@media (forced-colors: active)": "LinkText" },
71
71
  textDecorationThickness: "1px",
72
72
  textUnderlineOffset: "0.22em",
73
73
  },
package/src/testing.ts CHANGED
@@ -305,3 +305,22 @@ export function assertFakeHandles(html: string, allowedHandles: readonly string[
305
305
  if (!RESERVED_HOST.test(match[1] ?? "")) fail(`${label} links to ${match[1] ?? ""}, which is not a reserved example domain.`);
306
306
  }
307
307
  }
308
+
309
+ /** Limits from MARKETING_COPY.md for `StepThrough` steps. */
310
+ export const WALKTHROUGH_COPY_LIMITS = Object.freeze({ labelWords: 3, hintCharacters: 60 });
311
+
312
+ /**
313
+ * Walkthrough labels name a step in at most three words, and each hint is one
314
+ * sentence of at most 60 characters, so the wide step list stays no taller
315
+ * than the illustration it sits beside.
316
+ */
317
+ export function assertWalkthroughCopy(steps: readonly Readonly<{ id: string; label: string; hint?: string }>[], label = "walkthrough"): void {
318
+ for (const step of steps) {
319
+ const words = step.label.trim().split(/\s+/u).filter(Boolean).length;
320
+ if (words === 0 || words > WALKTHROUGH_COPY_LIMITS.labelWords) fail(`${label} step "${step.id}" label has ${String(words)} words; use one to ${String(WALKTHROUGH_COPY_LIMITS.labelWords)}.`);
321
+ const hint = step.hint?.trim();
322
+ if (hint === undefined || hint === "") continue;
323
+ if (hint.length > WALKTHROUGH_COPY_LIMITS.hintCharacters) fail(`${label} step "${step.id}" hint is ${String(hint.length)} characters; keep it within ${String(WALKTHROUGH_COPY_LIMITS.hintCharacters)}.`);
324
+ if ((hint.match(/[.!?](\s|$)/gu) ?? []).length > 1) fail(`${label} step "${step.id}" hint has more than one sentence.`);
325
+ }
326
+ }