@hraness/design-kit 0.35.2 → 0.36.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 (34) hide show
  1. package/MARKETING_COPY.md +1 -0
  2. package/README.md +57 -7
  3. package/dist/{chunk-vst6p4wd.js → chunk-sabcr66p.js} +68 -0
  4. package/dist/{chunk-fmeg5cxf.js → chunk-thv5t11w.js} +1 -1
  5. package/dist/{chunk-4tgt8egt.js → chunk-v8byd3hw.js} +106 -23
  6. package/dist/index.js +2 -2
  7. package/dist/mockups/client.js +249 -91
  8. package/dist/mockups/index.js +4 -0
  9. package/dist/provider-marks.js +1 -1
  10. package/dist/react/index.js +58 -3
  11. package/dist/react/server.js +2 -2
  12. package/dist/stylex-manifest.json +1 -1
  13. package/dist/stylex.css +30 -0
  14. package/package.json +1 -1
  15. package/portfolio-inventory.json +1 -1
  16. package/src/mockups/client.tsx +187 -63
  17. package/src/mockups/frames.tsx +4 -2
  18. package/src/mockups.css +58 -7
  19. package/src/product-marketing-foundation.css +12 -0
  20. package/src/product-marketing.css +59 -0
  21. package/src/provider-marks.generated.ts +20 -0
  22. package/src/provider-marks.ts +5 -0
  23. package/src/react/design-gallery.tsx +11 -0
  24. package/src/react/product-marketing.stylex.ts +50 -2
  25. package/src/react/product-marketing.tsx +38 -28
  26. package/vendor/provider-marks/BEEPER-NOTICE.md +16 -0
  27. package/vendor/provider-marks/SIMPLE-ICONS-LICENSE.md +30 -0
  28. package/vendor/provider-marks/UPSTREAM.md +24 -0
  29. package/vendor/provider-marks/beeper-glyph.svg +3 -0
  30. package/vendor/provider-marks/beeper.svg +16 -0
  31. package/vendor/provider-marks/lobehub/ollama.svg +1 -0
  32. package/vendor/provider-marks/lobehub/vercel.svg +1 -0
  33. package/vendor/provider-marks/simple-icons/imessage.svg +1 -0
  34. package/vendor/provider-marks/simple-icons/whatsapp.svg +1 -0
package/dist/stylex.css CHANGED
@@ -109,6 +109,10 @@
109
109
  padding: 0;
110
110
  }
111
111
 
112
+ .x1aetswf {
113
+ padding: 1.125rem;
114
+ }
115
+
112
116
  .x194jd1b {
113
117
  padding: 1.1rem 7rem 1rem 1rem;
114
118
  }
@@ -1084,6 +1088,10 @@
1084
1088
  accent-color: var(--primary, Highlight);
1085
1089
  }
1086
1090
 
1091
+ .xc26acl {
1092
+ align-content: center;
1093
+ }
1094
+
1087
1095
  .xcdzlcm {
1088
1096
  align-content: space-between;
1089
1097
  }
@@ -1675,6 +1683,10 @@
1675
1683
  block-size: 1rem;
1676
1684
  }
1677
1685
 
1686
+ .x1vlpzvb {
1687
+ block-size: 3.5rem;
1688
+ }
1689
+
1678
1690
  .x18o2qet {
1679
1691
  block-size: 4.5rem;
1680
1692
  }
@@ -2523,6 +2535,10 @@
2523
2535
  grid-template-columns: 2.25rem minmax(0, 1fr);
2524
2536
  }
2525
2537
 
2538
+ .xrdwe0 {
2539
+ grid-template-columns: 3.5rem minmax(0, 1fr);
2540
+ }
2541
+
2526
2542
  .xx9t13c {
2527
2543
  grid-template-columns: auto minmax(0, 1fr) auto;
2528
2544
  }
@@ -2631,6 +2647,10 @@
2631
2647
  inline-size: 2rem;
2632
2648
  }
2633
2649
 
2650
+ .x1pf0uk5 {
2651
+ inline-size: 3.5rem;
2652
+ }
2653
+
2634
2654
  .x6qm275 {
2635
2655
  inline-size: 4.5rem;
2636
2656
  }
@@ -2966,6 +2986,10 @@
2966
2986
  max-block-size: min(38rem, 75dvh);
2967
2987
  }
2968
2988
 
2989
+ .x1vj640n {
2990
+ max-block-size: none;
2991
+ }
2992
+
2969
2993
  .xgyk9h7 {
2970
2994
  max-inline-size: 100%;
2971
2995
  }
@@ -4101,6 +4125,12 @@
4101
4125
  }
4102
4126
  }
4103
4127
 
4128
+ @media (width >= 64rem) {
4129
+ .x13f99nf.x13f99nf {
4130
+ grid-template-columns: minmax(0, 1fr) minmax(0, .8fr);
4131
+ }
4132
+ }
4133
+
4104
4134
  @media (width >= 50rem) {
4105
4135
  .x2k1869.x2k1869 {
4106
4136
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/design-kit",
3
- "version": "0.35.2",
3
+ "version": "0.36.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",
@@ -6,7 +6,7 @@
6
6
  "name": "@hraness/design-kit",
7
7
  "path": ".",
8
8
  "visibility": "public",
9
- "version": "0.35.2"
9
+ "version": "0.36.0"
10
10
  }
11
11
  ],
12
12
  "contract": "hraness.portfolio-inventory/v1",
@@ -6,6 +6,7 @@ import {
6
6
  type ReactNode,
7
7
  useId,
8
8
  useLayoutEffect,
9
+ useMemo,
9
10
  useRef,
10
11
  useState,
11
12
  } from "react";
@@ -145,8 +146,10 @@ export type ModeShowcaseProps<S extends string, M extends string, O extends stri
145
146
  status?: (state: Readonly<{ surface: S; mode: M; option: O | undefined }>) => ReactNode;
146
147
  /** Optional context that adds to the visual. Omit repeated descriptions or generic disclaimers. */
147
148
  caption?: string;
148
- /** Page-window height in CSS pixels at full scale. */
149
+ /** Page-window cap in natural mode; minimum complete stage height in fill mode. */
149
150
  height?: number;
151
+ /** Reserve every authored choice at readable size, up to 128 combinations. */
152
+ fit?: "natural" | "fill";
150
153
  /** Narrowest layout width before the stage scales down. */
151
154
  minWidth?: number;
152
155
  theme?: MockupTheme;
@@ -164,6 +167,7 @@ export type ModeShowcaseProps<S extends string, M extends string, O extends stri
164
167
  export function ModeShowcase<S extends string, M extends string, O extends string = string>({
165
168
  caption,
166
169
  className,
170
+ fit = "natural",
167
171
  height = 440,
168
172
  initial,
169
173
  label,
@@ -181,7 +185,9 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
181
185
  assertUniqueIds(modes, "ModeShowcase mode");
182
186
  if (options !== undefined) assertUniqueIds(options, "ModeShowcase option");
183
187
  const captionText = optionalText(caption, "ModeShowcase");
184
- if (!(height > 0)) throw new RangeError("ModeShowcase height must be positive.");
188
+ if (!Number.isFinite(height) || !(height > 0)) throw new RangeError("ModeShowcase height must be finite and positive.");
189
+ if (fit !== "natural" && fit !== "fill") throw new RangeError("ModeShowcase fit must be natural or fill.");
190
+ if (fit === "fill" && surfaces.length * modes.length * (options?.length ?? 1) > 128) throw new RangeError("ModeShowcase fill supports up to 128 surface, mode, and option combinations.");
185
191
 
186
192
  const id = useId();
187
193
  const firstSurface = surfaces.find((surface) => surface.id === initial?.surface) ?? itemAt(surfaces, 0, "ModeShowcase surfaces");
@@ -199,6 +205,17 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
199
205
  const optionChoice = options?.find((entry) => entry.id === option);
200
206
  const optionInactive = optionInactiveModes.includes(mode);
201
207
 
208
+ // Pure, nonanimated authored states reserve one maximum before any choice.
209
+ // They stay hidden and inert; only a detached measurement clone reveals them.
210
+ 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])}>
212
+ <div className="hkm-fit"><div className="hkm-fit-inner">
213
+ {entry.render({ animated: false, mode: choice.id, option: optionChoice.id, previousMode: choice.id, theme })}
214
+ </div></div>
215
+ </div>
216
+ )))), [fit, modes, options, surfaces, theme]);
217
+ const stage = useFittedShowcaseStage(fit, measurements, JSON.stringify([mode, option]), height);
218
+
202
219
  const chooseMode = (next: M) => {
203
220
  if (next === mode) return;
204
221
  setPreviousMode(mode);
@@ -229,73 +246,86 @@ export function ModeShowcase<S extends string, M extends string, O extends strin
229
246
  return (
230
247
  <figure
231
248
  aria-label={label?.(surface) ?? `Illustration of ${surface.label.toLowerCase()}`}
232
- className={joinMockupClasses("hkm-showcase", className)}
249
+ className={joinMockupClasses("hkm-showcase", "hkm-modes", className)}
250
+ data-hkm-fit={fit === "fill" ? fit : undefined}
233
251
  data-hkm-theme={theme}
234
252
  data-nosnippet=""
235
253
  >
236
- <div className="hkm-showcase-controls">
237
- {surfaces.length > 1 ? (
238
- <div aria-label="Surface" className="hkm-tabs" role="tablist">
239
- {surfaces.map((entry) => (
240
- <button
241
- aria-controls={`${id}-panel`}
242
- aria-selected={entry.id === surface.id}
243
- className="hkm-tab"
244
- id={`${id}-tab-${entry.id}`}
245
- key={entry.id}
246
- onClick={() => setSurfaceId(entry.id)}
247
- onKeyDown={onTabKey}
248
- ref={(element) => {
249
- if (element === null) tabs.current.delete(entry.id);
250
- else tabs.current.set(entry.id, element);
251
- }}
252
- role="tab"
253
- tabIndex={entry.id === surface.id ? 0 : -1}
254
- type="button"
255
- >
256
- {entry.label}
257
- </button>
258
- ))}
259
- </div>
260
- ) : null}
261
- <div className="hkm-showcase-settings">
262
- <div className="hkm-showcase-row">
263
- <span className="hkm-showcase-label" id={`${id}-mode`}>{modeLabel}</span>
264
- <div aria-labelledby={`${id}-mode`} className="hkm-segmented" role="group">
265
- {modes.map((entry) => (
266
- <button aria-pressed={entry.id === mode} key={entry.id} onClick={() => chooseMode(entry.id)} type="button">
267
- {entry.label}
268
- </button>
269
- ))}
270
- </div>
271
- </div>
272
- {options === undefined ? null : (
273
- <div className="hkm-showcase-row">
274
- <span className="hkm-showcase-label" id={`${id}-option`}>{optionLabel}</span>
275
- <div aria-labelledby={`${id}-option`} className="hkm-segmented" role="group">
276
- {options.map((entry) => (
277
- <button aria-pressed={entry.id === option} disabled={optionInactive} key={entry.id} onClick={() => chooseOption(entry.id)} type="button">
278
- {entry.label}
279
- </button>
280
- ))}
281
- </div>
282
- </div>
283
- )}
254
+ {surfaces.length > 1 ? (
255
+ <div aria-label="Surface" className="hkm-tabs hkm-folder-tabs" role="tablist">
256
+ {surfaces.map((entry) => (
257
+ <button
258
+ aria-controls={`${id}-panel`}
259
+ aria-selected={entry.id === surface.id}
260
+ className="hkm-tab"
261
+ id={`${id}-tab-${entry.id}`}
262
+ key={entry.id}
263
+ onClick={() => setSurfaceId(entry.id)}
264
+ onKeyDown={onTabKey}
265
+ ref={(element) => {
266
+ if (element === null) tabs.current.delete(entry.id);
267
+ else tabs.current.set(entry.id, element);
268
+ }}
269
+ role="tab"
270
+ tabIndex={entry.id === surface.id ? 0 : -1}
271
+ type="button"
272
+ >
273
+ {entry.label}
274
+ </button>
275
+ ))}
284
276
  </div>
285
- {hint === "" ? null : <p className="hkm-showcase-hint">{hint}</p>}
286
- </div>
277
+ ) : null}
287
278
  <div
288
279
  aria-labelledby={surfaces.length > 1 ? `${id}-tab-${surface.id}` : undefined}
289
- className="hkm-showcase-stage"
290
- data-hkm-animated={animated ? "" : undefined}
291
- data-hkm-from={animated ? previousMode : undefined}
280
+ className="hkm-showcase-stage hkm-mode-panel"
292
281
  id={`${id}-panel`}
293
282
  role={surfaces.length > 1 ? "tabpanel" : undefined}
294
283
  style={{ "--hkm-page-height": `${height}px` } as CSSProperties}
295
284
  >
296
- <FitToWidth minWidth={minWidth}>
297
- {surface.render({ animated, mode, option, previousMode, theme })}
298
- </FitToWidth>
285
+ <div className="hkm-showcase-controls">
286
+ <div className="hkm-showcase-settings">
287
+ <div className="hkm-showcase-row">
288
+ <span className="hkm-showcase-label" id={`${id}-mode`}>{modeLabel}</span>
289
+ <div aria-labelledby={`${id}-mode`} className="hkm-segmented" role="group">
290
+ {modes.map((entry) => (
291
+ <button aria-pressed={entry.id === mode} key={entry.id} onClick={() => chooseMode(entry.id)} type="button">
292
+ {entry.label}
293
+ </button>
294
+ ))}
295
+ </div>
296
+ </div>
297
+ {options === undefined ? null : (
298
+ <div className="hkm-showcase-row">
299
+ <span className="hkm-showcase-label" id={`${id}-option`}>{optionLabel}</span>
300
+ <div aria-labelledby={`${id}-option`} className="hkm-segmented" role="group">
301
+ {options.map((entry) => (
302
+ <button aria-pressed={entry.id === option} disabled={optionInactive} key={entry.id} onClick={() => chooseOption(entry.id)} type="button">
303
+ {entry.label}
304
+ </button>
305
+ ))}
306
+ </div>
307
+ </div>
308
+ )}
309
+ </div>
310
+ {hint === "" ? null : <p className="hkm-showcase-hint">{hint}</p>}
311
+ </div>
312
+ <div className="hkm-mode-stage" ref={stage}>
313
+ {measurements}
314
+ {surfaces.map((entry) => (
315
+ <div
316
+ aria-hidden={entry.id !== surface.id}
317
+ className="hkm-mode-surface"
318
+ data-hkm-animated={entry.id === surface.id && animated ? "" : undefined}
319
+ data-hkm-from={entry.id === surface.id && animated ? previousMode : undefined}
320
+ inert={entry.id !== surface.id}
321
+ key={entry.id}
322
+ >
323
+ <FitToWidth minWidth={fit === "fill" ? 1 : minWidth}>
324
+ {entry.render({ animated: entry.id === surface.id && animated, mode, option, previousMode, theme })}
325
+ </FitToWidth>
326
+ </div>
327
+ ))}
328
+ </div>
299
329
  </div>
300
330
  {!hasStatus && captionText === undefined ? null : (
301
331
  <figcaption className="hkm-showcase-caption">
@@ -320,6 +350,93 @@ export type ThroughStep = Readonly<{
320
350
  render: (state: Readonly<{ animated: boolean; theme: MockupTheme | undefined }>) => ReactNode;
321
351
  }>;
322
352
 
353
+ /** Measure complete mounted slides at a readable baseline, separately from fitted paint. */
354
+ function fitShowcaseStage(stage: HTMLDivElement, minimumHeight: number): void {
355
+ const owner = stage.parentElement;
356
+ if (owner === null || stage.clientWidth <= 0) return;
357
+ const probe = stage.cloneNode(true) as HTMLDivElement;
358
+ probe.setAttribute("aria-hidden", "true");
359
+ probe.setAttribute("inert", "");
360
+ probe.setAttribute("data-hkm-measuring", "");
361
+ probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;inset:0 auto auto 0;inline-size:${stage.getBoundingClientRect().width}px;block-size:auto;`;
362
+ for (const sentinel of probe.querySelectorAll("[data-hkm-font-sentinel]")) sentinel.remove();
363
+ for (const node of probe.querySelectorAll("[id]")) node.removeAttribute("id");
364
+ for (const node of probe.querySelectorAll("[data-hkm-animated]")) node.removeAttribute("data-hkm-animated");
365
+ for (const body of probe.querySelectorAll<HTMLElement>('[data-hkm-density="presentation"]')) body.style.removeProperty("--hkm-terminal-presentation-size");
366
+ for (const fixture of probe.querySelectorAll<HTMLElement>("[data-hkm-measurement]")) fixture.hidden = false;
367
+ owner.append(probe);
368
+ try {
369
+ const naturalHeight = Math.max(minimumHeight, Math.ceil(probe.getBoundingClientRect().height));
370
+ // The tallest complete slide establishes the floor. Fitted type never feeds
371
+ // back into it, and changing the active tab never requests a measurement.
372
+ stage.style.setProperty("--hkm-showcase-fill-height", `${naturalHeight}px`);
373
+ for (const fixture of probe.querySelectorAll("[data-hkm-measurement]")) fixture.remove();
374
+ probe.style.blockSize = `${naturalHeight}px`;
375
+ probe.removeAttribute("data-hkm-measuring");
376
+ const bodies = [...stage.querySelectorAll<HTMLElement>('[data-hkm-density="presentation"]')].filter((body) => body.closest("[data-hkm-measurement]") === null);
377
+ const copies = [...probe.querySelectorAll<HTMLElement>('[data-hkm-density="presentation"]')];
378
+ for (const [index, copy] of copies.entries()) {
379
+ const body = bodies[index];
380
+ if (body === undefined) continue;
381
+ const lines = [...copy.querySelectorAll<HTMLElement>(".hkm-terminal-line")];
382
+ const wrappedRows = (line: HTMLElement) => Math.ceil((line.getBoundingClientRect().height - 0.5) / Number.parseFloat(getComputedStyle(line).lineHeight));
383
+ const baselineRows = lines.map(wrappedRows);
384
+ let low = 1;
385
+ let high = 4;
386
+ for (let attempt = 0; attempt < 9; attempt += 1) {
387
+ const candidate = (low + high) / 2;
388
+ copy.style.setProperty("--hkm-terminal-presentation-size", `${candidate}rem`);
389
+ 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;
390
+ else high = candidate;
391
+ }
392
+ body.style.setProperty("--hkm-terminal-presentation-size", `${Math.floor(low * 1000) / 1000}rem`);
393
+ }
394
+ stage.setAttribute("data-hkm-fitted", "");
395
+ } finally {
396
+ probe.remove();
397
+ }
398
+ }
399
+
400
+ /** Share one measurement lifecycle across surface and walkthrough selectors. */
401
+ function useFittedShowcaseStage(fit: "natural" | "fill", source: unknown, variation = "", minimumHeight = 0) {
402
+ const stage = useRef<HTMLDivElement>(null);
403
+ useIsomorphicLayoutEffect(() => {
404
+ const node = stage.current;
405
+ if (fit !== "fill" || node === null || typeof ResizeObserver === "undefined") return undefined;
406
+ // A rem-sized box changes even when both the document and stage are fixed.
407
+ const fontSentinel = document.createElement("span");
408
+ fontSentinel.setAttribute("data-hkm-font-sentinel", "");
409
+ fontSentinel.setAttribute("aria-hidden", "true");
410
+ fontSentinel.inert = true;
411
+ fontSentinel.style.cssText = "position:absolute;inline-size:1rem;block-size:1rem;visibility:hidden;pointer-events:none;inset:0 auto auto 0;";
412
+ node.append(fontSentinel);
413
+ let previous = "";
414
+ let disposed = false;
415
+ const measure = () => {
416
+ if (disposed) return;
417
+ const key = `${node.clientWidth}/${getComputedStyle(document.documentElement).fontSize}`;
418
+ if (key === previous) return;
419
+ previous = key;
420
+ fitShowcaseStage(node, minimumHeight);
421
+ };
422
+ measure();
423
+ const observer = new ResizeObserver(measure);
424
+ observer.observe(node);
425
+ observer.observe(document.documentElement);
426
+ observer.observe(fontSentinel);
427
+ void document.fonts.ready.then(() => { previous = ""; measure(); });
428
+ return () => {
429
+ disposed = true;
430
+ observer.disconnect();
431
+ fontSentinel.remove();
432
+ node.style.removeProperty("--hkm-showcase-fill-height");
433
+ node.removeAttribute("data-hkm-fitted");
434
+ for (const body of node.querySelectorAll<HTMLElement>('[data-hkm-density="presentation"]')) body.style.removeProperty("--hkm-terminal-presentation-size");
435
+ };
436
+ }, [fit, source, variation, minimumHeight]);
437
+ return stage;
438
+ }
439
+
323
440
  /**
324
441
  * Folder tabs and chevron controls walk a flow. All render functions stay mounted
325
442
  * to reserve the tallest panel; inactive panels are inert and visually hidden.
@@ -329,12 +446,15 @@ export function StepThrough({
329
446
  caption,
330
447
  className,
331
448
  initial,
449
+ fit = "natural",
332
450
  label = "Steps",
333
451
  minWidth = 400,
334
452
  steps,
335
453
  theme,
336
454
  }: Readonly<{
337
455
  steps: readonly ThroughStep[];
456
+ /** Fill the tallest natural slide and fit presentation terminals without scaling. */
457
+ fit?: "natural" | "fill";
338
458
  caption?: string;
339
459
  initial?: string;
340
460
  label?: string;
@@ -343,6 +463,8 @@ export function StepThrough({
343
463
  className?: string;
344
464
  }>) {
345
465
  assertUniqueIds(steps, "StepThrough step");
466
+ if (fit !== "natural" && fit !== "fill") throw new RangeError("StepThrough fit must be natural or fill.");
467
+ const stage = useFittedShowcaseStage(fit, steps);
346
468
  const captionText = optionalText(caption, "StepThrough");
347
469
  const id = useId();
348
470
  const [index, setIndex] = useState(() => Math.max(0, steps.findIndex((step) => step.id === initial)));
@@ -365,9 +487,9 @@ export function StepThrough({
365
487
  };
366
488
 
367
489
  return (
368
- <figure aria-label={`Illustration: ${label.toLowerCase()}`} className={joinMockupClasses("hkm-showcase", "hkm-steps", className)} data-hkm-theme={theme} data-nosnippet="">
490
+ <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="">
369
491
  <div className="hkm-showcase-controls">
370
- <div aria-label={label} className="hkm-tabs hkm-step-tabs" role="tablist">
492
+ <div aria-label={label} className="hkm-tabs hkm-folder-tabs hkm-step-tabs" role="tablist">
371
493
  {steps.map((entry, position) => (
372
494
  <button
373
495
  aria-controls={`${id}-panel-${entry.id}`}
@@ -392,7 +514,7 @@ export function StepThrough({
392
514
  ))}
393
515
  </div>
394
516
  </div>
395
- <div className="hkm-showcase-stage hkm-step-stage">
517
+ <div className="hkm-showcase-stage hkm-step-stage" ref={stage}>
396
518
  {steps.map((entry, position) => (
397
519
  <div
398
520
  aria-hidden={position !== current}
@@ -404,7 +526,9 @@ export function StepThrough({
404
526
  key={entry.id}
405
527
  role="tabpanel"
406
528
  >
407
- <FitToWidth minWidth={minWidth}>{entry.render({ animated: position === current && animated, theme })}</FitToWidth>
529
+ {fit === "fill"
530
+ ? <div className="hkm-fit"><div className="hkm-fit-inner">{entry.render({ animated: position === current && animated, theme })}</div></div>
531
+ : <FitToWidth minWidth={minWidth}>{entry.render({ animated: position === current && animated, theme })}</FitToWidth>}
408
532
  </div>
409
533
  ))}
410
534
  </div>
@@ -106,13 +106,15 @@ const TERMINAL_KINDS = new Set(["input", "output", "comment"]);
106
106
  * Commands and output are sample text; keep them to commands the product ships.
107
107
  */
108
108
  export function TerminalFrame({
109
+ density = "standard",
109
110
  fade,
110
111
  height,
111
112
  lines,
112
113
  prompt = "$",
113
114
  title = "Terminal",
114
115
  ...root
115
- }: MockupRootProps & Readonly<{ title?: string; prompt?: string; lines: readonly TerminalLine[]; height?: number; fade?: boolean }>) {
116
+ }: MockupRootProps & Readonly<{ title?: string; prompt?: string; lines: readonly TerminalLine[]; height?: number; fade?: boolean; density?: "standard" | "presentation" }>) {
117
+ if (density !== "standard" && density !== "presentation") throw new RangeError("Terminal density must be standard or presentation.");
116
118
  for (const line of lines) {
117
119
  if (!TERMINAL_KINDS.has(line.kind)) throw new TypeError(`Unknown terminal line kind ${JSON.stringify(line.kind)}.`);
118
120
  }
@@ -125,7 +127,7 @@ export function TerminalFrame({
125
127
  <span className="hkm-title">{title}</span>
126
128
  <span />
127
129
  </div>
128
- <div className="hkm-page hkm-terminal-body" data-hkm-fade={fades ? "" : undefined} style={pageStyle(height)}>
130
+ <div className="hkm-page hkm-terminal-body" data-hkm-density={density === "presentation" ? density : undefined} data-hkm-fade={fades ? "" : undefined} style={pageStyle(height)}>
129
131
  <div className="hkm-terminal-lines">
130
132
  {lines.map((line, index) => (
131
133
  <div className="hkm-terminal-line" data-hkm-beat={line.beat} data-hkm-line={line.kind} data-hkm-tone={line.tone} key={index}>
package/src/mockups.css CHANGED
@@ -333,6 +333,15 @@
333
333
  font-variant-ligatures: none;
334
334
  }
335
335
 
336
+ .hkm-root .hkm-terminal-body[data-hkm-density="presentation"] {
337
+ font-size: var(--hkm-terminal-presentation-size, 1rem);
338
+ line-height: 1.5;
339
+ }
340
+
341
+ .hkm-root .hkm-terminal-body[data-hkm-density="presentation"] .hkm-terminal-line {
342
+ min-block-size: 1.5em;
343
+ }
344
+
336
345
  .hkm-root .hkm-terminal-lines {
337
346
  display: grid;
338
347
  gap: 1px;
@@ -2503,24 +2512,66 @@
2503
2512
  }
2504
2513
  }
2505
2514
 
2506
- /* Folder tabs share an edge with the stable-height walkthrough stage. */
2507
- .hkm-showcase.hkm-steps { gap: 0; }
2515
+ /* Folder tabs share an edge with each showcase panel. */
2516
+ .hkm-showcase:is(.hkm-steps, .hkm-modes) { gap: 0; }
2508
2517
  .hkm-showcase.hkm-steps .hkm-showcase-controls { padding: 0; border: 0; border-radius: 0; background: transparent; }
2509
- .hkm-showcase.hkm-steps .hkm-step-tabs { gap: 6px; margin: 0; padding: 0; border: 0; position: relative; z-index: 1; }
2510
- .hkm-showcase.hkm-steps .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); }
2511
- .hkm-showcase.hkm-steps .hkm-tab[aria-selected="true"] { background: var(--hkm-sc-bg); color: var(--hkm-sc-fg); }
2512
- .hkm-showcase.hkm-steps .hkm-tab:hover { color: var(--hkm-sc-fg); }
2518
+ .hkm-showcase .hkm-folder-tabs { gap: 6px; margin: 0; padding: 0; border: 0; position: relative; z-index: 1; }
2519
+ .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
+ .hkm-showcase .hkm-folder-tabs .hkm-tab[aria-selected="true"] { background: var(--hkm-sc-bg); color: var(--hkm-sc-fg); }
2521
+ .hkm-showcase .hkm-folder-tabs .hkm-tab:hover { color: var(--hkm-sc-fg); }
2513
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; }
2523
+ .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
+ .hkm-showcase.hkm-modes .hkm-folder-tabs + .hkm-mode-panel { border-start-start-radius: 0; margin-block-start: -1px; }
2525
+ .hkm-showcase.hkm-modes .hkm-showcase-controls { border: 0; border-block-end: 1px solid var(--hkm-sc-line); border-radius: 0; }
2526
+ .hkm-mode-stage { display: grid; min-inline-size: 0; }
2527
+ .hkm-mode-surface { grid-area: 1 / 1; min-inline-size: 0; align-self: start; }
2528
+ .hkm-mode-surface[aria-hidden="true"] { visibility: hidden; pointer-events: none; }
2529
+ .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; }
2514
2531
  .hkm-step-panel { grid-area: 1 / 1; min-inline-size: 0; align-self: start; }
2515
2532
  .hkm-step-panel[aria-hidden="true"] { visibility: hidden; pointer-events: none; }
2516
2533
  .hkm-showcase.hkm-steps .hkm-step-panel .hkm-root { border-start-start-radius: 0; border-start-end-radius: 0; box-shadow: none; }
2534
+ /* Fill uses the tallest complete slide as its natural height. It does not
2535
+ * scale text or ask fitted font sizes to determine the stage height. */
2536
+ .hkm-showcase[data-hkm-fit="fill"] [data-hkm-measuring] .hkm-page { max-block-size: none; }
2537
+
2538
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) {
2539
+ block-size: var(--hkm-showcase-fill-height, auto);
2540
+ }
2541
+
2542
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) :is(.hkm-step-panel, .hkm-mode-surface) {
2543
+ align-self: stretch;
2544
+ min-block-size: 0;
2545
+ }
2546
+
2547
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) :is(.hkm-fit, .hkm-fit-inner, .hkm-root, .hkm-root > .hkm-window) {
2548
+ block-size: 100%;
2549
+ min-block-size: 0;
2550
+ }
2551
+
2552
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) .hkm-window:has(> .hkm-page) {
2553
+ display: flex;
2554
+ flex-direction: column;
2555
+ }
2556
+
2557
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) .hkm-window > .hkm-page {
2558
+ flex: 1 1 auto;
2559
+ block-size: auto;
2560
+ min-block-size: 0;
2561
+ max-block-size: none;
2562
+ }
2563
+
2564
+ .hkm-showcase[data-hkm-fit="fill"] :is(.hkm-step-stage, .hkm-mode-stage):not([data-hkm-measuring]) :is(.hkm-title-bar, .hkm-browser-bar) {
2565
+ flex: none;
2566
+ }
2567
+
2517
2568
  .hkm-showcase.hkm-steps .hkm-step-nav { justify-content: space-between; padding-block-start: 18px; gap: 16px; }
2518
2569
  .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); }
2519
2570
  .hkm-showcase.hkm-steps .hkm-step-button[data-hkm-primary] { background: var(--hkm-sc-fg); color: var(--hkm-sc-bg); }
2520
2571
  .hkm-showcase.hkm-steps .hkm-step-button:disabled { color: var(--hkm-sc-muted); background: transparent; }
2521
2572
  .hkm-showcase.hkm-steps .hkm-step-button[data-hkm-primary]:disabled { background: var(--hkm-sc-field); }
2522
2573
  .hkm-showcase.hkm-steps .hkm-step-announcement { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
2523
- .hkm-showcase.hkm-steps .hkm-showcase-caption { margin-block-start: 1rem; }
2574
+ .hkm-showcase:is(.hkm-steps, .hkm-modes) .hkm-showcase-caption { margin-block-start: 1rem; }
2524
2575
 
2525
2576
  /* ---------------------------------------------------------------- */
2526
2577
  /* Motion */
@@ -415,6 +415,13 @@ html:has(:is(.hraness-marketing-header, .hraness-marketing-header-surface):not([
415
415
  z-index: 30;
416
416
  }
417
417
 
418
+ /* Intrinsic media sizing is shared with raw HTML card icons. */
419
+ .hraness-marketing-card__icon > :is(img, svg) {
420
+ inline-size: 100%;
421
+ block-size: 100%;
422
+ object-fit: contain;
423
+ }
424
+
418
425
  .hraness-marketing-card-row,
419
426
  .hraness-marketing-interface-grid,
420
427
  .hraness-marketing-trust-grid,
@@ -439,6 +446,11 @@ html:has(:is(.hraness-marketing-header, .hraness-marketing-header-surface):not([
439
446
  display: none;
440
447
  }
441
448
 
449
+ /* Supplemental proof belongs below both columns of a split hero. */
450
+ .hraness-marketing-hero[data-layout="split"] > :where(.hraness-marketing-proof, .hraness-marketing-facts) {
451
+ grid-column: 1 / -1;
452
+ }
453
+
442
454
  /* Keep all meaningful hero content above the inert decorative backdrop. */
443
455
  .hraness-marketing-hero > :where(.hraness-marketing-hero__copy, .hraness-marketing-hero__frame, .hraness-marketing-proof, .hraness-marketing-facts) {
444
456
  position: relative;
@@ -707,6 +707,17 @@ html:has(:is(.hraness-marketing-header, .hraness-marketing-header-surface):not([
707
707
  padding-block: var(--hraness-marketing-hero-space, clamp(3.5rem, 9vw, 7rem) clamp(2.5rem, 6vw, 4.5rem));
708
708
  }
709
709
 
710
+ .hraness-marketing-hero[data-layout="split"] {
711
+ grid-template-columns: minmax(0, 1fr);
712
+ align-items: center;
713
+ }
714
+
715
+ @media (min-width: 64rem) {
716
+ .hraness-marketing-hero[data-layout="split"] {
717
+ grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
718
+ }
719
+ }
720
+
710
721
  /* Hero backdrops are retired. Hand-authored markup that still ships the old
711
722
  * backdrop hooks paints nothing and takes no space. */
712
723
  .hraness-marketing-hero-backdrop {
@@ -1698,6 +1709,49 @@ html:has(:is(.hraness-marketing-header, .hraness-marketing-header-surface):not([
1698
1709
  box-shadow: var(--hraness-marketing-surface-shadow);
1699
1710
  }
1700
1711
 
1712
+ .hraness-marketing-card[data-layout="icon"] {
1713
+ display: grid;
1714
+ grid-template-columns: 3.5rem minmax(0, 1fr);
1715
+ align-items: center;
1716
+ gap: 1rem;
1717
+ padding: 1.125rem;
1718
+ }
1719
+
1720
+ .hraness-marketing-card[data-layout="icon"]:focus-visible {
1721
+ outline: 2px solid var(--hraness-marketing-ink);
1722
+ outline-offset: 3px;
1723
+ }
1724
+
1725
+ .hraness-marketing-card__icon {
1726
+ display: flex;
1727
+ align-items: center;
1728
+ justify-content: center;
1729
+ inline-size: 3.5rem;
1730
+ block-size: 3.5rem;
1731
+ min-inline-size: 0;
1732
+ pointer-events: none;
1733
+ }
1734
+
1735
+ .hraness-marketing-card__icon > :is(img, svg) {
1736
+ inline-size: 100%;
1737
+ block-size: 100%;
1738
+ object-fit: contain;
1739
+ }
1740
+
1741
+ .hraness-marketing-card__copy {
1742
+ display: grid;
1743
+ align-content: center;
1744
+ min-inline-size: 0;
1745
+ gap: 0.35rem;
1746
+ }
1747
+
1748
+ .hraness-marketing-card[data-layout="icon"] .hraness-marketing-card__meta {
1749
+ overflow: visible;
1750
+ min-block-size: 0;
1751
+ max-block-size: none;
1752
+ overflow-wrap: anywhere;
1753
+ }
1754
+
1701
1755
  .hraness-marketing-card__art {
1702
1756
  display: flex;
1703
1757
  flex: 0 0 auto;
@@ -2266,6 +2320,11 @@ html:has(:is(.hraness-marketing-header, .hraness-marketing-header-surface):not([
2266
2320
  }
2267
2321
  }
2268
2322
 
2323
+ /* Supplemental proof belongs below both columns of a split hero. */
2324
+ .hraness-marketing-hero[data-layout="split"] > :where(.hraness-marketing-proof, .hraness-marketing-facts) {
2325
+ grid-column: 1 / -1;
2326
+ }
2327
+
2269
2328
  /* Keep all meaningful hero content above the inert decorative backdrop. */
2270
2329
  .hraness-marketing-hero > :where(.hraness-marketing-hero__copy, .hraness-marketing-hero__frame, .hraness-marketing-proof, .hraness-marketing-facts) {
2271
2330
  position: relative;