@monospaced/set-skills 0.0.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/README.md +39 -0
- package/package.json +19 -0
- package/src/compose-first/SKILL.md +100 -0
- package/src/compose-first/examples/core/full-page.md +130 -0
- package/src/compose-first/examples/react/full-page.md +104 -0
- package/src/custom-with-tokens/SKILL.md +102 -0
- package/src/custom-with-tokens/examples/core/stepper.md +215 -0
- package/src/custom-with-tokens/examples/react/stepper.md +220 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
## @monospaced/set-skills
|
|
2
|
+
|
|
3
|
+
Markdown guardrails for AI coding agents (and humans) building sites and apps with Set.
|
|
4
|
+
|
|
5
|
+
Each skill is a folder containing a `SKILL.md` (with `name` and `description` frontmatter) plus optional sibling `examples/`, `references/`, and `assets/` folders. The shape is shared by Claude Code and OpenAI Codex, so authoring vendor-neutral skills works in both.
|
|
6
|
+
|
|
7
|
+
## About Set
|
|
8
|
+
|
|
9
|
+
These skills are companion content for the Set design system: `@monospaced/set-{core,assets,config,tokens}` (and `@monospaced/set-react` for React projects), serving the **Monospaced** brand.
|
|
10
|
+
|
|
11
|
+
- **Compositional** — components encode design choices in their props; consumers compose UI from the system's vocabulary rather than authoring custom CSS.
|
|
12
|
+
- **Multi-context** — tokens resolve under theme (light / dark), brand, and surface context automatically.
|
|
13
|
+
- **Brand contexts**:
|
|
14
|
+
- `mnsp` — the public-facing Monospaced brand.
|
|
15
|
+
- `wrfr` — a stripped-back wireframe expression of the same brand (monochrome, no rounding, no motion). Use for wireframes, sketches, and internal tools.
|
|
16
|
+
- `base` — structural foundation layer. Not a brand; consumers don't pick it.
|
|
17
|
+
- **Voice (for content, copy, and naming decisions)** — calm, business-like, time-less; organised and precise.
|
|
18
|
+
|
|
19
|
+
The operational skills below cover _how_ to use the system; this section is just orientation.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pnpm add -D @monospaced/set-skills
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
Copy or symlink the skill folders this package ships into your agent's discovery directory:
|
|
30
|
+
|
|
31
|
+
- **Claude Code** → `.claude/skills/`
|
|
32
|
+
- **OpenAI Codex** → `.agents/skills/`
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
# Example: install all skills for Claude Code
|
|
36
|
+
cp -r node_modules/@monospaced/set-skills/src/* .claude/skills/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Refer to your agent's documentation for the canonical discovery paths.
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@monospaced/set-skills",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Markdown guardrails for AI coding agents (and humans) building sites and apps with Set.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/monospaced/set.git",
|
|
8
|
+
"directory": "packages/skills"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./*": "./src/*"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: compose-first
|
|
3
|
+
description: Use Set's components to build pages and sections, before authoring custom CSS. Read this when generating markup with @monospaced/set-core or @monospaced/set-react.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Compose first
|
|
7
|
+
|
|
8
|
+
Set is a compositional system. The fastest path to a correct, accessible, on-brand UI is to build out of the system's components and let their props express your intent — essentially no custom CSS, only tokens for minor edge-case tweaks. Anything beyond that belongs to the next layer.
|
|
9
|
+
|
|
10
|
+
Inline code in this skill is shown in JSX for compactness. See `@monospaced/set-skills/compose-first/examples/core/` for template-string equivalents and `@monospaced/set-skills/compose-first/examples/react/` for the parallel JSX.
|
|
11
|
+
|
|
12
|
+
## Root is required
|
|
13
|
+
|
|
14
|
+
Every Set app must be wrapped in `Root`. It emits the `.set` scoping class that every component's CSS targets, sets the brand and (optional) theme, and carries language / direction attributes. Without it, components render unstyled.
|
|
15
|
+
|
|
16
|
+
For a top-level app, set `appRoot`:
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<Root appRoot>{/* page composition here */}</Root>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For embedded use (e.g. injecting Set into a portion of a non-Set page), omit `appRoot`. `Root` still scopes its subtree.
|
|
23
|
+
|
|
24
|
+
## Surface (color context)
|
|
25
|
+
|
|
26
|
+
`Surface` sets a color context for nested content. Wrap a region in `Surface` to apply a different context — e.g. a brand-coloured strip inside an otherwise-default page:
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
<Surface variant="brand">
|
|
30
|
+
{/* content renders against the brand surface */}
|
|
31
|
+
</Surface>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`Surface` can scope context across any region — a single strip, a section, or the whole page.
|
|
35
|
+
|
|
36
|
+
Several components (`Box`, `Card`, `Panel`, etc.) also accept a `surface` prop — switch context at the component level without a separate wrapper:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
<Box surface="brand" paddingBlock="xl">
|
|
40
|
+
{/* ... */}
|
|
41
|
+
</Box>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Layout primitives
|
|
45
|
+
|
|
46
|
+
Reach for these first:
|
|
47
|
+
|
|
48
|
+
- `Page` — top-level shell with `banner`, `header`, `children` (main), `footer` slots.
|
|
49
|
+
- `Container` — wraps page-level content with a bounded inline width.
|
|
50
|
+
- `Grid` + `GridItem` — 12-column responsive layout. The primary chassis for main content beyond a single column.
|
|
51
|
+
- `Stack` — vertical group with consistent `gap` (token-driven flex wrapper).
|
|
52
|
+
- `Inline` — horizontal group with consistent `gap` (token-driven flex wrapper).
|
|
53
|
+
- `Box` — a low-level boundary and inset primitive with optional light containment.
|
|
54
|
+
|
|
55
|
+
The canonical main-content chain is `Root` → `Page` → `Container` → `Grid` → `GridItem` → `Stack` / `Inline`. `Box` is a low-level primitive with no fixed position — wrap any step where padding, background, or border is needed (commonly around `Grid` for section padding, around `Stack`/`Inline` inside a `GridItem`, or around individual children). Site furniture (headers, footers, navigation) typically nests more simply — `Container` → `Inline` / `Stack` — with `Box` where padding rhythm is needed.
|
|
56
|
+
|
|
57
|
+
`Stack` and `Inline` hold a single fixed arrangement — `Inline` wraps when it runs out of room, but neither _switches_ arrangement at a breakpoint (e.g. stacked when narrow, side-by-side when wide). Responsive layout change lives in `Grid` / `GridItem`, and that is page-level. A self-contained block that needs its own breakpoint-driven layout switch is a genuine gap — handle it in the custom-with-tokens layer (a local component with container- or media-query CSS), not by forcing `Stack` / `Inline`.
|
|
58
|
+
|
|
59
|
+
## Decision flow
|
|
60
|
+
|
|
61
|
+
When asked to build a page or section:
|
|
62
|
+
|
|
63
|
+
1. **Pages** → use `Page`. Assemble `header` / `children` / `footer` slots from compositions, not custom HTML. The optional `banner` slot expects a `Banner` component (site-wide dismissible announcement).
|
|
64
|
+
2. **Bounded content** → use `Container`. Don't author custom max-width wrappers.
|
|
65
|
+
3. **Main content layout** → use `Grid` + `GridItem`. The 12-column responsive system is the primary chassis for page bodies. `GridItem` is a wrapper around the CSS grid-item layout surface — column/row placement, spans, alignment — configured per-item via props (not via classes on the parent).
|
|
66
|
+
4. **Vertical groups** → use `Stack` with `gap`. Not margins.
|
|
67
|
+
5. **Horizontal groups** → use `Inline` with `gap`. Not flex.
|
|
68
|
+
6. **Insetting content or applying a boundary** → use `Box` (low-level primitive). For higher-level contained content (a styled container as a unit), reach for `Panel`.
|
|
69
|
+
7. **Structural patterns** → use the matching component (cards, navigation, posters, sidebars, etc.) rather than re-rolling. Check the References below for components you may not know about.
|
|
70
|
+
8. **Headings** → use `Heading`. `level` and `size` are deliberately decoupled — pick `level` for semantic hierarchy (no skipped levels), `size` for visual treatment.
|
|
71
|
+
9. **Inline copy or authored paragraphs** → use `Text`. Supports inline (`as: "span"`) and paragraph (`as: "p"`) form, and accepts inline markup (`<em>`, `<strong>`, `<a>`, `<code>`, etc.) as children for emphasis, links, and code.
|
|
72
|
+
10. **Rich text** (raw block markup, typically from markdown or CMS HTML) → use `Prose`. It styles `<h1>`–`<h6>`, `<p>`, lists, tables, etc. per the system's typography. For paragraphs you compose directly in your layout, use `Text` instead.
|
|
73
|
+
11. **Other leaves** (image, icon, link, button, divider, etc.) → use the matching content component instead of raw HTML. Check the References below for components you may not know about.
|
|
74
|
+
|
|
75
|
+
## Smell tests
|
|
76
|
+
|
|
77
|
+
Custom CSS at this layer is at most a one-off, token-based tweak (e.g. nudging a single property by a small amount). If you're about to write more than that, stop. One of two things is true:
|
|
78
|
+
|
|
79
|
+
- You missed a compositional path — re-read the decision flow above, and check the References for components you may not know about.
|
|
80
|
+
- The system doesn't cover this case, so you've left compose-first — move to the next layer (custom with tokens).
|
|
81
|
+
|
|
82
|
+
## When composition isn't enough
|
|
83
|
+
|
|
84
|
+
If a layout genuinely can't be expressed with the existing components, the next layer is custom markup with design tokens. That layer has its own guidance.
|
|
85
|
+
|
|
86
|
+
## Examples
|
|
87
|
+
|
|
88
|
+
Worked examples live alongside this skill:
|
|
89
|
+
|
|
90
|
+
- `@monospaced/set-skills/compose-first/examples/core/` — for `@monospaced/set-core` consumers (SSR / web components / template strings).
|
|
91
|
+
- `@monospaced/set-skills/compose-first/examples/react/` — for `@monospaced/set-react` consumers.
|
|
92
|
+
|
|
93
|
+
Pick the flavor matching your project.
|
|
94
|
+
|
|
95
|
+
## References
|
|
96
|
+
|
|
97
|
+
- Component catalog (live): [Storybook](https://set.monospaced.com/storybook).
|
|
98
|
+
- Component SPECs: imported from `@monospaced/set-core` (e.g. `SET_STACK_SPEC`) — runtime values with top-level `description` and per-prop documentation. Confirms what each component does before you reach for raw HTML.
|
|
99
|
+
- Token catalog (CSS, browseable in your editor): `@monospaced/set-config/set.catalog.css`.
|
|
100
|
+
- Token data (JSON, for agents and tooling): `@monospaced/set-tokens/mnsp`, `@monospaced/set-tokens/base`.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Full page (core)
|
|
2
|
+
|
|
3
|
+
A complete page composition: branded header with nav, a content intro section laid on the 12-column responsive grid, footer. Built entirely from Set components — no custom CSS.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
defineSetNav,
|
|
8
|
+
renderSetBox,
|
|
9
|
+
renderSetButton,
|
|
10
|
+
renderSetContainer,
|
|
11
|
+
renderSetDivider,
|
|
12
|
+
renderSetGrid,
|
|
13
|
+
renderSetGridItem,
|
|
14
|
+
renderSetHeading,
|
|
15
|
+
renderSetInline,
|
|
16
|
+
renderSetLogo,
|
|
17
|
+
renderSetNav,
|
|
18
|
+
renderSetPage,
|
|
19
|
+
renderSetRoot,
|
|
20
|
+
renderSetStack,
|
|
21
|
+
renderSetText,
|
|
22
|
+
} from "@monospaced/set-core";
|
|
23
|
+
|
|
24
|
+
defineSetNav();
|
|
25
|
+
|
|
26
|
+
const page = renderSetPage({
|
|
27
|
+
headerBorder: "scroll",
|
|
28
|
+
headerSize: "lg",
|
|
29
|
+
stickyHeader: "always",
|
|
30
|
+
header: renderSetContainer({
|
|
31
|
+
gutter: "narrow",
|
|
32
|
+
maxInlineSize: "wide",
|
|
33
|
+
children: renderSetBox({
|
|
34
|
+
paddingBlock: "sm",
|
|
35
|
+
paddingInline: "none",
|
|
36
|
+
responsive: true,
|
|
37
|
+
children: renderSetInline({
|
|
38
|
+
gap: "sm",
|
|
39
|
+
justify: "between",
|
|
40
|
+
children: [
|
|
41
|
+
`<a href="/">${renderSetLogo({ label: "Brand", variant: "secondary" })}</a>`,
|
|
42
|
+
renderSetNav({
|
|
43
|
+
collapsible: "belowTablet",
|
|
44
|
+
contentId: "site-nav",
|
|
45
|
+
expanderPosition: "end",
|
|
46
|
+
items: [
|
|
47
|
+
{ current: true, href: "/", label: "Home" },
|
|
48
|
+
{ href: "/work", label: "Work" },
|
|
49
|
+
{ href: "/about", label: "About" },
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
].join(""),
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
}),
|
|
56
|
+
children: renderSetContainer({
|
|
57
|
+
children: renderSetBox({
|
|
58
|
+
paddingBlock: "2xl",
|
|
59
|
+
paddingInline: "none",
|
|
60
|
+
children: renderSetGrid({
|
|
61
|
+
children: renderSetGridItem({
|
|
62
|
+
colSpan: 6,
|
|
63
|
+
colSpanNarrow: 12,
|
|
64
|
+
children: renderSetStack({
|
|
65
|
+
align: "start",
|
|
66
|
+
gap: "lg",
|
|
67
|
+
children: [
|
|
68
|
+
renderSetHeading({
|
|
69
|
+
level: 1,
|
|
70
|
+
responsive: true,
|
|
71
|
+
size: "4xl",
|
|
72
|
+
text: "A heading that anchors the section",
|
|
73
|
+
}),
|
|
74
|
+
renderSetText({
|
|
75
|
+
as: "p",
|
|
76
|
+
children:
|
|
77
|
+
"Supporting copy that introduces the topic. Set's Text component handles the type scale and measure so this paragraph reads well at any viewport.",
|
|
78
|
+
}),
|
|
79
|
+
renderSetButton({
|
|
80
|
+
appearance: "solid",
|
|
81
|
+
label: "Get started",
|
|
82
|
+
size: "lg",
|
|
83
|
+
}),
|
|
84
|
+
].join(""),
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
}),
|
|
90
|
+
footer: renderSetContainer({
|
|
91
|
+
gutter: "narrow",
|
|
92
|
+
maxInlineSize: "wide",
|
|
93
|
+
children: [
|
|
94
|
+
renderSetDivider({ tone: "brand" }),
|
|
95
|
+
renderSetBox({
|
|
96
|
+
paddingBlock: "sm",
|
|
97
|
+
paddingInline: "none",
|
|
98
|
+
responsive: true,
|
|
99
|
+
children: renderSetText({
|
|
100
|
+
as: "p",
|
|
101
|
+
tone: "muted",
|
|
102
|
+
children: "© 2026 Brand",
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
].join(""),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const html = renderSetRoot({
|
|
110
|
+
appOverscrollBehavior: "none",
|
|
111
|
+
appRoot: true,
|
|
112
|
+
children: page,
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## What's compositional here
|
|
117
|
+
|
|
118
|
+
- **Root** wraps the app, emitting the `.set` scoping class every component's CSS targets. `appRoot` marks this as the owning app root.
|
|
119
|
+
- **Page** owns the shell layout. Header is sticky.
|
|
120
|
+
- **Container** bounds the header, main, and footer. The header / footer use a wider `gutter` and `maxInlineSize`; main keeps the default reading width.
|
|
121
|
+
- **Box** carries vertical padding rhythm — `sm` for header, `2xl` for main, `md` for footer.
|
|
122
|
+
- **Inline** lays out the logo and nav with `justify: "between"`.
|
|
123
|
+
- **Nav** is a structural component that handles collapse / expander wiring at narrow viewports.
|
|
124
|
+
- **Grid** + **GridItem** lay the main content on the 12-column responsive system. The item spans 6 columns at the default container threshold and falls back to all 12 at narrow viewports — the breakpoint behavior is built into the primitive.
|
|
125
|
+
- **Stack** stacks the heading, copy, and CTA inside the grid item with a consistent `gap`. No margins.
|
|
126
|
+
- **Heading**, **Text**, **Button**, **Logo**, **Divider** are content leaves.
|
|
127
|
+
|
|
128
|
+
Main content runs the canonical chassis — **Root → Page → Container → Grid → GridItem → Stack → leaves** — with **Box** providing the section padding around the Grid (Box is a flexible primitive, applied wherever padding / background / border is needed; it has no fixed position in the chain). Site furniture (header, footer) rarely needs the column system, so it nests more simply: **Container → Inline → leaves**, again with Box where padding rhythm is needed.
|
|
129
|
+
|
|
130
|
+
No custom CSS.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Full page (react)
|
|
2
|
+
|
|
3
|
+
A complete page composition: branded header with nav, a content intro section laid on the 12-column responsive grid, footer. Built entirely from Set components — no custom CSS.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import {
|
|
7
|
+
Box,
|
|
8
|
+
Button,
|
|
9
|
+
Container,
|
|
10
|
+
Divider,
|
|
11
|
+
Grid,
|
|
12
|
+
GridItem,
|
|
13
|
+
Heading,
|
|
14
|
+
Inline,
|
|
15
|
+
Logo,
|
|
16
|
+
Nav,
|
|
17
|
+
Page,
|
|
18
|
+
Root,
|
|
19
|
+
Stack,
|
|
20
|
+
Text,
|
|
21
|
+
} from "@monospaced/set-react";
|
|
22
|
+
|
|
23
|
+
export function App() {
|
|
24
|
+
return (
|
|
25
|
+
<Root appOverscrollBehavior="none" appRoot>
|
|
26
|
+
<Page
|
|
27
|
+
headerBorder="scroll"
|
|
28
|
+
headerSize="lg"
|
|
29
|
+
stickyHeader="always"
|
|
30
|
+
header={
|
|
31
|
+
<Container gutter="narrow" maxInlineSize="wide">
|
|
32
|
+
<Box paddingBlock="sm" paddingInline="none" responsive>
|
|
33
|
+
<Inline gap="sm" justify="between">
|
|
34
|
+
<a href="/">
|
|
35
|
+
<Logo label="Brand" variant="secondary" />
|
|
36
|
+
</a>
|
|
37
|
+
<Nav
|
|
38
|
+
collapsible="belowTablet"
|
|
39
|
+
contentId="site-nav"
|
|
40
|
+
expanderPosition="end"
|
|
41
|
+
items={[
|
|
42
|
+
{ current: true, href: "/", label: "Home" },
|
|
43
|
+
{ href: "/work", label: "Work" },
|
|
44
|
+
{ href: "/about", label: "About" },
|
|
45
|
+
]}
|
|
46
|
+
/>
|
|
47
|
+
</Inline>
|
|
48
|
+
</Box>
|
|
49
|
+
</Container>
|
|
50
|
+
}
|
|
51
|
+
footer={
|
|
52
|
+
<Container gutter="narrow" maxInlineSize="wide">
|
|
53
|
+
<Divider tone="brand" />
|
|
54
|
+
<Box paddingBlock="sm" paddingInline="none" responsive>
|
|
55
|
+
<Text as="p" tone="muted">
|
|
56
|
+
© 2026 Brand
|
|
57
|
+
</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
</Container>
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<Container>
|
|
63
|
+
<Box paddingBlock="2xl" paddingInline="none">
|
|
64
|
+
<Grid>
|
|
65
|
+
<GridItem colSpan={6} colSpanNarrow={12}>
|
|
66
|
+
<Stack align="start" gap="lg">
|
|
67
|
+
<Heading
|
|
68
|
+
level={1}
|
|
69
|
+
responsive
|
|
70
|
+
size="4xl"
|
|
71
|
+
text="A heading that anchors the section"
|
|
72
|
+
/>
|
|
73
|
+
<Text as="p">
|
|
74
|
+
Supporting copy that introduces the topic. Set's Text
|
|
75
|
+
component handles the type scale and measure so this
|
|
76
|
+
paragraph reads well at any viewport.
|
|
77
|
+
</Text>
|
|
78
|
+
<Button appearance="solid" label="Get started" size="lg" />
|
|
79
|
+
</Stack>
|
|
80
|
+
</GridItem>
|
|
81
|
+
</Grid>
|
|
82
|
+
</Box>
|
|
83
|
+
</Container>
|
|
84
|
+
</Page>
|
|
85
|
+
</Root>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## What's compositional here
|
|
91
|
+
|
|
92
|
+
- **Root** wraps the app, emitting the `.set` scoping class every component's CSS targets. `appRoot` marks this as the owning app root.
|
|
93
|
+
- **Page** owns the shell layout. Header is sticky.
|
|
94
|
+
- **Container** bounds the header, main, and footer. The header / footer use a wider `gutter` and `maxInlineSize`; main keeps the default reading width.
|
|
95
|
+
- **Box** carries vertical padding rhythm — `sm` for header, `2xl` for main, `md` for footer.
|
|
96
|
+
- **Inline** lays out the logo and nav with `justify="between"`.
|
|
97
|
+
- **Nav** is a structural component that handles collapse / expander wiring at narrow viewports.
|
|
98
|
+
- **Grid** + **GridItem** lay the main content on the 12-column responsive system. The item spans 6 columns at the default container threshold and falls back to all 12 at narrow viewports — the breakpoint behavior is built into the primitive.
|
|
99
|
+
- **Stack** stacks the heading, copy, and CTA inside the grid item with a consistent `gap`. No margins.
|
|
100
|
+
- **Heading**, **Text**, **Button**, **Logo**, **Divider** are content leaves.
|
|
101
|
+
|
|
102
|
+
Main content runs the canonical chassis — **Root → Page → Container → Grid → GridItem → Stack → leaves** — with **Box** providing the section padding around the Grid (Box is a flexible primitive, applied wherever padding / background / border is needed; it has no fixed position in the chain). Site furniture (header, footer) rarely needs the column system, so it nests more simply: **Container → Inline → leaves**, again with Box where padding rhythm is needed.
|
|
103
|
+
|
|
104
|
+
No custom CSS.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: custom-with-tokens
|
|
3
|
+
description: When the system has a gap — a missing component, variant, or pattern — fill it using Set's existing components for everything that fits, and tokens for any residual CSS values. Read this when compose-first has run out and you're about to author custom markup.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Custom with tokens
|
|
7
|
+
|
|
8
|
+
The fastest path through a gap is _with_ the system, not around it.
|
|
9
|
+
|
|
10
|
+
## When this applies
|
|
11
|
+
|
|
12
|
+
You've worked through the compose-first decision flow and one of two things is true:
|
|
13
|
+
|
|
14
|
+
- The system genuinely doesn't cover the case (e.g. a Stepper — see the worked example below).
|
|
15
|
+
- The case wants a visual treatment outside any existing component's prop surface.
|
|
16
|
+
- A self-contained block needs breakpoint-driven layout that no prop exposes — `Stack` / `Inline` hold one arrangement (`Inline` only wraps) and `Grid` / `GridItem` responsiveness is page-level, so component-local layout switching is a genuine gap.
|
|
17
|
+
|
|
18
|
+
If you haven't checked compose-first first, do that. Many "I need custom markup/CSS" intuitions evaporate once you find the right component.
|
|
19
|
+
|
|
20
|
+
## The layered model
|
|
21
|
+
|
|
22
|
+
When you fill a gap:
|
|
23
|
+
|
|
24
|
+
1. **Reuse existing components.** Inside your custom markup, reach for Set components wherever they apply — `Icon`, `Button`, `Heading`, `Text`, `Link`, `Divider`, `Image`, etc. Many leaves you'd reach for in plain HTML have a Set equivalent that handles a11y, sizing, and brand defaults for free. Check the References below for components you may not know about.
|
|
25
|
+
2. **Write custom markup + CSS** for the parts no component covers — the structure, the wiring, the bespoke piece.
|
|
26
|
+
3. **Reference tokens for design values.** Colors, spacing, dimensions, typography, motion, shadows, opacity, etc. — reach for `var(--set-*)` whenever the system has a token. Tokens resolve under theme (light / dark), brand, and surface context automatically; responsive variants (typography, layout spacing) carry breakpoint scale baked in. Raw values lose all of that. See the References below for the catalog.
|
|
27
|
+
|
|
28
|
+
The order matters. Tokens won't backfill what a component would have given you (a11y, sizing, brand defaults). Reach for the component first; only diverge when the design genuinely needs something outside its prop surface.
|
|
29
|
+
|
|
30
|
+
## Picking the right token
|
|
31
|
+
|
|
32
|
+
The published token catalog ships at `@monospaced/set-config/set.catalog.css` — a `:root` block listing every `--set-*` token with its resolved value and `$description`. The same data ships as DTCG JSON via `@monospaced/set-tokens/{mnsp,base}` if your tooling wants tokens as data.
|
|
33
|
+
|
|
34
|
+
Tokens are organised by category prefix (e.g. `--set-color-*`, `--set-spacing-*`, `--set-radius-*`). The category narrows the search; the `$description` on each token confirms semantic intent — read it before reaching for a token whose name sounds right.
|
|
35
|
+
|
|
36
|
+
If the right token feels missing for your case, that may signal a system gap worth surfacing as an issue rather than an opportunity to reach for a literal value.
|
|
37
|
+
|
|
38
|
+
## Accessibility
|
|
39
|
+
|
|
40
|
+
All UI built with Set must meet [WCAG 2.2 AA](https://www.w3.org/TR/WCAG22/) — the same bar the system holds itself to internally. A few points specific to Set worth highlighting:
|
|
41
|
+
|
|
42
|
+
- **Color contrast follows from tokens.** Set's color tokens are tuned to meet WCAG AA contrast minimums for their _intended pairings_. Each token's `$description` names its role — read it to verify the pairing fits. Arbitrary `--set-color-*` combinations don't automatically satisfy AA.
|
|
43
|
+
- **`.visually-hidden` for SR-only context.** Set ships a `.visually-hidden` utility class from `Root`. Use it for screen-reader-only text — accessible names for icon-only controls, state announcements (e.g. "step complete"), context that's redundant visually but needed for SR. Prefer a visible label, then `.visually-hidden`, then `aria-label` (last resort).
|
|
44
|
+
- **Don't remove or recolour the focus indicator.** Set's `Root` CSS provides a focus-visible indicator on every interactive element — don't remove it, and don't change its color. Adjusting offset / inset / radius to fit a custom shape is fine. Wire your custom logic to keyboard events the same way you wire it to clicks.
|
|
45
|
+
|
|
46
|
+
Refer to [Using ARIA](https://www.w3.org/TR/using-aria/) before adding ARIA attributes. When implementing ARIA patterns, the APG below is the canonical reference — with caveats.
|
|
47
|
+
|
|
48
|
+
### ARIA Authoring Practices Guide (APG) patterns
|
|
49
|
+
|
|
50
|
+
The [APG patterns](https://www.w3.org/WAI/ARIA/apg/patterns/) are a partially trusted source — use selectively.
|
|
51
|
+
|
|
52
|
+
**CAN reference:**
|
|
53
|
+
|
|
54
|
+
- Content in "About This Interaction" sections
|
|
55
|
+
- Content in "Keyboard Interaction" sections
|
|
56
|
+
|
|
57
|
+
**CANNOT reference:**
|
|
58
|
+
|
|
59
|
+
- Code examples from APG
|
|
60
|
+
- CodePen links (URLs starting with `https://codepen.io/`)
|
|
61
|
+
|
|
62
|
+
## CSS architecture
|
|
63
|
+
|
|
64
|
+
Match the system's CSS authoring pattern so your custom rules co-exist cleanly with system rules:
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
:where(.set) .app-step-indicator {
|
|
68
|
+
/* base styles */
|
|
69
|
+
|
|
70
|
+
.step {
|
|
71
|
+
/* descendant styles */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **`:where(.set)`** scopes selectors to a Set `Root`. Your custom components must render inside a Set `Root` (or any descendant of one) — the `Root` applies the system's foundational styles (typography, color context, brand) that your custom CSS inherits and composes against, and provides the `.set` ancestor the `:where(.set)` selector matches. `:where()` contributes zero specificity, so the root selector sits at `0,1,0` — matching how the system scopes its own rules. Nested descendant and state selectors follow normal CSS specificity from there. Combined with standard CSS source order (e.g. your custom CSS loaded after `@monospaced/set-core/styles.css`), conflicts resolve predictably in your favour.
|
|
77
|
+
- **Project-prefixed class names** (`.app-*`, `.my-*`, anything but `.set-*`) avoid collisions with system class names. Prefix the component-level class; nest descendants unprefixed (mirroring how the system authors its own components).
|
|
78
|
+
|
|
79
|
+
## Smell tests
|
|
80
|
+
|
|
81
|
+
You've gone past "filling a gap" and started building parallel infrastructure when:
|
|
82
|
+
|
|
83
|
+
- You're using raw values (e.g. `16px`, `#ffaa00`, `0.5s`) or wrong-category tokens (e.g. `padding: var(--set-color-foreground-default)`) for design values. Lint catches most foot-guns but has gaps. Every design value should resolve to a `var(--set-*)` whose name matches the property's intent.
|
|
84
|
+
- You're writing CSS that targets Set's `.set-*` component classes or any of their descendants. Don't override Set component internals from the consumer side — ever. Use the component's prop surface, switch surface context, or fill the gap with a custom `.app-*` component instead.
|
|
85
|
+
- Your tokens look like a parallel theming layer with `--set-` prefixes you defined yourself. Don't squat on the namespace; pick `--my-*` or your own app/project prefix for any project-specific custom properties.
|
|
86
|
+
- You're disabling lint rules to ship. The disable comment should carry rationale that survives review — if it'd surprise a future reader, the right move is usually a system change (a missing token, a refined component) rather than a permanent escape.
|
|
87
|
+
|
|
88
|
+
## Examples
|
|
89
|
+
|
|
90
|
+
Worked examples live alongside this skill:
|
|
91
|
+
|
|
92
|
+
- `@monospaced/set-skills/custom-with-tokens/examples/core/` — for `@monospaced/set-core` consumers (SSR / web components / template strings).
|
|
93
|
+
- `@monospaced/set-skills/custom-with-tokens/examples/react/` — for `@monospaced/set-react` consumers.
|
|
94
|
+
|
|
95
|
+
Pick the flavor matching your project.
|
|
96
|
+
|
|
97
|
+
## References
|
|
98
|
+
|
|
99
|
+
- Token catalog (CSS, browseable in your editor): `@monospaced/set-config/set.catalog.css`.
|
|
100
|
+
- Token data (JSON, for agents and tooling): `@monospaced/set-tokens/mnsp`, `@monospaced/set-tokens/base`.
|
|
101
|
+
- Component catalog (live): [Storybook](https://set.monospaced.com/storybook).
|
|
102
|
+
- Component SPECs: imported from `@monospaced/set-core` (e.g. `SET_BUTTON_SPEC`) — runtime values with top-level `description` and per-prop documentation. Confirms what each component does before you reach for raw HTML.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Stepper (core)
|
|
2
|
+
|
|
3
|
+
Multi-step process indicator (onboarding, checkout, multi-page form). A row of numbered steps with `done`, `current`, and `upcoming` visual states, connected by a baseline. Set doesn't ship one, so it's a clear gap to fill.
|
|
4
|
+
|
|
5
|
+
The example exercises the layered model end-to-end: a leaf component for the done-state checkmark, custom markup for the structural pattern, tokens for every design value (with one component-scoped custom property composed from tokens), and `@keyframes` driving state transitions through motion tokens.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { renderSetIcon, renderSetRoot } from "@monospaced/set-core";
|
|
9
|
+
|
|
10
|
+
type Step = { label: string; number: number };
|
|
11
|
+
type StepState = "current" | "done" | "upcoming";
|
|
12
|
+
|
|
13
|
+
const steps: Step[] = [
|
|
14
|
+
{ number: 1, label: "Plan" },
|
|
15
|
+
{ number: 2, label: "Build" },
|
|
16
|
+
{ number: 3, label: "Ship" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function getStepState(index: number, currentStep: number): StepState {
|
|
20
|
+
if (currentStep >= steps.length) return "done";
|
|
21
|
+
if (index < currentStep) return "done";
|
|
22
|
+
if (index === currentStep) return "current";
|
|
23
|
+
return "upcoming";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderStateLabel(state: StepState): string {
|
|
27
|
+
if (state === "done")
|
|
28
|
+
return '<span class="visually-hidden">, completed</span>';
|
|
29
|
+
if (state === "current")
|
|
30
|
+
return '<span class="visually-hidden">, current step</span>';
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderStepper({ currentStep }: { currentStep: number }): string {
|
|
35
|
+
const items = steps
|
|
36
|
+
.map(({ number, label }, index) => {
|
|
37
|
+
const state = getStepState(index, currentStep);
|
|
38
|
+
const ariaCurrent = state === "current" ? ' aria-current="step"' : "";
|
|
39
|
+
|
|
40
|
+
return `
|
|
41
|
+
<li class="step" data-state="${state}"${ariaCurrent}>
|
|
42
|
+
<span class="bubble" aria-hidden="true">
|
|
43
|
+
<span class="bubble-number">${number}</span>
|
|
44
|
+
<span class="bubble-icon">${renderSetIcon({
|
|
45
|
+
ariaHidden: true,
|
|
46
|
+
name: "check",
|
|
47
|
+
size: "xs",
|
|
48
|
+
})}</span>
|
|
49
|
+
</span>
|
|
50
|
+
<span class="label">${label}${renderStateLabel(state)}</span>
|
|
51
|
+
</li>
|
|
52
|
+
`;
|
|
53
|
+
})
|
|
54
|
+
.join("");
|
|
55
|
+
|
|
56
|
+
return `<ol class="app-stepper">${items}</ol>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Render inside a Set Root — the `.set` ancestor scopes both system
|
|
60
|
+
// CSS and your `:where(.set) .app-stepper` selector.
|
|
61
|
+
const html = renderSetRoot({
|
|
62
|
+
appRoot: true,
|
|
63
|
+
children: renderStepper({ currentStep: 1 }),
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
@keyframes app-stepper-check-in {
|
|
69
|
+
from {
|
|
70
|
+
opacity: 0;
|
|
71
|
+
scale: 0.7;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
to {
|
|
75
|
+
opacity: 1;
|
|
76
|
+
scale: 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
:where(.set) .app-stepper {
|
|
81
|
+
--app-stepper-size: var(--set-spacing-vertical-800);
|
|
82
|
+
|
|
83
|
+
display: flex;
|
|
84
|
+
list-style: none;
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding-inline: 0;
|
|
87
|
+
|
|
88
|
+
.bubble {
|
|
89
|
+
background-color: var(--set-color-background-subtle);
|
|
90
|
+
block-size: var(--app-stepper-size);
|
|
91
|
+
border-radius: calc(var(--app-stepper-size) * var(--set-radius-ratio-lg));
|
|
92
|
+
color: var(--set-color-foreground-muted-text);
|
|
93
|
+
display: grid;
|
|
94
|
+
font: var(--set-typography-text-body-xs);
|
|
95
|
+
font-variant-numeric: tabular-nums;
|
|
96
|
+
font-weight: var(--set-typography-font-weight-bold);
|
|
97
|
+
inline-size: var(--app-stepper-size);
|
|
98
|
+
place-items: center;
|
|
99
|
+
position: relative;
|
|
100
|
+
transition:
|
|
101
|
+
background-color var(--set-motion-duration-200)
|
|
102
|
+
var(--set-motion-easing-ease-in-out),
|
|
103
|
+
color var(--set-motion-duration-200) var(--set-motion-easing-ease-in-out);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.bubble-icon,
|
|
107
|
+
.bubble-number {
|
|
108
|
+
grid-area: 1 / 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.bubble-icon {
|
|
112
|
+
opacity: 0;
|
|
113
|
+
scale: 0.7;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.label {
|
|
117
|
+
color: var(--set-color-foreground-muted-text);
|
|
118
|
+
font: var(--set-typography-text-body-xs);
|
|
119
|
+
font-weight: var(--set-typography-font-weight-medium);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.step {
|
|
123
|
+
display: grid;
|
|
124
|
+
flex: 1;
|
|
125
|
+
gap: var(--set-spacing-vertical-400);
|
|
126
|
+
isolation: isolate;
|
|
127
|
+
justify-items: center;
|
|
128
|
+
position: relative;
|
|
129
|
+
text-align: center;
|
|
130
|
+
|
|
131
|
+
&::after {
|
|
132
|
+
background-color: var(--set-color-border-subtle);
|
|
133
|
+
block-size: var(--set-layout-border-width-thick);
|
|
134
|
+
content: "";
|
|
135
|
+
inline-size: 100%;
|
|
136
|
+
inset-block-start: calc(
|
|
137
|
+
(var(--app-stepper-size) / 2) -
|
|
138
|
+
(var(--set-layout-border-width-thick) / 2)
|
|
139
|
+
);
|
|
140
|
+
inset-inline-start: 50%;
|
|
141
|
+
position: absolute;
|
|
142
|
+
z-index: -1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&:first-child {
|
|
146
|
+
justify-items: start;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
&:first-child::after {
|
|
150
|
+
inline-size: calc(150% - (var(--app-stepper-size) / 2));
|
|
151
|
+
inset-inline-start: calc(var(--app-stepper-size) / 2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
&:first-child .label {
|
|
155
|
+
translate: calc((var(--app-stepper-size) - 100%) / 2) 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&:nth-last-child(2)::after {
|
|
159
|
+
inline-size: calc(150% - (var(--app-stepper-size) / 2));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
&:last-child {
|
|
163
|
+
justify-items: end;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
&:last-child::after {
|
|
167
|
+
display: none;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
&:last-child .label {
|
|
171
|
+
translate: calc((100% - var(--app-stepper-size)) / 2) 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
&[data-state="done"]::after {
|
|
175
|
+
background-color: var(--set-color-foreground-default);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.step[data-state="current"] .bubble,
|
|
180
|
+
.step[data-state="done"] .bubble {
|
|
181
|
+
background-color: var(--set-color-foreground-default);
|
|
182
|
+
color: var(--set-color-foreground-contrast);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.step[data-state="done"] .bubble-icon {
|
|
186
|
+
animation: app-stepper-check-in var(--set-motion-duration-200)
|
|
187
|
+
var(--set-motion-easing-ease-out);
|
|
188
|
+
opacity: 1;
|
|
189
|
+
scale: 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.step[data-state="done"] .bubble-number {
|
|
193
|
+
opacity: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.step[data-state="current"] .label,
|
|
197
|
+
.step[data-state="done"] .label {
|
|
198
|
+
color: var(--set-color-foreground-default);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
What sits where:
|
|
204
|
+
|
|
205
|
+
- **Leaf component for the content node.** `renderSetIcon` for the done-state checkmark — Set handles its sizing, color inheritance, and a11y. The number and the icon both render unconditionally; CSS reveals the right one for the state via `opacity` + `@keyframes`.
|
|
206
|
+
- **Custom markup expresses the structural pattern.** The `<ol>` / `<li>` / `<span>` skeleton captures "row of state-driven circles connected by a baseline." `aria-current="step"` carries the active-step semantics for screen readers; the bubble is `aria-hidden` because state is conveyed by both `aria-current` and the `.visually-hidden` text appended to the label (`, completed` / `, current step`). `.visually-hidden` is a Set utility class shipped from Root — no need to redefine.
|
|
207
|
+
- **Why not `renderSetText` for numbers and labels?** Text's prop surface doesn't expose `font-weight` or `font-variant-numeric`, and the design needs specific weights (bold for numbers, medium for labels) and tabular figures for the bubble's number to align across states. This is exactly the second branch the skill names in "When this applies" — _the case wants a visual treatment outside any existing component's prop surface_. Drop to a plain `<span>` and reach for typography composites + targeted overrides instead.
|
|
208
|
+
- **Composite typography token + targeted overrides.** `font: var(--set-typography-text-body-xs)` carries the system's full body-xs spec (size, family, line-height) in one declaration. Specific properties then override only the deviations: `font-weight` switches per role (`bold` / `medium`); `font-variant-numeric: tabular-nums` is a CSS keyword (no token surface) that aligns digits across states. Reaching for the composite first keeps you on the system's typography path; overrides surface only the genuine deviations.
|
|
209
|
+
- **Custom CSS, structural.** `display: grid` / `display: flex`, `position: relative` / `absolute`, `place-items: center`, `isolation: isolate` (creates a stacking context so the connector line's `z-index: -1` doesn't escape), edge-aware `:first-child` / `:last-child` rules for row alignment, `border-radius` derived through `calc()`.
|
|
210
|
+
- **Tokens for every design value.** Spacing (`--set-spacing-vertical-{400,800}`), colors (`--set-color-{foreground,background,border}-*`), border (`--set-layout-border-width-thick`), radius via `--set-radius-ratio-lg` composed against the component-scoped size, typography (`--set-typography-text-body-xs` composite, `--set-typography-font-weight-{bold,medium}` overrides), motion (`--set-motion-duration-200`, `--set-motion-easing-ease-{in-out,out}`).
|
|
211
|
+
- **Component-scoped custom property.** `--app-stepper-size` is defined at the component-level selector and consumed by descendants in the same file. The same-file scope keeps it local to the component (the `set/set-known-tokens` lint rule accepts same-file definitions). Tokens still drive the value (`var(--set-spacing-vertical-800)`); the custom property just gives the component one knob to compose around.
|
|
212
|
+
- **Animation through motion tokens.** `@keyframes app-stepper-check-in` defines the entrance transition; `animation: app-stepper-check-in var(--set-motion-duration-200) var(--set-motion-easing-ease-out)` runs it with the system's motion timing.
|
|
213
|
+
- **CSS architecture.** Selector wrapped in `:where(.set)` so the root sits at `0,1,0` (matching system rules); top-level class is project-prefixed (`.app-stepper`); descendants are nested unprefixed.
|
|
214
|
+
|
|
215
|
+
The `data-state` attribute is the seam between markup and styling. State semantics live on the element; state visuals are CSS rules keyed off the attribute. Adding a fourth state (e.g. `error`) is one CSS block referencing `--set-color-status-error-*` and one new value in the data.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Stepper (React)
|
|
2
|
+
|
|
3
|
+
Multi-step process indicator (onboarding, checkout, multi-page form). A row of numbered steps with `done`, `current`, and `upcoming` visual states, connected by a baseline. Set doesn't ship one, so it's a clear gap to fill.
|
|
4
|
+
|
|
5
|
+
The example exercises the layered model end-to-end: a leaf component for the done-state checkmark, custom markup for the structural pattern, tokens for every design value (with one component-scoped custom property composed from tokens), and `@keyframes` driving state transitions through motion tokens.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { Icon, Root } from "@monospaced/set-react";
|
|
9
|
+
|
|
10
|
+
type Step = { label: string; number: number };
|
|
11
|
+
type StepState = "current" | "done" | "upcoming";
|
|
12
|
+
|
|
13
|
+
const steps: Step[] = [
|
|
14
|
+
{ number: 1, label: "Plan" },
|
|
15
|
+
{ number: 2, label: "Build" },
|
|
16
|
+
{ number: 3, label: "Ship" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function getStepState(index: number, currentStep: number): StepState {
|
|
20
|
+
if (currentStep >= steps.length) return "done";
|
|
21
|
+
if (index < currentStep) return "done";
|
|
22
|
+
if (index === currentStep) return "current";
|
|
23
|
+
return "upcoming";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Stepper({ currentStep }: { currentStep: number }) {
|
|
27
|
+
return (
|
|
28
|
+
<ol className="app-stepper">
|
|
29
|
+
{steps.map(({ number, label }, index) => {
|
|
30
|
+
const state = getStepState(index, currentStep);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<li
|
|
34
|
+
key={number}
|
|
35
|
+
className="step"
|
|
36
|
+
data-state={state}
|
|
37
|
+
{...(state === "current" ? { "aria-current": "step" } : {})}
|
|
38
|
+
>
|
|
39
|
+
<span className="bubble" aria-hidden="true">
|
|
40
|
+
<span className="bubble-number">{number}</span>
|
|
41
|
+
<span className="bubble-icon">
|
|
42
|
+
<Icon name="check" size="xs" />
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
<span className="label">
|
|
46
|
+
{label}
|
|
47
|
+
{state === "done" ? (
|
|
48
|
+
<span className="visually-hidden">, completed</span>
|
|
49
|
+
) : null}
|
|
50
|
+
{state === "current" ? (
|
|
51
|
+
<span className="visually-hidden">, current step</span>
|
|
52
|
+
) : null}
|
|
53
|
+
</span>
|
|
54
|
+
</li>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</ol>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Render inside a Set Root — the `.set` ancestor scopes both system
|
|
62
|
+
// CSS and your `:where(.set) .app-stepper` selector.
|
|
63
|
+
function App() {
|
|
64
|
+
return (
|
|
65
|
+
<Root appRoot>
|
|
66
|
+
<Stepper currentStep={1} />
|
|
67
|
+
</Root>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```css
|
|
73
|
+
@keyframes app-stepper-check-in {
|
|
74
|
+
from {
|
|
75
|
+
opacity: 0;
|
|
76
|
+
scale: 0.7;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
to {
|
|
80
|
+
opacity: 1;
|
|
81
|
+
scale: 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
:where(.set) .app-stepper {
|
|
86
|
+
--app-stepper-size: var(--set-spacing-vertical-800);
|
|
87
|
+
|
|
88
|
+
display: flex;
|
|
89
|
+
list-style: none;
|
|
90
|
+
margin: 0;
|
|
91
|
+
padding-inline: 0;
|
|
92
|
+
|
|
93
|
+
.bubble {
|
|
94
|
+
background-color: var(--set-color-background-subtle);
|
|
95
|
+
block-size: var(--app-stepper-size);
|
|
96
|
+
border-radius: calc(var(--app-stepper-size) * var(--set-radius-ratio-lg));
|
|
97
|
+
color: var(--set-color-foreground-muted-text);
|
|
98
|
+
display: grid;
|
|
99
|
+
font: var(--set-typography-text-body-xs);
|
|
100
|
+
font-variant-numeric: tabular-nums;
|
|
101
|
+
font-weight: var(--set-typography-font-weight-bold);
|
|
102
|
+
inline-size: var(--app-stepper-size);
|
|
103
|
+
place-items: center;
|
|
104
|
+
position: relative;
|
|
105
|
+
transition:
|
|
106
|
+
background-color var(--set-motion-duration-200)
|
|
107
|
+
var(--set-motion-easing-ease-in-out),
|
|
108
|
+
color var(--set-motion-duration-200) var(--set-motion-easing-ease-in-out);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.bubble-icon,
|
|
112
|
+
.bubble-number {
|
|
113
|
+
grid-area: 1 / 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.bubble-icon {
|
|
117
|
+
opacity: 0;
|
|
118
|
+
scale: 0.7;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.label {
|
|
122
|
+
color: var(--set-color-foreground-muted-text);
|
|
123
|
+
font: var(--set-typography-text-body-xs);
|
|
124
|
+
font-weight: var(--set-typography-font-weight-medium);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.step {
|
|
128
|
+
display: grid;
|
|
129
|
+
flex: 1;
|
|
130
|
+
gap: var(--set-spacing-vertical-400);
|
|
131
|
+
isolation: isolate;
|
|
132
|
+
justify-items: center;
|
|
133
|
+
position: relative;
|
|
134
|
+
text-align: center;
|
|
135
|
+
|
|
136
|
+
&::after {
|
|
137
|
+
background-color: var(--set-color-border-subtle);
|
|
138
|
+
block-size: var(--set-layout-border-width-thick);
|
|
139
|
+
content: "";
|
|
140
|
+
inline-size: 100%;
|
|
141
|
+
inset-block-start: calc(
|
|
142
|
+
(var(--app-stepper-size) / 2) -
|
|
143
|
+
(var(--set-layout-border-width-thick) / 2)
|
|
144
|
+
);
|
|
145
|
+
inset-inline-start: 50%;
|
|
146
|
+
position: absolute;
|
|
147
|
+
z-index: -1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
&:first-child {
|
|
151
|
+
justify-items: start;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
&:first-child::after {
|
|
155
|
+
inline-size: calc(150% - (var(--app-stepper-size) / 2));
|
|
156
|
+
inset-inline-start: calc(var(--app-stepper-size) / 2);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
&:first-child .label {
|
|
160
|
+
translate: calc((var(--app-stepper-size) - 100%) / 2) 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&:nth-last-child(2)::after {
|
|
164
|
+
inline-size: calc(150% - (var(--app-stepper-size) / 2));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
&:last-child {
|
|
168
|
+
justify-items: end;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
&:last-child::after {
|
|
172
|
+
display: none;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
&:last-child .label {
|
|
176
|
+
translate: calc((100% - var(--app-stepper-size)) / 2) 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
&[data-state="done"]::after {
|
|
180
|
+
background-color: var(--set-color-foreground-default);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.step[data-state="current"] .bubble,
|
|
185
|
+
.step[data-state="done"] .bubble {
|
|
186
|
+
background-color: var(--set-color-foreground-default);
|
|
187
|
+
color: var(--set-color-foreground-contrast);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.step[data-state="done"] .bubble-icon {
|
|
191
|
+
animation: app-stepper-check-in var(--set-motion-duration-200)
|
|
192
|
+
var(--set-motion-easing-ease-out);
|
|
193
|
+
opacity: 1;
|
|
194
|
+
scale: 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.step[data-state="done"] .bubble-number {
|
|
198
|
+
opacity: 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.step[data-state="current"] .label,
|
|
202
|
+
.step[data-state="done"] .label {
|
|
203
|
+
color: var(--set-color-foreground-default);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
What sits where:
|
|
209
|
+
|
|
210
|
+
- **Leaf component for the content node.** `Icon` for the done-state checkmark — Set handles its sizing, color inheritance, and a11y. The number and the icon both render unconditionally; CSS reveals the right one for the state via `opacity` + `@keyframes`.
|
|
211
|
+
- **Custom markup expresses the structural pattern.** The `ol` / `li` / `span` skeleton captures "row of state-driven circles connected by a baseline." `aria-current="step"` carries the active-step semantics for screen readers; the bubble is `aria-hidden` because state is conveyed by both `aria-current` and the `.visually-hidden` text appended to the label (`, completed` / `, current step`). `.visually-hidden` is a Set utility class shipped from Root — no need to redefine.
|
|
212
|
+
- **Why not `Text` for numbers and labels?** Text's prop surface doesn't expose `font-weight` or `font-variant-numeric`, and the design needs specific weights (bold for numbers, medium for labels) and tabular figures for the bubble's number to align across states. This is exactly the second branch the skill names in "When this applies" — _the case wants a visual treatment outside any existing component's prop surface_. Drop to a plain `<span>` and reach for typography composites + targeted overrides instead.
|
|
213
|
+
- **Composite typography token + targeted overrides.** `font: var(--set-typography-text-body-xs)` carries the system's full body-xs spec (size, family, line-height) in one declaration. Specific properties then override only the deviations: `font-weight` switches per role (`bold` / `medium`); `font-variant-numeric: tabular-nums` is a CSS keyword (no token surface) that aligns digits across states. Reaching for the composite first keeps you on the system's typography path; overrides surface only the genuine deviations.
|
|
214
|
+
- **Custom CSS, structural.** `display: grid` / `display: flex`, `position: relative` / `absolute`, `place-items: center`, `isolation: isolate` (creates a stacking context so the connector line's `z-index: -1` doesn't escape), edge-aware `:first-child` / `:last-child` rules for row alignment, `border-radius` derived through `calc()`.
|
|
215
|
+
- **Tokens for every design value.** Spacing (`--set-spacing-vertical-{400,800}`), colors (`--set-color-{foreground,background,border}-*`), border (`--set-layout-border-width-thick`), radius via `--set-radius-ratio-lg` composed against the component-scoped size, typography (`--set-typography-text-body-xs` composite, `--set-typography-font-weight-{bold,medium}` overrides), motion (`--set-motion-duration-200`, `--set-motion-easing-ease-{in-out,out}`).
|
|
216
|
+
- **Component-scoped custom property.** `--app-stepper-size` is defined at the component-level selector and consumed by descendants in the same file. The same-file scope keeps it local to the component (the `set/set-known-tokens` lint rule accepts same-file definitions). Tokens still drive the value (`var(--set-spacing-vertical-800)`); the custom property just gives the component one knob to compose around.
|
|
217
|
+
- **Animation through motion tokens.** `@keyframes app-stepper-check-in` defines the entrance transition; `animation: app-stepper-check-in var(--set-motion-duration-200) var(--set-motion-easing-ease-out)` runs it with the system's motion timing.
|
|
218
|
+
- **CSS architecture.** Selector wrapped in `:where(.set)` so the root sits at `0,1,0` (matching system rules); top-level class is project-prefixed (`.app-stepper`); descendants are nested unprefixed.
|
|
219
|
+
|
|
220
|
+
The `data-state` attribute is the seam between markup and styling. State semantics live on the element; state visuals are CSS rules keyed off the attribute. Adding a fourth state (e.g. `error`) is one CSS block referencing `--set-color-status-error-*` and one new value in the data.
|