@tonycodes-ui/mortar 0.0.0-stage → 0.11.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 (104) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +276 -0
  3. package/CONTRIBUTING.md +48 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9703 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/index.d.ts +34 -0
  47. package/dist/index.js +17 -0
  48. package/dist/inspection.d.ts +24 -0
  49. package/dist/inspection.js +14 -0
  50. package/dist/internal.d.ts +492 -0
  51. package/dist/internal.js +273 -0
  52. package/dist/layout.d.ts +107 -0
  53. package/dist/layout.js +66 -0
  54. package/dist/marketing-sections.d.ts +161 -0
  55. package/dist/marketing-sections.js +58 -0
  56. package/dist/marketing.d.ts +254 -0
  57. package/dist/marketing.js +150 -0
  58. package/dist/navigation.d.ts +35 -0
  59. package/dist/navigation.js +44 -0
  60. package/dist/primitives.d.ts +258 -0
  61. package/dist/primitives.js +244 -0
  62. package/dist/showcase.d.ts +29 -0
  63. package/dist/showcase.js +10 -0
  64. package/dist/workspace.d.ts +80 -0
  65. package/dist/workspace.js +48 -0
  66. package/docs/agents.md +127 -0
  67. package/docs/auth-flows.md +71 -0
  68. package/docs/migrating-to-0.6.md +94 -0
  69. package/docs/theming.md +170 -0
  70. package/package.json +94 -4
  71. package/skills/mortar/SKILL.md +36 -0
  72. package/src/application.css +1487 -0
  73. package/src/application.tsx +1413 -0
  74. package/src/auth.tsx +452 -0
  75. package/src/brand.ts +335 -0
  76. package/src/calendar.css +468 -0
  77. package/src/calendar.tsx +868 -0
  78. package/src/choice.tsx +240 -0
  79. package/src/composer.tsx +158 -0
  80. package/src/data.css +690 -0
  81. package/src/display.tsx +452 -0
  82. package/src/docs.css +704 -0
  83. package/src/docs.tsx +737 -0
  84. package/src/ecommerce.css +810 -0
  85. package/src/ecommerce.tsx +1033 -0
  86. package/src/feedback.tsx +388 -0
  87. package/src/foundation.css +1867 -0
  88. package/src/foundation.tsx +227 -0
  89. package/src/heading.ts +13 -0
  90. package/src/icons.tsx +237 -0
  91. package/src/index.ts +230 -0
  92. package/src/inspection.tsx +91 -0
  93. package/src/internal.tsx +614 -0
  94. package/src/layout.tsx +342 -0
  95. package/src/marketing-sections.tsx +621 -0
  96. package/src/marketing.css +2400 -0
  97. package/src/marketing.tsx +1156 -0
  98. package/src/navigation.tsx +118 -0
  99. package/src/overlays.css +350 -0
  100. package/src/primitives.tsx +1135 -0
  101. package/src/showcase.tsx +120 -0
  102. package/src/tokens.css +449 -0
  103. package/src/workspace.tsx +333 -0
  104. package/styles.css +56 -0
@@ -0,0 +1,1156 @@
1
+ import {
2
+ IconLinks,
3
+ KitLink,
4
+ type IconLink,
5
+ type ItemGroup,
6
+ type LinkItem,
7
+ } from "./navigation.js";
8
+ import { useId, useState, type CSSProperties, type ReactNode } from "react";
9
+ import {
10
+ CloseButton,
11
+ DialogPanel,
12
+ DialogTitle,
13
+ Popover,
14
+ PopoverButton,
15
+ PopoverPanel,
16
+ } from "@headlessui/react";
17
+ import { ActionLink, Button, type LinkAction } from "./primitives.js";
18
+ import { BrandMark, Prose, type SectionTone } from "./layout.js";
19
+ import { SegmentedControl } from "./inspection.js";
20
+ import { ChevronDown } from "./icons.js";
21
+ import { StatGroup } from "./marketing-sections.js";
22
+ import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
23
+ import {
24
+ ActionLinks,
25
+ DialogFrame,
26
+ TermList,
27
+ TitledIntro,
28
+ cx,
29
+ componentRoot,
30
+ titledIntroSlots,
31
+ sectionTone,
32
+ useDomId,
33
+ } from "./internal.js";
34
+
35
+ export type FlyoutItem = Pick<
36
+ LinkItem,
37
+ "label" | "href" | "description" | "icon"
38
+ >;
39
+ /** A header link, or a labelled group that opens a flyout panel. */
40
+ export type HeaderLink =
41
+ | LinkAction
42
+ | (ItemGroup<FlyoutItem> & {
43
+ footer?: LinkAction[];
44
+ /** list: one column. columns: a wider panel in two columns. full: as wide as the viewport allows, up to four columns. */
45
+ layout?: "list" | "columns" | "full";
46
+ });
47
+
48
+ function Flyout({
49
+ label,
50
+ items,
51
+ footer = [],
52
+ layout = "list",
53
+ onNavigate,
54
+ }: {
55
+ label: string;
56
+ items: FlyoutItem[];
57
+ footer?: LinkAction[];
58
+ layout?: "list" | "columns" | "full";
59
+ onNavigate: () => void;
60
+ }) {
61
+ const dataLayout = layout === "list" ? undefined : layout;
62
+ return (
63
+ <Popover className="m-flyout" data-layout={dataLayout}>
64
+ <PopoverButton className="m-flyout-trigger">
65
+ {label}
66
+ <ChevronDown />
67
+ </PopoverButton>
68
+ {/* Anchored panels render in a portal, so headers inside clipped containers never cut them off. */}
69
+ <PopoverPanel
70
+ anchor="bottom"
71
+ className="m-flyout-panel"
72
+ data-layout={dataLayout}
73
+ >
74
+ <ul>
75
+ {items.map((item, index) => (
76
+ <li key={`${item.label}:${item.href}:${index}`}>
77
+ <CloseButton as={KitLink} href={item.href} onClick={onNavigate}>
78
+ {item.icon && (
79
+ <span className="m-flyout-icon" aria-hidden="true">
80
+ {item.icon}
81
+ </span>
82
+ )}
83
+ <span className="m-flyout-text">
84
+ <strong>{item.label}</strong>
85
+ {item.description && <span>{item.description}</span>}
86
+ </span>
87
+ </CloseButton>
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ {footer.length > 0 && (
92
+ <div className="m-flyout-footer">
93
+ {footer.map((link, index) => (
94
+ <CloseButton
95
+ as={KitLink}
96
+ key={`${link.label}:${link.href}:${index}`}
97
+ href={link.href}
98
+ onClick={onNavigate}
99
+ >
100
+ {link.label}
101
+ </CloseButton>
102
+ ))}
103
+ </div>
104
+ )}
105
+ </PopoverPanel>
106
+ </Popover>
107
+ );
108
+ }
109
+ export function MarketingHeader({
110
+ brand,
111
+ homeHref = "/",
112
+ logo,
113
+ links,
114
+ action,
115
+ controls,
116
+ layout = "inline",
117
+ mobileMenu = "inline",
118
+ className,
119
+ id,
120
+ }: {
121
+ brand: string;
122
+ logo?: ReactNode;
123
+ homeHref?: string;
124
+ /** Links, or groups of items that open a flyout panel. */
125
+ links: HeaderLink[];
126
+ action?: LinkAction;
127
+ controls?: ReactNode;
128
+ /** inline: brand, links and actions in one row. center: links at the start, the brand centred, actions at the end. */
129
+ layout?: "inline" | "center";
130
+ /** How narrow screens show the links. inline: the menu button expands them under the header. panel: it opens a full-screen dialog. */
131
+ mobileMenu?: "inline" | "panel";
132
+ className?: string;
133
+ id?: string;
134
+ }) {
135
+ const [open, setOpen] = useState(false);
136
+ // Generated so two headers on one page keep distinct, valid references.
137
+ const navId = useDomId("m-marketing-nav");
138
+ const panel = mobileMenu === "panel";
139
+ const close = () => setOpen(false);
140
+ const brandLink = (
141
+ <KitLink className="m-brand" href={homeHref}>
142
+ <BrandMark logo={logo} />
143
+ {brand}
144
+ </KitLink>
145
+ );
146
+ const menuButton = (
147
+ <Button
148
+ variant="ghost"
149
+ className="m-menu-trigger"
150
+ aria-haspopup={panel ? "dialog" : undefined}
151
+ aria-expanded={open}
152
+ aria-controls={panel ? undefined : navId}
153
+ onClick={() => setOpen(!open)}
154
+ >
155
+ Menu
156
+ </Button>
157
+ );
158
+ const nav = (
159
+ <nav
160
+ id={navId}
161
+ aria-label="Primary"
162
+ data-open={(open && !panel) || undefined}
163
+ >
164
+ {links.map((link, index) =>
165
+ "items" in link ? (
166
+ <Flyout
167
+ key={`${link.label}:flyout:${index}`}
168
+ label={link.label}
169
+ items={link.items}
170
+ footer={link.footer}
171
+ layout={link.layout}
172
+ onNavigate={close}
173
+ />
174
+ ) : (
175
+ <KitLink
176
+ key={`${link.label}:${link.href}:${index}`}
177
+ href={link.href}
178
+ onClick={close}
179
+ >
180
+ {link.label}
181
+ </KitLink>
182
+ ),
183
+ )}
184
+ </nav>
185
+ );
186
+ const actions = (controls || action) && (
187
+ <>
188
+ {controls}
189
+ {action && <ActionLink {...action} />}
190
+ </>
191
+ );
192
+ return (
193
+ <header
194
+ id={id}
195
+ className={cx("m-marketing-header", className)}
196
+ {...componentRoot("MarketingHeader")}
197
+ data-layout={layout === "center" ? "center" : undefined}
198
+ data-menu={panel ? "panel" : undefined}
199
+ >
200
+ {/* Centred, the DOM follows the visual order so focus moves from start to end. */}
201
+ {layout === "center" ? (
202
+ <>
203
+ {menuButton}
204
+ {nav}
205
+ {brandLink}
206
+ </>
207
+ ) : (
208
+ <>
209
+ {brandLink}
210
+ {menuButton}
211
+ {nav}
212
+ </>
213
+ )}
214
+ <div className="m-actions">{actions}</div>
215
+ {panel && (
216
+ <DialogFrame open={open} onClose={close}>
217
+ <DialogPanel className="m-marketing-panel">
218
+ <div className="m-dialog-heading">
219
+ <DialogTitle className="m-brand">
220
+ <BrandMark logo={logo} />
221
+ {brand}
222
+ </DialogTitle>
223
+ <Button variant="ghost" onClick={close}>
224
+ Close menu
225
+ </Button>
226
+ </div>
227
+ <PanelLinks links={links} onNavigate={close} />
228
+ {actions && <div className="m-actions">{actions}</div>}
229
+ </DialogPanel>
230
+ </DialogFrame>
231
+ )}
232
+ </header>
233
+ );
234
+ }
235
+ /** The header's links in the full-screen mobile panel; flyout groups become disclosures. */
236
+ function PanelLinks({
237
+ links,
238
+ onNavigate,
239
+ }: {
240
+ links: HeaderLink[];
241
+ onNavigate: () => void;
242
+ }) {
243
+ return (
244
+ <nav aria-label="Primary" className="m-marketing-panel-nav">
245
+ {links.map((link, index) =>
246
+ "items" in link ? (
247
+ <details key={`${link.label}:group:${index}`}>
248
+ <summary>
249
+ {link.label}
250
+ <ChevronDown />
251
+ </summary>
252
+ {[...link.items, ...(link.footer ?? [])].map((item, i) => (
253
+ <KitLink
254
+ key={`${item.label}:${item.href}:${i}`}
255
+ href={item.href}
256
+ onClick={onNavigate}
257
+ >
258
+ {item.label}
259
+ </KitLink>
260
+ ))}
261
+ </details>
262
+ ) : (
263
+ <KitLink
264
+ key={`${link.label}:${link.href}:${index}`}
265
+ href={link.href}
266
+ onClick={onNavigate}
267
+ >
268
+ {link.label}
269
+ </KitLink>
270
+ ),
271
+ )}
272
+ </nav>
273
+ );
274
+ }
275
+ export function Hero({
276
+ id,
277
+ className,
278
+ eyebrow,
279
+ title,
280
+ description,
281
+ actions = [],
282
+ children,
283
+ layout = "center",
284
+ headingLevel = 1,
285
+ background,
286
+ images = [],
287
+ tone = "default",
288
+ }: {
289
+ id?: string;
290
+ className?: string;
291
+ eyebrow?: string;
292
+ title: string;
293
+ description: string;
294
+ actions?: LinkAction[];
295
+ children?: ReactNode;
296
+ /** center: copy above the preview. split: copy beside the preview. angled: copy beside an image that fills the end half to the edge, cut on a slant (pass one img or video as children). */
297
+ layout?: "center" | "split" | "angled";
298
+ headingLevel?: HeadingLevel;
299
+ /** Decorative full-cover image or video behind the copy. The hero switches to the inverse tone with a scrim so text keeps its contrast. */
300
+ background?: ReactNode;
301
+ /** Image tiles shown as a staggered collage beside (split) or below (center) the copy. Use instead of children; give each image its own alt text, or alt="" when decorative. */
302
+ images?: ReactNode[];
303
+ /** default: the page. accent: a full-bleed band of the brand's accent fill (its accent gradient when it defines one) with on-accent copy and actions; the preview keeps the page's colours. A background takes precedence. */
304
+ tone?: "default" | "accent";
305
+ }) {
306
+ return (
307
+ <section
308
+ id={id}
309
+ className={cx("m-hero", `m-hero-${layout}`, className)}
310
+ {...componentRoot("Hero")}
311
+ data-background={background ? true : undefined}
312
+ data-tone={background ? "inverse" : sectionTone(tone)}
313
+ >
314
+ {background && (
315
+ <div className="m-hero-background" aria-hidden="true">
316
+ {background}
317
+ </div>
318
+ )}
319
+ <div>
320
+ <TitledIntro
321
+ eyebrow={eyebrow}
322
+ title={title}
323
+ headingLevel={headingLevel}
324
+ />
325
+ <p className="m-hero-description">{description}</p>
326
+ <ActionLinks actions={actions} />
327
+ </div>
328
+ {images.length > 0 && (
329
+ <div className="m-hero-tiles">
330
+ {images.map((image, index) => (
331
+ <div key={index}>{image}</div>
332
+ ))}
333
+ </div>
334
+ )}
335
+ {children && <div className="m-hero-preview">{children}</div>}
336
+ </section>
337
+ );
338
+ }
339
+ export function FeatureGrid({
340
+ id,
341
+ className,
342
+ eyebrow,
343
+ title,
344
+ description,
345
+ items,
346
+ marker = "auto",
347
+ columns,
348
+ tone = "default",
349
+ headingLevel = 2,
350
+ }: {
351
+ id?: string;
352
+ className?: string;
353
+ eyebrow?: string;
354
+ title: string;
355
+ description?: string;
356
+ items: { title: string; description: string; icon?: ReactNode }[];
357
+ /** auto: show icons when supplied, otherwise no marker. number: for sequences. */
358
+ marker?: "auto" | "icon" | "number" | "none";
359
+ /** Defaults to a balanced count for the number of items. */
360
+ columns?: 2 | 3 | 4;
361
+ tone?: SectionTone;
362
+ /** The section title's level; item titles sit one level below. */
363
+ headingLevel?: HeadingLevel;
364
+ }) {
365
+ const ItemHeading = headingTag(childLevel(headingLevel));
366
+ const cols =
367
+ columns ??
368
+ (items.length === 2 || items.length === 4
369
+ ? 2
370
+ : items.length % 3 === 0 || items.length > 4
371
+ ? 3
372
+ : Math.min(items.length, 3));
373
+ return (
374
+ <section
375
+ id={id}
376
+ className={cx("m-marketing-section", className)}
377
+ data-tone={sectionTone(tone)}
378
+ {...componentRoot("FeatureGrid")}
379
+ >
380
+ {...titledIntroSlots({ eyebrow, title, description, headingLevel })}
381
+ <div className="m-feature-grid" data-columns={cols}>
382
+ {items.map((item, i) => {
383
+ const content =
384
+ marker === "number"
385
+ ? String(i + 1).padStart(2, "0")
386
+ : marker !== "none"
387
+ ? item.icon
388
+ : null;
389
+ return (
390
+ <article key={item.title}>
391
+ {content && (
392
+ <span
393
+ className="m-feature-icon"
394
+ data-marker={marker === "number" ? "number" : "icon"}
395
+ aria-hidden="true"
396
+ >
397
+ {content}
398
+ </span>
399
+ )}
400
+ <ItemHeading>{item.title}</ItemHeading>
401
+ <p>{item.description}</p>
402
+ </article>
403
+ );
404
+ })}
405
+ </div>
406
+ </section>
407
+ );
408
+ }
409
+ export function FeatureSection({
410
+ id,
411
+ className,
412
+ eyebrow,
413
+ title,
414
+ description,
415
+ details,
416
+ children,
417
+ reverse = false,
418
+ tone = "default",
419
+ framed = true,
420
+ offset = false,
421
+ headingLevel = 2,
422
+ }: {
423
+ id?: string;
424
+ className?: string;
425
+ eyebrow?: string;
426
+ title: string;
427
+ description: string;
428
+ /** Supporting content under the description, such as a CheckList or actions. */
429
+ details?: ReactNode;
430
+ children: ReactNode;
431
+ reverse?: boolean;
432
+ tone?: SectionTone;
433
+ /** Wrap the preview in the sunken frame. Disable when the child brings its own frame. */
434
+ framed?: boolean;
435
+ /** Let a wide screenshot run past the section's end edge and crop there (wide screens only). */
436
+ offset?: boolean;
437
+ headingLevel?: HeadingLevel;
438
+ }) {
439
+ const [eyebrowSlot, headingSlot] = titledIntroSlots({
440
+ eyebrow,
441
+ title,
442
+ headingLevel,
443
+ });
444
+ return (
445
+ <section
446
+ id={id}
447
+ className={cx("m-feature-section", className)}
448
+ data-reverse={reverse || undefined}
449
+ data-offset={offset || undefined}
450
+ data-tone={sectionTone(tone)}
451
+ {...componentRoot("FeatureSection")}
452
+ >
453
+ <div>
454
+ {eyebrowSlot}
455
+ {headingSlot}
456
+ <p>{description}</p>
457
+ {details && <div className="m-feature-details">{details}</div>}
458
+ </div>
459
+ <div className="m-feature-preview" data-framed={framed || undefined}>
460
+ {children}
461
+ </div>
462
+ </section>
463
+ );
464
+ }
465
+ /** Numbered, connected sequence for marketing "how it works" sections. */
466
+ export function ProcessSteps({
467
+ id,
468
+ className,
469
+ eyebrow,
470
+ title,
471
+ description,
472
+ items,
473
+ tone = "default",
474
+ headingLevel = 2,
475
+ }: {
476
+ id?: string;
477
+ className?: string;
478
+ eyebrow?: string;
479
+ title: string;
480
+ description?: string;
481
+ items: { title: string; description: string }[];
482
+ tone?: SectionTone;
483
+ headingLevel?: HeadingLevel;
484
+ }) {
485
+ const ItemHeading = headingTag(childLevel(headingLevel));
486
+ return (
487
+ <section
488
+ id={id}
489
+ className={cx("m-marketing-section", className)}
490
+ data-tone={sectionTone(tone)}
491
+ {...componentRoot("ProcessSteps")}
492
+ >
493
+ {...titledIntroSlots({ eyebrow, title, description, headingLevel })}
494
+ <ol
495
+ className="m-process-steps"
496
+ style={{ "--m-steps": items.length } as CSSProperties}
497
+ >
498
+ {items.map((item, i) => (
499
+ <li key={`${item.title}:${i}`}>
500
+ <span className="m-process-number" aria-hidden="true">
501
+ {i + 1}
502
+ </span>
503
+ <ItemHeading>{item.title}</ItemHeading>
504
+ <p>{item.description}</p>
505
+ </li>
506
+ ))}
507
+ </ol>
508
+ </section>
509
+ );
510
+ }
511
+ export function Testimonial({
512
+ id,
513
+ className,
514
+ quote,
515
+ name,
516
+ role,
517
+ logo,
518
+ avatar,
519
+ }: {
520
+ id?: string;
521
+ className?: string;
522
+ quote: string;
523
+ name: string;
524
+ role?: string;
525
+ /** Company logo above the quote; give the image its company name as alt text. */
526
+ logo?: ReactNode;
527
+ /** Photo or avatar beside the name (decorative). */
528
+ avatar?: ReactNode;
529
+ }) {
530
+ const person = (
531
+ <>
532
+ <strong>{name}</strong>
533
+ {role && <span>{role}</span>}
534
+ </>
535
+ );
536
+ return (
537
+ <figure
538
+ id={id}
539
+ className={cx("m-testimonial", className)}
540
+ {...componentRoot("Testimonial")}
541
+ >
542
+ {logo && <div className="m-testimonial-logo">{logo}</div>}
543
+ <blockquote>{quote}</blockquote>
544
+ <figcaption>
545
+ {avatar ? (
546
+ <>
547
+ <span className="m-testimonial-avatar" aria-hidden="true">
548
+ {avatar}
549
+ </span>
550
+ <span className="m-testimonial-person">{person}</span>
551
+ </>
552
+ ) : (
553
+ person
554
+ )}
555
+ </figcaption>
556
+ </figure>
557
+ );
558
+ }
559
+ export function LogoCloud({
560
+ id,
561
+ className,
562
+ label,
563
+ items,
564
+ layout = "inline",
565
+ title,
566
+ description,
567
+ actions = [],
568
+ headingLevel = 2,
569
+ }: {
570
+ id?: string;
571
+ className?: string;
572
+ label: string;
573
+ items: { name: string; image?: string }[];
574
+ /** inline: a centred row. grid: logos in bordered tiles. split: copy beside a logo grid. */
575
+ layout?: "inline" | "grid" | "split";
576
+ /** Split layout heading; the label still names the section. */
577
+ title?: string;
578
+ /** Split layout supporting copy. */
579
+ description?: string;
580
+ /** Split layout links, e.g. a case-studies link. */
581
+ actions?: LinkAction[];
582
+ /** Level of the split layout's title. */
583
+ headingLevel?: HeadingLevel;
584
+ }) {
585
+ const Heading = headingTag(headingLevel);
586
+ const list = (
587
+ <ul>
588
+ {items.map((item) => (
589
+ <li key={item.name}>
590
+ {item.image ? (
591
+ <img src={item.image} alt={item.name} />
592
+ ) : (
593
+ <span>{item.name}</span>
594
+ )}
595
+ </li>
596
+ ))}
597
+ </ul>
598
+ );
599
+ if (layout === "split") {
600
+ return (
601
+ <section
602
+ id={id}
603
+ className={cx("m-logo-cloud", className)}
604
+ aria-label={label}
605
+ {...componentRoot("LogoCloud")}
606
+ data-layout="split"
607
+ >
608
+ <div className="m-logo-cloud-copy">
609
+ {title ? (
610
+ <Heading className="m-section-title">{title}</Heading>
611
+ ) : (
612
+ <p>{label}</p>
613
+ )}
614
+ {description && (
615
+ <p className="m-section-description">{description}</p>
616
+ )}
617
+ {actions.length > 0 && <ActionLinks actions={actions} />}
618
+ </div>
619
+ {list}
620
+ </section>
621
+ );
622
+ }
623
+ return (
624
+ <section
625
+ id={id}
626
+ className={cx("m-logo-cloud", className)}
627
+ aria-label={label}
628
+ {...componentRoot("LogoCloud")}
629
+ data-layout={layout === "grid" ? "grid" : undefined}
630
+ >
631
+ <p>{label}</p>
632
+ {list}
633
+ </section>
634
+ );
635
+ }
636
+ export interface PricingPlan {
637
+ id: string;
638
+ name: string;
639
+ description: string;
640
+ monthly: string;
641
+ annual?: string;
642
+ unit?: string;
643
+ annualUnit?: string;
644
+ features: string[];
645
+ action: LinkAction;
646
+ featured?: boolean;
647
+ availability?: "available" | "coming-soon" | "unavailable";
648
+ availabilityLabel?: string;
649
+ }
650
+ /** A full-width tier band under the pricing plans. */
651
+ export interface PricingExtra {
652
+ name: string;
653
+ description: string;
654
+ /** Optional price, e.g. "$9" or "Custom". */
655
+ price?: string;
656
+ unit?: string;
657
+ action: LinkAction;
658
+ }
659
+ export function Pricing({
660
+ id,
661
+ className,
662
+ title,
663
+ description,
664
+ plans,
665
+ tone = "default",
666
+ billingToggle,
667
+ layout = "cards",
668
+ featuresLabel = "What’s included",
669
+ headingLevel = 2,
670
+ extra,
671
+ }: {
672
+ id?: string;
673
+ className?: string;
674
+ title: string;
675
+ description?: string;
676
+ plans: PricingPlan[];
677
+ /** Applied only to featured cards. Other plans keep the page surface. */
678
+ tone?: SectionTone;
679
+ billingToggle?: boolean;
680
+ /** cards: side-by-side tiers. detailed: one full-width row per plan, features beside a price panel (single-price pages). */
681
+ layout?: "cards" | "detailed";
682
+ /** Heading above the feature list in the detailed layout. */
683
+ featuresLabel?: string;
684
+ /** The section title's level; plan names sit one level below. */
685
+ headingLevel?: HeadingLevel;
686
+ /** A full-width tier band under the plans, such as an enterprise or discount row. */
687
+ extra?: PricingExtra;
688
+ }) {
689
+ const [annualChoice, setAnnual] = useState(true);
690
+ const showToggle =
691
+ billingToggle ??
692
+ (plans.length > 0 && plans.every((plan) => Boolean(plan.annual)));
693
+ // Annual prices show only while the toggle that switches them is visible,
694
+ // so the page never mixes units the visitor cannot change.
695
+ const annual = showToggle && annualChoice;
696
+ const PlanHeading = headingTag(childLevel(headingLevel));
697
+ return (
698
+ <section
699
+ id={id}
700
+ className={cx("m-marketing-section m-pricing", className)}
701
+ {...componentRoot("Pricing")}
702
+ >
703
+ <TitledIntro
704
+ title={title}
705
+ description={description}
706
+ headingLevel={headingLevel}
707
+ />
708
+ {showToggle && (
709
+ <SegmentedControl
710
+ label="Billing interval"
711
+ value={annual ? "annual" : "monthly"}
712
+ options={[
713
+ { value: "annual", label: "Annual" },
714
+ { value: "monthly", label: "Monthly" },
715
+ ]}
716
+ onChange={(value) => setAnnual(value === "annual")}
717
+ />
718
+ )}
719
+ <div
720
+ className="m-pricing-grid"
721
+ data-layout={layout === "detailed" ? "detailed" : undefined}
722
+ data-columns={layout === "cards" && plans.length === 4 ? 4 : undefined}
723
+ >
724
+ {plans.map((plan) => {
725
+ const availability = plan.availability ?? "available";
726
+ const blocked = availability !== "available";
727
+ const showAnnual = annual && Boolean(plan.annual);
728
+ const suffix = showAnnual ? plan.annualUnit : plan.unit;
729
+ const price = (
730
+ <div className="m-price">
731
+ <strong>{showAnnual ? plan.annual : plan.monthly}</strong>
732
+ {suffix && <span>{suffix}</span>}
733
+ </div>
734
+ );
735
+ const features = (
736
+ <ul>
737
+ {plan.features.map((feature) => (
738
+ <li key={feature}>{feature}</li>
739
+ ))}
740
+ </ul>
741
+ );
742
+ // Static text, not a live region: nothing changes after render.
743
+ const purchase = blocked ? (
744
+ <p className="m-pricing-availability">
745
+ {plan.availabilityLabel ??
746
+ (availability === "coming-soon"
747
+ ? "Coming soon"
748
+ : "Unavailable")}
749
+ </p>
750
+ ) : (
751
+ <ActionLink
752
+ {...plan.action}
753
+ variant={layout === "detailed" ? "primary" : "secondary"}
754
+ />
755
+ );
756
+ return (
757
+ <article
758
+ key={plan.id}
759
+ data-featured={plan.featured || undefined}
760
+ data-tone={plan.featured ? sectionTone(tone) : undefined}
761
+ data-availability={blocked ? availability : undefined}
762
+ >
763
+ {layout === "detailed" ? (
764
+ <>
765
+ <div className="m-pricing-detail">
766
+ <PlanHeading>{plan.name}</PlanHeading>
767
+ <p>{plan.description}</p>
768
+ <p className="m-pricing-features-label">{featuresLabel}</p>
769
+ {features}
770
+ </div>
771
+ <div className="m-pricing-offer">
772
+ {price}
773
+ {purchase}
774
+ </div>
775
+ </>
776
+ ) : (
777
+ <>
778
+ <PlanHeading>{plan.name}</PlanHeading>
779
+ <p>{plan.description}</p>
780
+ {price}
781
+ {features}
782
+ {purchase}
783
+ </>
784
+ )}
785
+ </article>
786
+ );
787
+ })}
788
+ {extra && (
789
+ <article className="m-pricing-extra">
790
+ <div>
791
+ <PlanHeading>{extra.name}</PlanHeading>
792
+ <p>{extra.description}</p>
793
+ </div>
794
+ {extra.price && (
795
+ <div className="m-price">
796
+ <strong>{extra.price}</strong>
797
+ {extra.unit && <span>{extra.unit}</span>}
798
+ </div>
799
+ )}
800
+ <ActionLink {...extra.action} variant="secondary" />
801
+ </article>
802
+ )}
803
+ </div>
804
+ </section>
805
+ );
806
+ }
807
+ export function FAQ({
808
+ id,
809
+ className,
810
+ title,
811
+ description,
812
+ items,
813
+ layout = "split",
814
+ headingLevel = 2,
815
+ }: {
816
+ id?: string;
817
+ className?: string;
818
+ title: string;
819
+ description?: ReactNode;
820
+ items: { question: string; answer: ReactNode }[];
821
+ /** split: heading beside an accordion. center: heading above a narrow accordion. grid: every answer visible in columns. */
822
+ layout?: "split" | "center" | "grid";
823
+ headingLevel?: HeadingLevel;
824
+ }) {
825
+ const Heading = headingTag(headingLevel);
826
+ const head = (
827
+ <div>
828
+ <Heading className="m-section-title">{title}</Heading>
829
+ {description && <div>{description}</div>}
830
+ </div>
831
+ );
832
+ if (layout === "grid") {
833
+ return (
834
+ <section
835
+ id={id}
836
+ className={cx("m-faq", className)}
837
+ {...componentRoot("FAQ")}
838
+ data-layout="grid"
839
+ >
840
+ {head}
841
+ <TermList
842
+ className="m-faq-grid"
843
+ items={items.map((item) => ({
844
+ term: item.question,
845
+ details: item.answer,
846
+ }))}
847
+ />
848
+ </section>
849
+ );
850
+ }
851
+ return (
852
+ <section
853
+ id={id}
854
+ className={cx("m-faq", className)}
855
+ {...componentRoot("FAQ")}
856
+ data-layout={layout === "center" ? "center" : undefined}
857
+ >
858
+ {head}
859
+ <div>
860
+ {items.map((item) => (
861
+ <details key={item.question}>
862
+ <summary>
863
+ {item.question}
864
+ <span aria-hidden="true">+</span>
865
+ </summary>
866
+ <div>{item.answer}</div>
867
+ </details>
868
+ ))}
869
+ </div>
870
+ </section>
871
+ );
872
+ }
873
+ export function CallToAction({
874
+ id,
875
+ className,
876
+ title,
877
+ description,
878
+ actions,
879
+ tone = "default",
880
+ media,
881
+ headingLevel = 2,
882
+ }: {
883
+ id?: string;
884
+ className?: string;
885
+ title: string;
886
+ description?: string;
887
+ actions: LinkAction[];
888
+ /** default: the page surface. muted: the sunken band. inverse: the inverse token remap. accent: the brand's accent fill (its accent gradient when it defines one) with on-accent copy and actions. */
889
+ tone?: SectionTone | "accent";
890
+ /** Image or screenshot beside the copy (promo layout). */
891
+ media?: ReactNode;
892
+ headingLevel?: HeadingLevel;
893
+ }) {
894
+ const copy = (
895
+ <>
896
+ <TitledIntro
897
+ title={title}
898
+ description={description}
899
+ descriptionClassName={null}
900
+ headingLevel={headingLevel}
901
+ />
902
+ <ActionLinks actions={actions} />
903
+ </>
904
+ );
905
+ return (
906
+ <section
907
+ id={id}
908
+ className={cx("m-cta", className)}
909
+ {...componentRoot("CallToAction")}
910
+ data-tone={sectionTone(tone)}
911
+ data-layout={media ? "split" : undefined}
912
+ >
913
+ {media ? (
914
+ <>
915
+ <div className="m-cta-copy">{copy}</div>
916
+ <div className="m-cta-media">{media}</div>
917
+ </>
918
+ ) : (
919
+ copy
920
+ )}
921
+ </section>
922
+ );
923
+ }
924
+ export function Footer({
925
+ id,
926
+ className,
927
+ brand,
928
+ logo,
929
+ description,
930
+ note,
931
+ columns,
932
+ copyright,
933
+ legal = [],
934
+ itemHeadingLevel = 2,
935
+ social = [],
936
+ layout = "columns",
937
+ newsletter,
938
+ }: {
939
+ id?: string;
940
+ className?: string;
941
+ brand: string;
942
+ logo?: ReactNode;
943
+ description: string;
944
+ note?: ReactNode;
945
+ /** Link columns; each label is the column's visible heading and its navigation name. Centred, the label only names the navigation. */
946
+ columns: ItemGroup<LinkAction>[];
947
+ copyright: string;
948
+ legal?: LinkAction[];
949
+ /** Level of the column headings. */
950
+ itemHeadingLevel?: HeadingLevel;
951
+ /** Social profiles, usually icon-only; each label names its link. */
952
+ social?: IconLink[];
953
+ /** columns: the brand beside headed link columns. center: brand, a row of links and social links, all centred. */
954
+ layout?: "columns" | "center";
955
+ /** A signup row above the copyright, usually a Newsletter; the footer removes its section padding. */
956
+ newsletter?: ReactNode;
957
+ }) {
958
+ const Heading = headingTag(itemHeadingLevel);
959
+ const center = layout === "center";
960
+ const identity = (
961
+ <>
962
+ <strong className="m-brand">
963
+ <BrandMark logo={logo} />
964
+ {brand}
965
+ </strong>
966
+ <p>{description}</p>
967
+ {note && <p>{note}</p>}
968
+ </>
969
+ );
970
+ const socialLinks = social.length > 0 && (
971
+ <IconLinks label="Social media" links={social} />
972
+ );
973
+ const navs = columns.map((column) => (
974
+ <nav key={column.label} aria-label={column.label}>
975
+ {/* Centred, the links sit in one row and the label only names the navigation. */}
976
+ {!center && <Heading>{column.label}</Heading>}
977
+ {column.items.map((link, index) => (
978
+ <KitLink href={link.href} key={`${link.label}:${link.href}:${index}`}>
979
+ {link.label}
980
+ </KitLink>
981
+ ))}
982
+ </nav>
983
+ ));
984
+ return (
985
+ <footer
986
+ id={id}
987
+ className={cx("m-footer", className)}
988
+ {...componentRoot("Footer")}
989
+ data-layout={center ? "center" : undefined}
990
+ >
991
+ {center ? (
992
+ <div className="m-footer-center">
993
+ {identity}
994
+ {navs}
995
+ {socialLinks}
996
+ </div>
997
+ ) : (
998
+ <div className="m-footer-grid">
999
+ <div>
1000
+ {identity}
1001
+ {socialLinks}
1002
+ </div>
1003
+ {navs}
1004
+ </div>
1005
+ )}
1006
+ {newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
1007
+ <div className="m-footer-bottom">
1008
+ <span>{copyright}</span>
1009
+ <div>
1010
+ {legal.map((link, index) => (
1011
+ <KitLink
1012
+ key={`${link.label}:${link.href}:${index}`}
1013
+ href={link.href}
1014
+ >
1015
+ {link.label}
1016
+ </KitLink>
1017
+ ))}
1018
+ </div>
1019
+ </div>
1020
+ </footer>
1021
+ );
1022
+ }
1023
+
1024
+ /** Intro band for inner pages (about, pricing, careers), with optional links and stats. */
1025
+ export function HeaderSection({
1026
+ id,
1027
+ className,
1028
+ eyebrow,
1029
+ title,
1030
+ description,
1031
+ links = [],
1032
+ stats = [],
1033
+ tone = "default",
1034
+ align = "start",
1035
+ headingLevel = 1,
1036
+ }: {
1037
+ id?: string;
1038
+ className?: string;
1039
+ eyebrow?: string;
1040
+ title: string;
1041
+ description?: string;
1042
+ links?: LinkAction[];
1043
+ stats?: { label: string; value: string }[];
1044
+ tone?: SectionTone;
1045
+ align?: "start" | "center";
1046
+ headingLevel?: HeadingLevel;
1047
+ }) {
1048
+ const headingId = useId();
1049
+ return (
1050
+ <section
1051
+ id={id}
1052
+ className={cx("m-marketing-section m-header-section", className)}
1053
+ data-tone={sectionTone(tone)}
1054
+ data-align={align === "start" ? undefined : align}
1055
+ aria-labelledby={headingId}
1056
+ {...componentRoot("HeaderSection")}
1057
+ >
1058
+ {...titledIntroSlots({
1059
+ eyebrow,
1060
+ title,
1061
+ description,
1062
+ id: headingId,
1063
+ headingLevel,
1064
+ })}
1065
+ {links.length > 0 && (
1066
+ <div className="m-header-links">
1067
+ {links.map((link, index) => (
1068
+ <KitLink
1069
+ key={`${link.label}:${link.href}:${index}`}
1070
+ href={link.href}
1071
+ >
1072
+ {link.label}
1073
+ <span aria-hidden="true"> →</span>
1074
+ </KitLink>
1075
+ ))}
1076
+ </div>
1077
+ )}
1078
+ {stats.length > 0 && (
1079
+ <TermList
1080
+ className="m-header-stats"
1081
+ items={stats.map((stat) => ({
1082
+ term: stat.label,
1083
+ details: stat.value,
1084
+ }))}
1085
+ />
1086
+ )}
1087
+ </section>
1088
+ );
1089
+ }
1090
+
1091
+ /** Long-form copy with optional media beside it, e.g. an about-page story. */
1092
+ export function ContentSection({
1093
+ id,
1094
+ className,
1095
+ eyebrow,
1096
+ title,
1097
+ description,
1098
+ media,
1099
+ mediaPosition = "end",
1100
+ tone = "default",
1101
+ headingLevel = 2,
1102
+ stats = [],
1103
+ testimonial,
1104
+ children,
1105
+ }: {
1106
+ id?: string;
1107
+ className?: string;
1108
+ eyebrow?: string;
1109
+ title: string;
1110
+ description?: string;
1111
+ /** Image, figure or illustration shown beside the copy. */
1112
+ media?: ReactNode;
1113
+ mediaPosition?: "start" | "end";
1114
+ tone?: SectionTone;
1115
+ headingLevel?: HeadingLevel;
1116
+ /** Figures under the copy, shown as a StatGroup named by the title. */
1117
+ stats?: { label: string; value: string; description?: string }[];
1118
+ /** A customer quote under the copy, shown as a Testimonial. */
1119
+ testimonial?: {
1120
+ quote: string;
1121
+ name: string;
1122
+ role?: string;
1123
+ avatar?: ReactNode;
1124
+ };
1125
+ children?: ReactNode;
1126
+ }) {
1127
+ const headingId = useId();
1128
+ return (
1129
+ <section
1130
+ id={id}
1131
+ className={cx("m-marketing-section m-content-section", className)}
1132
+ data-tone={sectionTone(tone)}
1133
+ data-media={media ? mediaPosition : undefined}
1134
+ aria-labelledby={headingId}
1135
+ {...componentRoot("ContentSection")}
1136
+ >
1137
+ <div className="m-content-copy">
1138
+ {...titledIntroSlots({
1139
+ eyebrow,
1140
+ title,
1141
+ description,
1142
+ id: headingId,
1143
+ headingLevel,
1144
+ })}
1145
+ {children && <Prose>{children}</Prose>}
1146
+ {testimonial && (
1147
+ <Testimonial className="m-content-quote" {...testimonial} />
1148
+ )}
1149
+ {stats.length > 0 && (
1150
+ <StatGroup className="m-content-stats" label={title} items={stats} />
1151
+ )}
1152
+ </div>
1153
+ {media && <figure className="m-content-media">{media}</figure>}
1154
+ </section>
1155
+ );
1156
+ }