@tonycodes-ui/mortar 0.11.0 → 0.13.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/AGENTS.md +1 -1
- package/CHANGELOG.md +16 -47
- package/CONTRIBUTING.md +14 -2
- package/DESIGN.md +1 -1
- package/README.md +1 -1
- package/catalog.json +610 -377
- package/cli/audit.mjs +152 -0
- package/dist/agent-data.json +1 -1
- package/dist/application.d.ts +30 -5
- package/dist/application.js +20 -8
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/inspection.js +17 -1
- package/dist/internal.d.ts +39 -3
- package/dist/internal.js +74 -4
- package/dist/marketing.d.ts +33 -9
- package/dist/marketing.js +23 -14
- package/dist/primitives.js +20 -11
- package/docs/agents.md +21 -18
- package/docs/theming.md +72 -1
- package/package.json +1 -1
- package/skills/mortar/SKILL.md +1 -1
- package/src/application.css +95 -1
- package/src/application.tsx +88 -19
- package/src/data.css +58 -2
- package/src/foundation.css +10 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +6 -0
- package/src/inspection.tsx +15 -0
- package/src/internal.tsx +180 -3
- package/src/marketing.css +181 -2
- package/src/marketing.tsx +103 -94
- package/src/primitives.tsx +38 -19
package/src/marketing.tsx
CHANGED
|
@@ -3,17 +3,9 @@ import {
|
|
|
3
3
|
KitLink,
|
|
4
4
|
type IconLink,
|
|
5
5
|
type ItemGroup,
|
|
6
|
-
type LinkItem,
|
|
7
6
|
} from "./navigation.js";
|
|
8
7
|
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";
|
|
8
|
+
import { DialogPanel, DialogTitle } from "@headlessui/react";
|
|
17
9
|
import { ActionLink, Button, type LinkAction } from "./primitives.js";
|
|
18
10
|
import { BrandMark, Prose, type SectionTone } from "./layout.js";
|
|
19
11
|
import { SegmentedControl } from "./inspection.js";
|
|
@@ -23,6 +15,8 @@ import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
|
|
|
23
15
|
import {
|
|
24
16
|
ActionLinks,
|
|
25
17
|
DialogFrame,
|
|
18
|
+
Flyout,
|
|
19
|
+
type FlyoutItem,
|
|
26
20
|
TermList,
|
|
27
21
|
TitledIntro,
|
|
28
22
|
cx,
|
|
@@ -32,10 +26,7 @@ import {
|
|
|
32
26
|
useDomId,
|
|
33
27
|
} from "./internal.js";
|
|
34
28
|
|
|
35
|
-
export type FlyoutItem
|
|
36
|
-
LinkItem,
|
|
37
|
-
"label" | "href" | "description" | "icon"
|
|
38
|
-
>;
|
|
29
|
+
export type { FlyoutItem } from "./internal.js";
|
|
39
30
|
/** A header link, or a labelled group that opens a flyout panel. */
|
|
40
31
|
export type HeaderLink =
|
|
41
32
|
| LinkAction
|
|
@@ -45,69 +36,9 @@ export type HeaderLink =
|
|
|
45
36
|
layout?: "list" | "columns" | "full";
|
|
46
37
|
});
|
|
47
38
|
|
|
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
39
|
export function MarketingHeader({
|
|
110
40
|
brand,
|
|
41
|
+
tagline,
|
|
111
42
|
homeHref = "/",
|
|
112
43
|
logo,
|
|
113
44
|
links,
|
|
@@ -119,6 +50,8 @@ export function MarketingHeader({
|
|
|
119
50
|
id,
|
|
120
51
|
}: {
|
|
121
52
|
brand: string;
|
|
53
|
+
/** A short descriptor beside the wordmark, outside the home link. Hidden on narrow screens. */
|
|
54
|
+
tagline?: string;
|
|
122
55
|
logo?: ReactNode;
|
|
123
56
|
homeHref?: string;
|
|
124
57
|
/** Links, or groups of items that open a flyout panel. */
|
|
@@ -137,12 +70,21 @@ export function MarketingHeader({
|
|
|
137
70
|
const navId = useDomId("m-marketing-nav");
|
|
138
71
|
const panel = mobileMenu === "panel";
|
|
139
72
|
const close = () => setOpen(false);
|
|
140
|
-
const
|
|
73
|
+
const homeLink = (
|
|
141
74
|
<KitLink className="m-brand" href={homeHref}>
|
|
142
75
|
<BrandMark logo={logo} />
|
|
143
76
|
{brand}
|
|
144
77
|
</KitLink>
|
|
145
78
|
);
|
|
79
|
+
// The tagline sits outside the link so the link's name stays the brand.
|
|
80
|
+
const brandLink = tagline ? (
|
|
81
|
+
<div className="m-brand-lockup">
|
|
82
|
+
{homeLink}
|
|
83
|
+
<span className="m-brand-tagline">{tagline}</span>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
homeLink
|
|
87
|
+
);
|
|
146
88
|
const menuButton = (
|
|
147
89
|
<Button
|
|
148
90
|
variant="ghost"
|
|
@@ -272,15 +214,53 @@ function PanelLinks({
|
|
|
272
214
|
</nav>
|
|
273
215
|
);
|
|
274
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* A public page: the header, a main region that grows to fill the viewport, and
|
|
219
|
+
* the footer. Content is never clipped; a longer page simply scrolls.
|
|
220
|
+
*/
|
|
221
|
+
export function MarketingPage({
|
|
222
|
+
header,
|
|
223
|
+
footer,
|
|
224
|
+
children,
|
|
225
|
+
align = "start",
|
|
226
|
+
id,
|
|
227
|
+
className,
|
|
228
|
+
}: {
|
|
229
|
+
/** Usually a MarketingHeader. */
|
|
230
|
+
header?: ReactNode;
|
|
231
|
+
/** Usually a Footer. */
|
|
232
|
+
footer?: ReactNode;
|
|
233
|
+
children: ReactNode;
|
|
234
|
+
/** start: content from the top. center: content centred in the space between header and footer, for a single hero. */
|
|
235
|
+
align?: "start" | "center";
|
|
236
|
+
id?: string;
|
|
237
|
+
className?: string;
|
|
238
|
+
}) {
|
|
239
|
+
return (
|
|
240
|
+
<div
|
|
241
|
+
id={id}
|
|
242
|
+
className={cx("m-marketing-page", className)}
|
|
243
|
+
{...componentRoot("MarketingPage")}
|
|
244
|
+
data-align={align === "center" ? "center" : undefined}
|
|
245
|
+
>
|
|
246
|
+
{header}
|
|
247
|
+
<main className="m-marketing-main">{children}</main>
|
|
248
|
+
{footer}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
275
252
|
export function Hero({
|
|
276
253
|
id,
|
|
277
254
|
className,
|
|
278
255
|
eyebrow,
|
|
279
256
|
title,
|
|
257
|
+
emphasis,
|
|
280
258
|
description,
|
|
281
259
|
actions = [],
|
|
260
|
+
note,
|
|
282
261
|
children,
|
|
283
262
|
layout = "center",
|
|
263
|
+
wide,
|
|
284
264
|
headingLevel = 1,
|
|
285
265
|
background,
|
|
286
266
|
images = [],
|
|
@@ -290,11 +270,17 @@ export function Hero({
|
|
|
290
270
|
className?: string;
|
|
291
271
|
eyebrow?: string;
|
|
292
272
|
title: string;
|
|
273
|
+
/** A phrase in the title to emphasise. Its first occurrence renders as `<em>` inside the one heading; a phrase not in the title is ignored. */
|
|
274
|
+
emphasis?: string;
|
|
293
275
|
description: string;
|
|
294
276
|
actions?: LinkAction[];
|
|
277
|
+
/** Short supporting content below the actions, such as a note or a Notice. */
|
|
278
|
+
note?: ReactNode;
|
|
295
279
|
children?: ReactNode;
|
|
296
280
|
/** 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
281
|
layout?: "center" | "split" | "angled";
|
|
282
|
+
/** Split only: the column that takes more of the width. Omitted, the columns are even. */
|
|
283
|
+
wide?: "copy" | "preview";
|
|
298
284
|
headingLevel?: HeadingLevel;
|
|
299
285
|
/** 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
286
|
background?: ReactNode;
|
|
@@ -308,6 +294,7 @@ export function Hero({
|
|
|
308
294
|
id={id}
|
|
309
295
|
className={cx("m-hero", `m-hero-${layout}`, className)}
|
|
310
296
|
{...componentRoot("Hero")}
|
|
297
|
+
data-wide={layout === "split" ? wide : undefined}
|
|
311
298
|
data-background={background ? true : undefined}
|
|
312
299
|
data-tone={background ? "inverse" : sectionTone(tone)}
|
|
313
300
|
>
|
|
@@ -320,10 +307,12 @@ export function Hero({
|
|
|
320
307
|
<TitledIntro
|
|
321
308
|
eyebrow={eyebrow}
|
|
322
309
|
title={title}
|
|
310
|
+
emphasis={emphasis}
|
|
323
311
|
headingLevel={headingLevel}
|
|
324
312
|
/>
|
|
325
313
|
<p className="m-hero-description">{description}</p>
|
|
326
314
|
<ActionLinks actions={actions} />
|
|
315
|
+
{note && <div className="m-hero-note">{note}</div>}
|
|
327
316
|
</div>
|
|
328
317
|
{images.length > 0 && (
|
|
329
318
|
<div className="m-hero-tiles">
|
|
@@ -928,7 +917,7 @@ export function Footer({
|
|
|
928
917
|
logo,
|
|
929
918
|
description,
|
|
930
919
|
note,
|
|
931
|
-
columns,
|
|
920
|
+
columns = [],
|
|
932
921
|
copyright,
|
|
933
922
|
legal = [],
|
|
934
923
|
itemHeadingLevel = 2,
|
|
@@ -940,30 +929,31 @@ export function Footer({
|
|
|
940
929
|
className?: string;
|
|
941
930
|
brand: string;
|
|
942
931
|
logo?: ReactNode;
|
|
943
|
-
description
|
|
932
|
+
description?: string;
|
|
944
933
|
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
|
|
934
|
+
/** Link columns; each label is the column's visible heading and its navigation name. Centred or inline, the label only names the navigation. */
|
|
935
|
+
columns?: ItemGroup<LinkAction>[];
|
|
947
936
|
copyright: string;
|
|
948
937
|
legal?: LinkAction[];
|
|
949
938
|
/** Level of the column headings. */
|
|
950
939
|
itemHeadingLevel?: HeadingLevel;
|
|
951
940
|
/** Social profiles, usually icon-only; each label names its link. */
|
|
952
941
|
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";
|
|
942
|
+
/** columns: the brand beside headed link columns. center: brand, a row of links and social links, all centred. inline: one compact row of brand, notes, links and copyright that wraps in reading order. */
|
|
943
|
+
layout?: "columns" | "center" | "inline";
|
|
955
944
|
/** A signup row above the copyright, usually a Newsletter; the footer removes its section padding. */
|
|
956
945
|
newsletter?: ReactNode;
|
|
957
946
|
}) {
|
|
958
947
|
const Heading = headingTag(itemHeadingLevel);
|
|
959
948
|
const center = layout === "center";
|
|
949
|
+
const inline = layout === "inline";
|
|
960
950
|
const identity = (
|
|
961
951
|
<>
|
|
962
952
|
<strong className="m-brand">
|
|
963
953
|
<BrandMark logo={logo} />
|
|
964
954
|
{brand}
|
|
965
955
|
</strong>
|
|
966
|
-
<p>{description}</p>
|
|
956
|
+
{description && <p>{description}</p>}
|
|
967
957
|
{note && <p>{note}</p>}
|
|
968
958
|
</>
|
|
969
959
|
);
|
|
@@ -972,8 +962,8 @@ export function Footer({
|
|
|
972
962
|
);
|
|
973
963
|
const navs = columns.map((column) => (
|
|
974
964
|
<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>}
|
|
965
|
+
{/* Centred or inline, the links sit in one row and the label only names the navigation. */}
|
|
966
|
+
{!center && !inline && <Heading>{column.label}</Heading>}
|
|
977
967
|
{column.items.map((link, index) => (
|
|
978
968
|
<KitLink href={link.href} key={`${link.label}:${link.href}:${index}`}>
|
|
979
969
|
{link.label}
|
|
@@ -981,6 +971,34 @@ export function Footer({
|
|
|
981
971
|
))}
|
|
982
972
|
</nav>
|
|
983
973
|
));
|
|
974
|
+
const legalLinks = (
|
|
975
|
+
<div>
|
|
976
|
+
{legal.map((link, index) => (
|
|
977
|
+
<KitLink key={`${link.label}:${link.href}:${index}`} href={link.href}>
|
|
978
|
+
{link.label}
|
|
979
|
+
</KitLink>
|
|
980
|
+
))}
|
|
981
|
+
</div>
|
|
982
|
+
);
|
|
983
|
+
if (inline)
|
|
984
|
+
return (
|
|
985
|
+
<footer
|
|
986
|
+
id={id}
|
|
987
|
+
className={cx("m-footer", className)}
|
|
988
|
+
{...componentRoot("Footer")}
|
|
989
|
+
data-layout="inline"
|
|
990
|
+
>
|
|
991
|
+
{/* One wrapping row; the DOM order is the reading order at every width. */}
|
|
992
|
+
<div className="m-footer-inline">
|
|
993
|
+
{identity}
|
|
994
|
+
{navs}
|
|
995
|
+
{socialLinks}
|
|
996
|
+
<span>{copyright}</span>
|
|
997
|
+
{legal.length > 0 && legalLinks}
|
|
998
|
+
</div>
|
|
999
|
+
{newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
|
|
1000
|
+
</footer>
|
|
1001
|
+
);
|
|
984
1002
|
return (
|
|
985
1003
|
<footer
|
|
986
1004
|
id={id}
|
|
@@ -1006,16 +1024,7 @@ export function Footer({
|
|
|
1006
1024
|
{newsletter && <div className="m-footer-newsletter">{newsletter}</div>}
|
|
1007
1025
|
<div className="m-footer-bottom">
|
|
1008
1026
|
<span>{copyright}</span>
|
|
1009
|
-
|
|
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>
|
|
1027
|
+
{legalLinks}
|
|
1019
1028
|
</div>
|
|
1020
1029
|
</footer>
|
|
1021
1030
|
);
|
package/src/primitives.tsx
CHANGED
|
@@ -444,6 +444,31 @@ function DescribedChoice({
|
|
|
444
444
|
</div>
|
|
445
445
|
);
|
|
446
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Keeps a choice in the described layout once it has had a description or
|
|
449
|
+
* error. Switching back to the plain label would remount the input and drop
|
|
450
|
+
* focus mid-interaction (for example a description that changes with the
|
|
451
|
+
* value). If the layout changes while the input has focus, focus follows it.
|
|
452
|
+
*/
|
|
453
|
+
function useDescribedLayout(
|
|
454
|
+
active: boolean,
|
|
455
|
+
input: { current: HTMLInputElement | null },
|
|
456
|
+
) {
|
|
457
|
+
const [described, setDescribed] = useState(false);
|
|
458
|
+
const refocus = useRef(false);
|
|
459
|
+
if (active && !described) {
|
|
460
|
+
refocus.current =
|
|
461
|
+
typeof document !== "undefined" &&
|
|
462
|
+
!!input.current &&
|
|
463
|
+
input.current === document.activeElement;
|
|
464
|
+
setDescribed(true);
|
|
465
|
+
}
|
|
466
|
+
useLayoutEffect(() => {
|
|
467
|
+
if (refocus.current) input.current?.focus();
|
|
468
|
+
refocus.current = false;
|
|
469
|
+
}, [described, input]);
|
|
470
|
+
return described;
|
|
471
|
+
}
|
|
447
472
|
export function Checkbox({
|
|
448
473
|
label,
|
|
449
474
|
description,
|
|
@@ -458,23 +483,8 @@ export function Checkbox({
|
|
|
458
483
|
error?: string;
|
|
459
484
|
}) {
|
|
460
485
|
const input = useRef<HTMLInputElement>(null);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// it. If the layout changes while the input has focus, focus follows it.
|
|
464
|
-
const [described, setDescribed] = useState(false);
|
|
465
|
-
const refocus = useRef(false);
|
|
466
|
-
if (error && !described) {
|
|
467
|
-
refocus.current =
|
|
468
|
-
typeof document !== "undefined" &&
|
|
469
|
-
!!input.current &&
|
|
470
|
-
input.current === document.activeElement;
|
|
471
|
-
setDescribed(true);
|
|
472
|
-
}
|
|
473
|
-
useLayoutEffect(() => {
|
|
474
|
-
if (refocus.current) input.current?.focus();
|
|
475
|
-
refocus.current = false;
|
|
476
|
-
}, [described]);
|
|
477
|
-
if (description || error || described)
|
|
486
|
+
const described = useDescribedLayout(!!(description || error), input);
|
|
487
|
+
if (described)
|
|
478
488
|
return (
|
|
479
489
|
<DescribedChoice
|
|
480
490
|
component="Checkbox"
|
|
@@ -514,7 +524,9 @@ export function Switch({
|
|
|
514
524
|
/** Secondary line under the label, announced as the description. */
|
|
515
525
|
description?: ReactNode;
|
|
516
526
|
}) {
|
|
517
|
-
|
|
527
|
+
const input = useRef<HTMLInputElement>(null);
|
|
528
|
+
const described = useDescribedLayout(!!description, input);
|
|
529
|
+
if (described)
|
|
518
530
|
return (
|
|
519
531
|
<DescribedChoice
|
|
520
532
|
component="Switch"
|
|
@@ -526,6 +538,7 @@ export function Switch({
|
|
|
526
538
|
<input
|
|
527
539
|
{...props}
|
|
528
540
|
{...attrs}
|
|
541
|
+
ref={input}
|
|
529
542
|
type="checkbox"
|
|
530
543
|
role="switch"
|
|
531
544
|
className="m-switch"
|
|
@@ -535,7 +548,13 @@ export function Switch({
|
|
|
535
548
|
);
|
|
536
549
|
return (
|
|
537
550
|
<label {...componentRoot("Switch")} className={cx("m-choice", className)}>
|
|
538
|
-
<input
|
|
551
|
+
<input
|
|
552
|
+
{...props}
|
|
553
|
+
ref={input}
|
|
554
|
+
type="checkbox"
|
|
555
|
+
role="switch"
|
|
556
|
+
className="m-switch"
|
|
557
|
+
/>
|
|
539
558
|
{label}
|
|
540
559
|
</label>
|
|
541
560
|
);
|