lucent-ui 0.37.0 → 0.39.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.
@@ -0,0 +1,298 @@
1
+ import { z } from 'zod';
2
+ import { ALL_MANIFESTS } from './registry.js';
3
+ import { ALL_PATTERNS } from './pattern-registry.js';
4
+ import { PALETTES, SHAPES, DENSITIES, SHADOWS, COMBINED, generatePresetConfig } from './presets.js';
5
+ import { DESIGN_RULES, DESIGN_RULES_SUMMARY } from './design-rules.js';
6
+ import { withLogging } from './logger.js';
7
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
8
+ function findManifest(nameOrId) {
9
+ const q = nameOrId.trim().toLowerCase();
10
+ return ALL_MANIFESTS.find((m) => m.id.toLowerCase() === q || m.name.toLowerCase() === q);
11
+ }
12
+ function scoreManifest(m, query) {
13
+ const q = query.toLowerCase();
14
+ let score = 0;
15
+ if (m.name.toLowerCase().includes(q))
16
+ score += 10;
17
+ if (m.id.toLowerCase().includes(q))
18
+ score += 8;
19
+ if (m.tier.toLowerCase().includes(q))
20
+ score += 5;
21
+ if (m.description.toLowerCase().includes(q))
22
+ score += 4;
23
+ if (m.designIntent.toLowerCase().includes(q))
24
+ score += 3;
25
+ for (const p of m.props) {
26
+ if (p.name.toLowerCase().includes(q))
27
+ score += 2;
28
+ if (p.description.toLowerCase().includes(q))
29
+ score += 1;
30
+ }
31
+ return score;
32
+ }
33
+ function findPattern(nameOrId) {
34
+ const q = nameOrId.trim().toLowerCase();
35
+ return ALL_PATTERNS.find((r) => r.id.toLowerCase() === q || r.name.toLowerCase() === q);
36
+ }
37
+ function scorePattern(r, query) {
38
+ const q = query.toLowerCase();
39
+ let score = 0;
40
+ if (r.name.toLowerCase().includes(q))
41
+ score += 10;
42
+ if (r.id.toLowerCase().includes(q))
43
+ score += 8;
44
+ if (r.category.toLowerCase().includes(q))
45
+ score += 5;
46
+ if (r.description.toLowerCase().includes(q))
47
+ score += 4;
48
+ if (r.designNotes.toLowerCase().includes(q))
49
+ score += 3;
50
+ for (const c of r.components) {
51
+ if (c.toLowerCase().includes(q))
52
+ score += 2;
53
+ }
54
+ return score;
55
+ }
56
+ // ─── Tool registrations ──────────────────────────────────────────────────────
57
+ export function registerTools(server) {
58
+ // Tool: list_components
59
+ server.tool('list_components', 'Lists all available Lucent UI components with their name, tier (atom/molecule), and one-line description.', {}, withLogging('list_components', async () => {
60
+ const components = ALL_MANIFESTS.map((m) => ({
61
+ id: m.id,
62
+ name: m.name,
63
+ tier: m.tier,
64
+ description: m.description,
65
+ }));
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: JSON.stringify({ components }, null, 2),
71
+ },
72
+ ],
73
+ };
74
+ }));
75
+ // Tool: get_component_manifest
76
+ server.tool('get_component_manifest', 'Returns the full manifest JSON for a Lucent UI component, including props, usage examples, design intent, and accessibility notes.', { componentName: z.string().describe('Component name or id, e.g. "Button" or "form-field"') }, withLogging('get_component_manifest', async ({ componentName }) => {
77
+ const manifest = findManifest(componentName);
78
+ if (!manifest) {
79
+ return {
80
+ content: [
81
+ {
82
+ type: 'text',
83
+ text: JSON.stringify({
84
+ error: `Component "${componentName}" not found.`,
85
+ available: ALL_MANIFESTS.map((m) => m.name),
86
+ }),
87
+ },
88
+ ],
89
+ isError: true,
90
+ };
91
+ }
92
+ return {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: JSON.stringify(manifest, null, 2),
97
+ },
98
+ ],
99
+ };
100
+ }));
101
+ // Tool: search_components
102
+ server.tool('search_components', 'Searches Lucent UI components and composition patterns by description or concept. Returns matching components and patterns ranked by relevance.', { query: z.string().describe('Natural language or keyword query, e.g. "loading indicator", "form validation", or "profile card"') }, withLogging('search_components', async ({ query }) => {
103
+ const componentResults = ALL_MANIFESTS
104
+ .map((m) => ({ manifest: m, score: scoreManifest(m, query) }))
105
+ .filter(({ score }) => score > 0)
106
+ .sort((a, b) => b.score - a.score)
107
+ .map(({ manifest, score }) => ({
108
+ id: manifest.id,
109
+ name: manifest.name,
110
+ tier: manifest.tier,
111
+ description: manifest.description,
112
+ score,
113
+ }));
114
+ const patternResults = ALL_PATTERNS
115
+ .map((r) => ({ pattern: r, score: scorePattern(r, query) }))
116
+ .filter(({ score }) => score > 0)
117
+ .sort((a, b) => b.score - a.score)
118
+ .map(({ pattern, score }) => ({
119
+ id: pattern.id,
120
+ name: pattern.name,
121
+ category: pattern.category,
122
+ description: pattern.description,
123
+ score,
124
+ }));
125
+ return {
126
+ content: [
127
+ {
128
+ type: 'text',
129
+ text: JSON.stringify({ query, components: componentResults, patterns: patternResults }, null, 2),
130
+ },
131
+ ],
132
+ };
133
+ }));
134
+ // Tool: get_composition_pattern
135
+ server.tool('get_composition_pattern', 'Returns a full composition pattern with structure tree, working JSX code, variants, and design notes. Query by pattern name/id or by category to get all patterns in that category.', {
136
+ name: z.string().optional().describe('Pattern name or id, e.g. "Profile Card" or "settings-panel"'),
137
+ category: z.string().optional().describe('Pattern category: "card", "form", "nav", "dashboard", "settings", or "action"'),
138
+ }, withLogging('get_composition_pattern', async ({ name, category }) => {
139
+ if (name) {
140
+ const pattern = findPattern(name);
141
+ if (!pattern) {
142
+ return {
143
+ content: [
144
+ {
145
+ type: 'text',
146
+ text: JSON.stringify({
147
+ error: `Pattern "${name}" not found.`,
148
+ available: ALL_PATTERNS.map((r) => ({ id: r.id, name: r.name, category: r.category })),
149
+ }),
150
+ },
151
+ ],
152
+ isError: true,
153
+ };
154
+ }
155
+ return {
156
+ content: [
157
+ {
158
+ type: 'text',
159
+ text: JSON.stringify(pattern, null, 2),
160
+ },
161
+ ],
162
+ };
163
+ }
164
+ if (category) {
165
+ const cat = category.trim().toLowerCase();
166
+ const patterns = ALL_PATTERNS.filter((r) => r.category === cat);
167
+ if (patterns.length === 0) {
168
+ return {
169
+ content: [
170
+ {
171
+ type: 'text',
172
+ text: JSON.stringify({
173
+ error: `No patterns found in category "${category}".`,
174
+ availableCategories: [...new Set(ALL_PATTERNS.map((r) => r.category))],
175
+ }),
176
+ },
177
+ ],
178
+ isError: true,
179
+ };
180
+ }
181
+ return {
182
+ content: [
183
+ {
184
+ type: 'text',
185
+ text: JSON.stringify({ category: cat, patterns }, null, 2),
186
+ },
187
+ ],
188
+ };
189
+ }
190
+ // No filter — return all patterns
191
+ return {
192
+ content: [
193
+ {
194
+ type: 'text',
195
+ text: JSON.stringify({
196
+ patterns: ALL_PATTERNS.map((r) => ({
197
+ id: r.id,
198
+ name: r.name,
199
+ category: r.category,
200
+ description: r.description,
201
+ components: r.components,
202
+ })),
203
+ }, null, 2),
204
+ },
205
+ ],
206
+ };
207
+ }));
208
+ // Tool: list_presets
209
+ server.tool('list_presets', 'Lists all available Lucent UI design presets. Returns combined presets (modern, enterprise, playful) and individual dimensions (palettes, shapes, densities, shadows) that can be mixed and matched.', {}, withLogging('list_presets', async () => {
210
+ return {
211
+ content: [
212
+ {
213
+ type: 'text',
214
+ text: JSON.stringify({
215
+ combined: COMBINED,
216
+ palettes: PALETTES,
217
+ shapes: SHAPES,
218
+ densities: DENSITIES,
219
+ shadows: SHADOWS,
220
+ }, null, 2),
221
+ },
222
+ ],
223
+ };
224
+ }));
225
+ // Tool: get_preset_config
226
+ server.tool('get_preset_config', 'Returns the LucentProvider configuration code for a given preset selection. Pass a combined preset name OR individual dimension names to get a ready-to-use config file and provider snippet.', {
227
+ preset: z.string().optional().describe('Combined preset name: "modern", "enterprise", or "playful"'),
228
+ palette: z.string().optional().describe('Palette name: "default", "brand", "indigo", "emerald", "rose", or "ocean"'),
229
+ shape: z.string().optional().describe('Shape name: "sharp", "rounded", or "pill"'),
230
+ density: z.string().optional().describe('Density name: "compact", "default", or "spacious"'),
231
+ shadow: z.string().optional().describe('Shadow name: "flat", "subtle", or "elevated"'),
232
+ }, withLogging('get_preset_config', async ({ preset, palette, shape, density, shadow }) => {
233
+ const result = generatePresetConfig({ preset, palette, shape, density, shadow });
234
+ if ('error' in result) {
235
+ return {
236
+ content: [{ type: 'text', text: JSON.stringify({ error: result.error }) }],
237
+ isError: true,
238
+ };
239
+ }
240
+ return {
241
+ content: [
242
+ {
243
+ type: 'text',
244
+ text: JSON.stringify({
245
+ configFile: result.configFile,
246
+ providerSnippet: result.providerSnippet,
247
+ }, null, 2),
248
+ },
249
+ ],
250
+ };
251
+ }));
252
+ // Tool: get_design_rules
253
+ server.tool('get_design_rules', 'Returns Lucent UI design rules for spacing, typography, button pairing, layout patterns, color usage, and density. These rules ensure AI-generated layouts are aesthetically consistent. Query a specific section or get all rules.', {
254
+ section: z
255
+ .string()
256
+ .optional()
257
+ .describe('Optional section id: "spacing", "typography", "buttons", "layout", "color", or "density". Omit to get all rules.'),
258
+ }, withLogging('get_design_rules', async ({ section }) => {
259
+ if (section) {
260
+ const s = section.trim().toLowerCase();
261
+ const rule = DESIGN_RULES.find((r) => r.id === s);
262
+ if (!rule) {
263
+ return {
264
+ content: [
265
+ {
266
+ type: 'text',
267
+ text: JSON.stringify({
268
+ error: `Section "${section}" not found.`,
269
+ availableSections: DESIGN_RULES.map((r) => ({
270
+ id: r.id,
271
+ title: r.title,
272
+ })),
273
+ }),
274
+ },
275
+ ],
276
+ isError: true,
277
+ };
278
+ }
279
+ return {
280
+ content: [
281
+ {
282
+ type: 'text',
283
+ text: `## ${rule.title}\n\n${rule.body}`,
284
+ },
285
+ ],
286
+ };
287
+ }
288
+ return {
289
+ content: [
290
+ {
291
+ type: 'text',
292
+ text: DESIGN_RULES_SUMMARY,
293
+ },
294
+ ],
295
+ };
296
+ }));
297
+ }
298
+ export { DESIGN_RULES_SUMMARY };
@@ -42,8 +42,8 @@ export const COMPONENT_MANIFEST = {
42
42
  type: 'enum',
43
43
  required: false,
44
44
  default: 'div',
45
- description: 'HTML element to render. Use semantic elements when appropriate (nav for navigation, form for forms).',
46
- enumValues: ['div', 'section', 'nav', 'form', 'fieldset', 'ul', 'ol'],
45
+ description: 'HTML element to render. Use semantic elements when appropriate (nav for navigation, ul/ol for lists, section/header/footer/main/aside/article for landmarks). When rendering as ul/ol, list-style/margin/padding are auto-reset; consumers pass <li> children.',
46
+ enumValues: ['div', 'section', 'nav', 'header', 'footer', 'main', 'aside', 'article', 'form', 'fieldset', 'ul', 'ol'],
47
47
  },
48
48
  {
49
49
  name: 'wrap',
@@ -92,6 +92,14 @@ export const COMPONENT_MANIFEST = {
92
92
  title: 'Header with actions',
93
93
  code: `<Row justify="between">\n <Text as="h2" size="xl" weight="semibold">Dashboard</Text>\n <Row gap="2">\n <Button variant="outline" size="sm">Export</Button>\n <Button variant="primary" size="sm">New report</Button>\n </Row>\n</Row>`,
94
94
  },
95
+ {
96
+ title: 'Semantic nav',
97
+ code: `<Row as="nav" gap="4" aria-label="Primary">\n <a href="/home">Home</a>\n <a href="/about">About</a>\n <a href="/contact">Contact</a>\n</Row>`,
98
+ },
99
+ {
100
+ title: 'Horizontal list (ul)',
101
+ code: `<Row as="ul" gap="2" aria-label="Tags">\n {tags.map(tag => (\n <li key={tag}><Tag>{tag}</Tag></li>\n ))}\n</Row>`,
102
+ },
95
103
  ],
96
104
  compositionGraph: [],
97
105
  accessibility: {
@@ -41,8 +41,8 @@ export const COMPONENT_MANIFEST = {
41
41
  type: 'enum',
42
42
  required: false,
43
43
  default: 'div',
44
- description: 'HTML element to render. Use semantic elements when appropriate (nav for navigation, form for forms).',
45
- enumValues: ['div', 'section', 'nav', 'form', 'fieldset', 'ul', 'ol'],
44
+ description: 'HTML element to render. Use semantic elements when appropriate (nav for navigation, ul/ol for lists, section/header/footer/main/aside/article for landmarks). When rendering as ul/ol, list-style/margin/padding are auto-reset; consumers pass <li> children.',
45
+ enumValues: ['div', 'section', 'nav', 'header', 'footer', 'main', 'aside', 'article', 'form', 'fieldset', 'ul', 'ol'],
46
46
  },
47
47
  {
48
48
  name: 'wrap',
@@ -91,6 +91,10 @@ export const COMPONENT_MANIFEST = {
91
91
  title: 'Semantic nav',
92
92
  code: `<Stack as="nav" gap="1">\n <NavLink href="/home">Home</NavLink>\n <NavLink href="/settings">Settings</NavLink>\n</Stack>`,
93
93
  },
94
+ {
95
+ title: 'Semantic list (ul)',
96
+ code: `<Stack as="ul" gap="3" aria-label="Recent notes">\n {notes.map(note => (\n <li key={note.id}>\n <Text size="sm">{note.body}</Text>\n </li>\n ))}\n</Stack>`,
97
+ },
94
98
  ],
95
99
  compositionGraph: [],
96
100
  accessibility: {
@@ -10,7 +10,10 @@ export const COMPONENT_MANIFEST = {
10
10
  'are not needed — props tables, changelog entries, comparison grids, reference docs. ' +
11
11
  'The compound API (Table.Head, Table.Body, Table.Row, Table.Cell) maps directly to ' +
12
12
  'semantic HTML so screen readers get the full table structure. ' +
13
- 'Horizontal overflow is handled automatically by a scroll wrapper.',
13
+ 'Horizontal overflow is handled automatically by a scroll wrapper. ' +
14
+ 'The wrapper paints var(--lucent-surface) as its background so the table always sits on a ' +
15
+ 'solid panel, regardless of parent page color; thead/tfoot/striped tints are translucent ' +
16
+ 'color-mix overlays on top of that surface so they adapt to both light and dark modes.',
14
17
  props: [
15
18
  {
16
19
  name: 'striped',
@@ -31,6 +31,17 @@ export const COMPONENT_MANIFEST = {
31
31
  required: false,
32
32
  description: 'Inline style overrides for the root <nav> element.',
33
33
  },
34
+ {
35
+ name: 'LinkComponent',
36
+ type: 'ComponentType',
37
+ required: false,
38
+ description: 'Optional custom link component used for items with an href, enabling SPA-friendly ' +
39
+ 'routing (react-router, Next.js, etc.) without a full-page reload. ' +
40
+ 'Receives { href, children, style, onClick, onMouseEnter, onMouseLeave }. ' +
41
+ 'If your link primitive uses a different prop name for the URL, wrap it in a small adapter. ' +
42
+ 'The adapter must forward `style` to the rendered DOM element so Breadcrumb typography and ' +
43
+ 'hover styling are preserved.',
44
+ },
34
45
  ],
35
46
  usageExamples: [
36
47
  {
@@ -61,6 +72,22 @@ export const COMPONENT_MANIFEST = {
61
72
  { label: 'Reports', onClick: () => navigate('/reports') },
62
73
  { label: 'Q1 Summary' },
63
74
  ]}
75
+ />`,
76
+ },
77
+ {
78
+ title: 'react-router integration (LinkComponent)',
79
+ code: `import { Link } from 'react-router-dom';
80
+
81
+ // react-router's Link uses \`to\`, so wrap it in a small adapter that
82
+ // maps Breadcrumb's \`href\` prop to \`to\` and forwards style/handlers.
83
+ const RouterLink = ({ href, ...rest }) => <Link to={href} {...rest} />;
84
+
85
+ <Breadcrumb
86
+ LinkComponent={RouterLink}
87
+ items={[
88
+ { label: 'Candidates', href: '/candidates' },
89
+ { label: 'Jane Doe' },
90
+ ]}
64
91
  />`,
65
92
  },
66
93
  ],
@@ -14,7 +14,9 @@ export const COMPONENT_MANIFEST = {
14
14
  'Filter → sort → paginate is the fixed pipeline order; any filter change resets the page to 0. ' +
15
15
  'Pagination is either controlled (page prop + onPageChange) or uncontrolled (internal state). ' +
16
16
  'A pageSize of 0 disables pagination entirely, useful when the parent manages windowing. ' +
17
- 'Row hover uses bg-subtle, not a border change, so the visual weight stays low for dense data views.',
17
+ 'Row hover uses bg-subtle, not a border change, so the visual weight stays low for dense data views. ' +
18
+ 'The table wrapper paints var(--lucent-surface) as its background so the component always sits on a solid panel, ' +
19
+ 'regardless of parent page color; header tints and row hover are translucent color-mix overlays on top of that surface.',
18
20
  props: [
19
21
  {
20
22
  name: 'columns',
@@ -7,6 +7,8 @@ export const COMPONENT_MANIFEST = {
7
7
  description: 'A drag-and-drop file upload zone with a file list, per-file progress bars, error display, and size/type validation.',
8
8
  designIntent: 'FileUpload separates concerns between the drop zone (entry point) and the file list (status display). ' +
9
9
  'The drop zone uses a dashed border and an upload arrow icon to communicate droppability without words. ' +
10
+ 'Its idle background and border are translucent color-mix overlays on top of var(--lucent-text-primary) (not hard-coded surface tokens), ' +
11
+ 'so the drop zone always reads as a visible step against whatever parent background it sits on — in any theme, in any palette. ' +
10
12
  'Progress is modelled as a field on UploadFile rather than as a callback so the parent controls upload logic — this component is purely presentational for the upload state. ' +
11
13
  'The progress bar turns success-green at 100% to give clear completion feedback. ' +
12
14
  'Errors are shown inline on each file row (not as a toast) so the user knows exactly which file failed and why. ' +
@@ -0,0 +1,144 @@
1
+ export const COMPONENT_MANIFEST = {
2
+ id: 'page-header',
3
+ name: 'PageHeader',
4
+ tier: 'molecule',
5
+ domain: 'neutral',
6
+ specVersion: '0.1',
7
+ description: 'A page-level header pairing breadcrumbs, a large display title, optional subtitle, and up to one primary CTA with 0–3 secondary outline actions.',
8
+ designIntent: 'PageHeader is the canonical chrome for a page body. It encapsulates the action-bar pattern — ' +
9
+ 'breadcrumbs on top, a 3xl display title with optional subtitle below, action buttons anchored ' +
10
+ 'to the title baseline, and a divider separating the header from page content. ' +
11
+ 'The component owns three zones: breadcrumbs (nav context), title block, and action slot. ' +
12
+ '\n\n' +
13
+ '## Multi-action support\n' +
14
+ 'The `action` prop holds the single primary CTA (rightmost position, `variant="primary"` by ' +
15
+ 'default). The `secondaryActions` prop accepts 0–3 additional actions that render to the left ' +
16
+ 'of the primary action with `variant="outline"` by default, keeping them visually subordinate. ' +
17
+ 'This covers the common detail-page need for Edit / Archive / primary-CTA triples without ' +
18
+ 'forcing consumers to rebuild the header from primitives. Beyond 3 secondary actions, reach for ' +
19
+ 'SplitButton or an overflow menu instead of widening the row.\n\n' +
20
+ '## Responsive behaviour\n' +
21
+ 'Both the outer title/action Row and the inner action Row set `wrap` so the button group flows ' +
22
+ 'below the title on narrow viewports instead of overflowing. `align="end"` anchors the buttons ' +
23
+ 'to the baseline of the subtitle line when the layout is horizontal.\n\n' +
24
+ '## Styling\n' +
25
+ 'All spacing, typography, and colors come from Lucent tokens — no custom values. The title uses ' +
26
+ 'the display font family and 3xl size; the subtitle uses the base font at sm/secondary. The ' +
27
+ 'bottom divider can be hidden via `hideDivider` when the header sits directly above another ' +
28
+ 'bordered surface.',
29
+ props: [
30
+ {
31
+ name: 'title',
32
+ type: 'string',
33
+ required: true,
34
+ description: 'Page title rendered as an h1 in the display font at 3xl size.',
35
+ },
36
+ {
37
+ name: 'subtitle',
38
+ type: 'ReactNode',
39
+ required: false,
40
+ description: 'Optional subtitle or metadata line rendered below the title at sm/secondary.',
41
+ },
42
+ {
43
+ name: 'breadcrumbs',
44
+ type: 'array',
45
+ required: false,
46
+ description: 'Breadcrumb items rendered above the title via the Breadcrumb molecule. Provides navigation context.',
47
+ },
48
+ {
49
+ name: 'action',
50
+ type: 'object',
51
+ required: false,
52
+ description: 'Single primary CTA rendered rightmost. Shape: { label, onClick?, variant?, leftIcon?, rightIcon?, disabled?, loading? }. ' +
53
+ 'Defaults to variant="primary". Pass `null` to explicitly omit when spreading dynamic props.',
54
+ },
55
+ {
56
+ name: 'secondaryActions',
57
+ type: 'array',
58
+ required: false,
59
+ description: '0–3 secondary actions rendered to the left of `action`. Each item uses the same PageHeaderAction shape as `action`. ' +
60
+ 'Defaults to variant="outline" so they stay visually subordinate. For more than 3 actions, use a SplitButton or overflow menu.',
61
+ },
62
+ {
63
+ name: 'hideDivider',
64
+ type: 'boolean',
65
+ required: false,
66
+ default: 'false',
67
+ description: 'Hide the bottom divider. Use when the header sits directly above another bordered surface.',
68
+ },
69
+ {
70
+ name: 'style',
71
+ type: 'object',
72
+ required: false,
73
+ description: 'Inline style overrides for the outer Stack wrapper.',
74
+ },
75
+ ],
76
+ usageExamples: [
77
+ {
78
+ title: 'Minimal — title only',
79
+ code: `<PageHeader title="Settings" />`,
80
+ },
81
+ {
82
+ title: 'With subtitle and single primary action',
83
+ code: `<PageHeader
84
+ title="Acme Corp"
85
+ subtitle="Last updated 5 minutes ago"
86
+ action={{ label: 'New report', onClick: () => createReport() }}
87
+ />`,
88
+ },
89
+ {
90
+ title: 'Detail view with secondary actions (Edit / Archive / Submit)',
91
+ code: `<PageHeader
92
+ title="Sana Khan"
93
+ subtitle="Senior Product Designer · Added 3 days ago"
94
+ breadcrumbs={[
95
+ { label: 'Home', href: '#' },
96
+ { label: 'Candidates', href: '#' },
97
+ { label: 'Sana Khan' },
98
+ ]}
99
+ secondaryActions={[
100
+ { label: 'Edit', onClick: () => openEdit() },
101
+ { label: 'Archive', onClick: () => archive() },
102
+ ]}
103
+ action={{ label: 'Submit to opportunity', onClick: () => submit() }}
104
+ />`,
105
+ },
106
+ {
107
+ title: 'Danger zone',
108
+ code: `<PageHeader
109
+ title="Danger zone"
110
+ subtitle="These actions are irreversible."
111
+ breadcrumbs={[
112
+ { label: 'Home', href: '#' },
113
+ { label: 'Settings', href: '#' },
114
+ { label: 'Danger zone' },
115
+ ]}
116
+ action={{ label: 'Delete project', variant: 'danger', onClick: () => confirmDelete() }}
117
+ />`,
118
+ },
119
+ {
120
+ title: 'No primary CTA, secondary actions only',
121
+ code: `<PageHeader
122
+ title="Reports"
123
+ secondaryActions={[
124
+ { label: 'Export', onClick: () => exportAll() },
125
+ { label: 'Filter', onClick: () => openFilter() },
126
+ ]}
127
+ />`,
128
+ },
129
+ ],
130
+ compositionGraph: [
131
+ { componentId: 'breadcrumb', componentName: 'Breadcrumb', role: 'Navigation context above the title', required: false },
132
+ { componentId: 'stack', componentName: 'Stack', role: 'Vertical layout for title and subtitle', required: true },
133
+ { componentId: 'row', componentName: 'Row', role: 'Horizontal layout for title block and action slot', required: true },
134
+ { componentId: 'text', componentName: 'Text', role: 'Title and subtitle rendering', required: true },
135
+ { componentId: 'button', componentName: 'Button', role: 'Primary and secondary action buttons', required: false },
136
+ { componentId: 'divider', componentName: 'Divider', role: 'Separator between header chrome and page content', required: false },
137
+ ],
138
+ accessibility: {
139
+ notes: 'The title renders as an <h1>, providing the page heading landmark for assistive tech. ' +
140
+ 'Breadcrumbs render inside <nav aria-label="Breadcrumb">. Action buttons inherit Button\'s ' +
141
+ 'focus ring, keyboard activation, and aria-label forwarding — pass aria-label on each action ' +
142
+ 'object when the label alone is insufficient (e.g. icon-only buttons).',
143
+ },
144
+ };
@@ -113,9 +113,32 @@ export const ButtonManifest = {
113
113
  type: 'enum',
114
114
  required: false,
115
115
  default: 'button',
116
- description: 'Native button type attribute.',
116
+ description: 'Native button type attribute. Ignored when `href` is set.',
117
117
  enumValues: ['button', 'submit', 'reset'],
118
118
  },
119
+ {
120
+ name: 'href',
121
+ type: 'string',
122
+ required: false,
123
+ description: 'When set, renders the Button as an `<a href={href}>` instead of a native `<button>`. ' +
124
+ 'Preserves native anchor affordances (middle-click, cmd/ctrl-click, right-click "copy link address", ' +
125
+ 'open in new tab) that an onClick handler cannot. ' +
126
+ 'Use for `mailto:` / `tel:` quick actions, external links styled as buttons, ' +
127
+ 'or in-app routes where users legitimately expect anchor semantics. ' +
128
+ 'When combined with `disabled`, the anchor renders with `aria-disabled="true"` and its `href` is stripped so navigation is neutralised.',
129
+ },
130
+ {
131
+ name: 'target',
132
+ type: 'string',
133
+ required: false,
134
+ description: 'Forwarded to the rendered `<a>` when `href` is set (e.g. `"_blank"` to open in a new tab). Ignored when rendering as a button.',
135
+ },
136
+ {
137
+ name: 'rel',
138
+ type: 'string',
139
+ required: false,
140
+ description: 'Forwarded to the rendered `<a>` when `href` is set (e.g. `"noopener noreferrer"` for external links). Ignored when rendering as a button.',
141
+ },
119
142
  ],
120
143
  usageExamples: [
121
144
  {
@@ -167,6 +190,21 @@ export const ButtonManifest = {
167
190
  code: `<Button variant="outline" size="2xs" leftIcon={<CloseIcon />} aria-label="Close" />`,
168
191
  description: 'Omitting children auto-sizes the button as a square via aspect-ratio: 1.',
169
192
  },
193
+ {
194
+ title: 'Mailto quick action',
195
+ code: `<Button variant="ghost" size="sm" href="mailto:foo@example.com" leftIcon={<MailIcon />} aria-label="Email" />`,
196
+ description: 'Renders as <a href="mailto:..."> so middle-click, cmd/ctrl-click, and right-click "copy link" all work.',
197
+ },
198
+ {
199
+ title: 'External link as button',
200
+ code: `<Button variant="primary" href="https://example.com" target="_blank" rel="noopener noreferrer" rightIcon={<ExternalIcon />}>View docs</Button>`,
201
+ description: 'Use href + target + rel for external links that should look like a primary call-to-action.',
202
+ },
203
+ {
204
+ title: 'Disabled link',
205
+ code: `<Button variant="outline" href="/settings" disabled>Settings</Button>`,
206
+ description: 'When disabled, the anchor is rendered with aria-disabled="true" and its href is stripped.',
207
+ },
170
208
  ],
171
209
  compositionGraph: [],
172
210
  accessibility: {