@pithyjs/pidl-catalog 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/dist/specs/atoms/actions/button.pidl.json +289 -0
  3. package/dist/specs/atoms/actions/icon-group.pidl.json +141 -0
  4. package/dist/specs/atoms/actions/segmented-control.pidl.json +137 -0
  5. package/dist/specs/atoms/content/action-tile.pidl.json +135 -0
  6. package/dist/specs/atoms/content/badge.pidl.json +189 -0
  7. package/dist/specs/atoms/content/callout.pidl.json +170 -0
  8. package/dist/specs/atoms/content/code-block.pidl.json +170 -0
  9. package/dist/specs/atoms/content/heading.pidl.json +175 -0
  10. package/dist/specs/atoms/content/icon.pidl.json +127 -0
  11. package/dist/specs/atoms/content/image.pidl.json +227 -0
  12. package/dist/specs/atoms/content/prose.pidl.json +183 -0
  13. package/dist/specs/atoms/content/stat.pidl.json +158 -0
  14. package/dist/specs/atoms/content/tabs.pidl.json +207 -0
  15. package/dist/specs/atoms/content/text.pidl.json +212 -0
  16. package/dist/specs/atoms/content/tile.pidl.json +156 -0
  17. package/dist/specs/atoms/data/donut.pidl.json +352 -0
  18. package/dist/specs/atoms/data/gauge.pidl.json +274 -0
  19. package/dist/specs/atoms/forms/checkbox-group.pidl.json +170 -0
  20. package/dist/specs/atoms/forms/checkbox.pidl.json +323 -0
  21. package/dist/specs/atoms/forms/file-picker.pidl.json +161 -0
  22. package/dist/specs/atoms/forms/form-field.pidl.json +106 -0
  23. package/dist/specs/atoms/forms/input.pidl.json +466 -0
  24. package/dist/specs/atoms/forms/radio-group.pidl.json +174 -0
  25. package/dist/specs/atoms/forms/radio.pidl.json +312 -0
  26. package/dist/specs/atoms/forms/search-input.pidl.json +265 -0
  27. package/dist/specs/atoms/forms/select.pidl.json +420 -0
  28. package/dist/specs/atoms/forms/textarea.pidl.json +422 -0
  29. package/dist/specs/atoms/forms/toggle.pidl.json +314 -0
  30. package/dist/specs/layout/grid-item.pidl.json +120 -0
  31. package/dist/specs/layout/grid.pidl.json +157 -0
  32. package/dist/specs/layout/page-section.pidl.json +105 -0
  33. package/dist/specs/layout/smart-container.pidl.json +148 -0
  34. package/dist/specs/layout/stack.pidl.json +144 -0
  35. package/dist/specs/organisms/content/accordion.pidl.json +196 -0
  36. package/dist/specs/organisms/content/card.pidl.json +283 -0
  37. package/dist/specs/organisms/feedback/alert-dialog.pidl.json +158 -0
  38. package/dist/specs/organisms/feedback/modal.pidl.json +133 -0
  39. package/dist/specs/organisms/feedback/toast.pidl.json +150 -0
  40. package/dist/specs/organisms/forms/dynamic-form.pidl.json +314 -0
  41. package/dist/specs/organisms/forms/form.pidl.json +135 -0
  42. package/dist/specs/organisms/forms/subscribe-form.pidl.json +188 -0
  43. package/dist/specs/organisms/navigation/command-palette.pidl.json +263 -0
  44. package/dist/specs/organisms/navigation/docs-sidebar.pidl.json +264 -0
  45. package/dist/specs/organisms/navigation/navbar.pidl.json +356 -0
  46. package/dist/specs/organisms/navigation/table-of-contents.pidl.json +197 -0
  47. package/dist/specs/sections/contact-section.pidl.json +183 -0
  48. package/dist/specs/sections/cta-section.pidl.json +168 -0
  49. package/dist/specs/sections/faq-section.pidl.json +202 -0
  50. package/dist/specs/sections/features-section.pidl.json +214 -0
  51. package/dist/specs/sections/footer-section.pidl.json +187 -0
  52. package/dist/specs/sections/hero-section.pidl.json +202 -0
  53. package/dist/src/borders-psychology.d.ts +17 -0
  54. package/dist/src/borders-psychology.d.ts.map +1 -0
  55. package/dist/src/borders-psychology.js +42 -0
  56. package/dist/src/borders-psychology.js.map +1 -0
  57. package/dist/src/color-psychology.d.ts +20 -0
  58. package/dist/src/color-psychology.d.ts.map +1 -0
  59. package/dist/src/color-psychology.js +57 -0
  60. package/dist/src/color-psychology.js.map +1 -0
  61. package/dist/src/design-intelligence.d.ts +13 -0
  62. package/dist/src/design-intelligence.d.ts.map +1 -0
  63. package/dist/src/design-intelligence.js +36 -0
  64. package/dist/src/design-intelligence.js.map +1 -0
  65. package/dist/src/index.d.ts +6238 -0
  66. package/dist/src/index.d.ts.map +1 -0
  67. package/dist/src/index.js +105 -0
  68. package/dist/src/index.js.map +1 -0
  69. package/dist/src/motion-psychology.d.ts +19 -0
  70. package/dist/src/motion-psychology.d.ts.map +1 -0
  71. package/dist/src/motion-psychology.js +47 -0
  72. package/dist/src/motion-psychology.js.map +1 -0
  73. package/dist/src/mutation-vocabulary.d.ts +13 -0
  74. package/dist/src/mutation-vocabulary.d.ts.map +1 -0
  75. package/dist/src/mutation-vocabulary.js +67 -0
  76. package/dist/src/mutation-vocabulary.js.map +1 -0
  77. package/dist/src/radius-psychology.d.ts +17 -0
  78. package/dist/src/radius-psychology.d.ts.map +1 -0
  79. package/dist/src/radius-psychology.js +42 -0
  80. package/dist/src/radius-psychology.js.map +1 -0
  81. package/dist/src/shadows-psychology.d.ts +17 -0
  82. package/dist/src/shadows-psychology.d.ts.map +1 -0
  83. package/dist/src/shadows-psychology.js +44 -0
  84. package/dist/src/shadows-psychology.js.map +1 -0
  85. package/dist/src/spacing-psychology.d.ts +22 -0
  86. package/dist/src/spacing-psychology.d.ts.map +1 -0
  87. package/dist/src/spacing-psychology.js +59 -0
  88. package/dist/src/spacing-psychology.js.map +1 -0
  89. package/dist/src/typography-psychology.d.ts +22 -0
  90. package/dist/src/typography-psychology.d.ts.map +1 -0
  91. package/dist/src/typography-psychology.js +68 -0
  92. package/dist/src/typography-psychology.js.map +1 -0
  93. package/package.json +64 -0
@@ -0,0 +1,356 @@
1
+ {
2
+ "kind": "component",
3
+ "id": "ui.navbar",
4
+ "name": "Navbar",
5
+ "displayName": "Navbar",
6
+ "version": "0.1.0",
7
+ "level": "organism",
8
+ "domain": "navigation",
9
+ "bidi": true,
10
+ "description": "Top-of-page navigation with brand, primary links, trailing actions, and a mobile popover menu. Uses semantic <nav> with required aria-label, native Popover API for the mobile menu, and container queries for responsive layout.",
11
+ "props": [
12
+ {
13
+ "name": "label",
14
+ "label": "Label",
15
+ "editor": { "tab": "settings" },
16
+ "type": "string",
17
+ "required": true,
18
+ "placeholder": "Primary",
19
+ "description": "Accessible name for the <nav> landmark. Required because a page can contain multiple <nav> elements (header, footer, sidebar) and screen readers need to distinguish them."
20
+ },
21
+ {
22
+ "name": "brand",
23
+ "label": "Brand",
24
+ "type": "string",
25
+ "placeholder": "Your Brand",
26
+ "description": "Wordmark / brand text shown at the start of the navbar. Rendered beside brandImageSrc if both are provided."
27
+ },
28
+ {
29
+ "name": "brandImageSrc",
30
+ "label": "Brand image source",
31
+ "type": "string",
32
+ "description": "Logo image URL shown at the start of the navbar."
33
+ },
34
+ {
35
+ "name": "brandImageAlt",
36
+ "label": "Brand image alt",
37
+ "type": "string",
38
+ "description": "Alt text for the brand logo image. REQUIRED when brandImageSrc is set."
39
+ },
40
+ {
41
+ "name": "brandHref",
42
+ "label": "Brand link",
43
+ "type": "string",
44
+ "default": "/",
45
+ "description": "Href for the brand link (usually the home page)."
46
+ },
47
+ {
48
+ "name": "brandTarget",
49
+ "label": "Open brand link in new tab",
50
+ "type": "enum",
51
+ "enum": ["_blank", "_self"],
52
+ "default": "_self",
53
+ "description": "Anchor target for the brand link. '_blank' opens it in a new tab with rel='noopener noreferrer'; '_self'/undefined stay in the same tab."
54
+ },
55
+ {
56
+ "name": "items",
57
+ "label": "Items",
58
+ "type": "NavbarItem[]",
59
+ "placeholder": [
60
+ { "label": "Features", "href": "/features" },
61
+ { "label": "Pricing", "href": "/pricing" },
62
+ { "label": "Docs", "href": "/docs" }
63
+ ],
64
+ "description": "Primary nav links. Each item is {label, href, exactActive?, prefetch?}.",
65
+ "itemSchema": {
66
+ "label": {
67
+ "type": "string",
68
+ "required": true,
69
+ "description": "Link text"
70
+ },
71
+ "href": {
72
+ "type": "string",
73
+ "required": true,
74
+ "description": "Link URL"
75
+ },
76
+ "exactActive": {
77
+ "type": "boolean",
78
+ "required": false,
79
+ "description": "Use exact pathname match for active state (default: false = prefix match)"
80
+ },
81
+ "prefetch": {
82
+ "type": "enum",
83
+ "enum": ["none", "hover", "visible", "intent"],
84
+ "required": false,
85
+ "description": "Prefetch strategy when the PithyJS router is available. Ignored for plain-anchor fallback. Default 'none'."
86
+ },
87
+ "target": {
88
+ "type": "enum",
89
+ "enum": ["_blank", "_self"],
90
+ "required": false,
91
+ "description": "Anchor target. '_blank' opens the link in a new tab with rel='noopener noreferrer' and opts it out of SPA router prefetching; '_self'/undefined stay in the same tab."
92
+ }
93
+ }
94
+ },
95
+ {
96
+ "name": "sticky",
97
+ "label": "Sticky",
98
+ "editor": { "tab": "layout" },
99
+ "type": "boolean",
100
+ "default": false,
101
+ "description": "Sticks to the top of the viewport as the page scrolls (position: sticky; inset-block-start: 0)."
102
+ },
103
+ {
104
+ "name": "transparent",
105
+ "label": "Transparent",
106
+ "type": "boolean",
107
+ "default": false,
108
+ "description": "Transparent background until the user scrolls past the top. Intended for use directly above a hero section."
109
+ },
110
+ {
111
+ "name": "density",
112
+ "label": "Density",
113
+ "editor": { "tab": "layout" },
114
+ "type": "enum",
115
+ "enum": ["comfortable", "compact"],
116
+ "default": "comfortable",
117
+ "description": "Vertical density. 'comfortable' is the default marketing navbar; 'compact' suits app/dashboard chrome."
118
+ },
119
+ {
120
+ "name": "skipToContentTarget",
121
+ "label": "Skip to content target",
122
+ "editor": { "tab": "settings" },
123
+ "type": "string",
124
+ "default": "main-content",
125
+ "description": "Target element id for the skip-to-content link. The element (usually <main>) must exist on the page."
126
+ },
127
+ {
128
+ "name": "id",
129
+ "label": "Anchor ID",
130
+ "editor": { "tab": "settings" },
131
+ "type": "string",
132
+ "description": "Optional anchor id on the <nav> element."
133
+ }
134
+ ],
135
+ "requiredProps": ["label"],
136
+ "events": [],
137
+ "slots": [
138
+ {
139
+ "name": "brand",
140
+ "displayName": "Brand",
141
+ "description": "Brand logo or wordmark at the inline-start. Overrides the brand/brandImageSrc props when projected.",
142
+ "accepts": ["ui.image", "ui.text"],
143
+ "max": 1,
144
+ "media": "logo"
145
+ },
146
+ {
147
+ "name": "actions",
148
+ "displayName": "Actions",
149
+ "description": "Trailing action buttons/links at the inline-end (desktop) or bottom of the mobile menu.",
150
+ "accepts": ["ui.button", "ui.icon-group"]
151
+ },
152
+ {
153
+ "name": "search",
154
+ "displayName": "Search",
155
+ "description": "Search input or command palette trigger. Rendered inside the nav menu area.",
156
+ "accepts": ["ui.input"],
157
+ "max": 1
158
+ },
159
+ {
160
+ "name": "announcement",
161
+ "displayName": "Announcement",
162
+ "description": "Top banner bar above the nav — dismissible alerts, promos, notices.",
163
+ "max": 1
164
+ }
165
+ ],
166
+ "tokens": {
167
+ "surface.rest": "transparent by default, --color-surface when scrolled or transparent=false",
168
+ "surface.scrolled": "color-mix(in oklch, var(--color-surface) 80%, transparent) + backdrop-filter: blur(12px) saturate(1.4)",
169
+ "surface.innerHighlight": "inset 0 1px 0 color-mix(text 6%, transparent) — simulates sealed frosted glass",
170
+ "border.scrolled": "--border-width-hairline, --color-border (block-end only)",
171
+ "shadow.scrolled": "--shadow-sm (stacked with the inset 1px highlight)",
172
+ "condense.comfortable": "min-block-size drops from control-padding-y-md → control-padding-y-sm on scroll; container padding-block matches",
173
+ "brand.letterSpacing.rest": "-0.01em",
174
+ "brand.letterSpacing.scrolled": "-0.02em (subtle tighten, motion-safe)",
175
+ "padding.block.comfortable": "--control-padding-md",
176
+ "padding.block.compact": "--control-padding-sm",
177
+ "padding.inline": "--space-md (gutter)",
178
+ "gap.items": "--space-sm (close friends — nav link list)",
179
+ "gap.brand-to-items": "--space-lg (acquaintances)",
180
+ "gap.items-to-actions": "--space-md (friends)",
181
+ "hamburger.size": "--control-min-touch (touch target ≥44px)",
182
+ "hamburger.stroke": "2.25 (matches drawer typographic weight)",
183
+ "link.color": "--color-text",
184
+ "link.color-hover": "--color-text",
185
+ "link.color-active": "--color-primary",
186
+ "brand.mark.default": "primary-colored dot via ::before when no brandImageSrc — sized 0.55em so it scales with the wordmark",
187
+ "indicator.desktop": "2px primary-colored pill bar positioned absolutely inside .ui-navbar-list; JS publishes --_indicator-x / --_indicator-w on active-item change and CSS transitions translate + inline-size",
188
+ "indicator.mobile": "3px leading pill bar on the active drawer item (::before on .ui-navbar-link, block-size animates from 0 → 1.5rem)",
189
+ "drawer.layout": "position: fixed; inset-block: 0 0; inset-inline-end: 0; inline-size: min(88vw, 22rem); leading-edge rounded corners + hairline inline-start border",
190
+ "drawer.backdrop": "color-mix(--color-text 55%, transparent) + backdrop-filter: blur(14px) saturate(1.2)",
191
+ "container.maxWidth": "xl container breakpoint (80rem) — nav content caps and centers on wide viewports while the bar background stays full-width",
192
+ "container.gutter": "--space-lg",
193
+ "view-transition": "A per-instance view-transition-name (pithy-<menuId>) pins the navbar across route transitions so only the outlet cross-fades; multiple navbars on one page each get a unique name to avoid collision",
194
+ "motion.chrome": "--motion-duration-normal, --motion-easing-standard",
195
+ "motion.popover-entrance": "--motion-duration-normal, --motion-easing-entrance",
196
+ "motion.indicator": "--motion-duration-normal, --motion-easing-entrance (translate + inline-size)",
197
+ "motion.hover-underline": "scale(0 1 → 1 1) transform-origin: inline-start; --motion-duration-normal, --motion-easing-entrance"
198
+ },
199
+ "spacing": {
200
+ "brand-to-items": "--space-lg (acquaintances — the brand is a separate semantic cluster from the nav links)",
201
+ "item-list-gap": "--space-sm (close friends — nav links are peers)",
202
+ "items-to-actions": "--space-md (friends — related but distinct clusters)",
203
+ "hamburger-to-content": "--space-sm (close friends — toggle next to menu trigger)",
204
+ "drawer-flex-gap": "--space-sm (close friends — drawer flex column gap)",
205
+ "drawer-header-to-list": "--space-md (friends — brand/close row to nav list, separated by hairline divider)",
206
+ "drawer-actions-pinned": "margin-block-start: auto (actions/theme-toggle anchored to drawer bottom with hairline top divider)"
207
+ },
208
+ "designGuidance": {
209
+ "surface": "Three surface modes: (1) transparent=false, sticky=false → solid --color-surface always; (2) sticky=true, transparent=false → solid surface always, with hairline bottom border + inner 1px highlight + --shadow-sm when scrolled; (3) transparent=true → fully transparent over hero at rest, translucent via tokenized glass.glass-surface(...) mixin once scrolled past the 1px sentinel. The scrolled-state transition is motion-safe. Comfortable density additionally condenses (min-block-size + padding-block drop one tier) on scroll; compact is already condensed and is not altered. Brand wordmark letter-spacing tightens from -0.01em → -0.02em on scroll.",
210
+ "depth": "No shadow at rest. When scrolled, --shadow-sm and a hairline block-end border appear together — depth signals 'this is elevated above the page now'. Do not stack additional shadows.",
211
+ "borders": "No borders at rest. On scroll, a single hairline --border-width-hairline in --color-border on the block-end. In forced-colors mode, a 1px ButtonText border is always present for visibility.",
212
+ "interactivity": "Desktop links use --color-text at rest; hover/focus-visible draws a hairline ::after underline from the leading edge (scale-x transition) and active state is shown by a single 2px primary pill bar that slides between items (JS publishes --_indicator-x / --_indicator-w on aria-current change). Mobile drawer items use --color-text medium weight at rest; active uses --color-primary semibold plus a 3px leading pill bar (::before) that grows from 0 → 1.5rem. The hamburger is a ≥44×44 target, scale(0.96) press feedback, morphs to an X via stroke transforms — all motion-safe. All tap highlights are suppressed (-webkit-tap-highlight-color: transparent) and drawer link contextmenu is preventDefault'd to stop long-press state drift on touch.",
213
+ "bestPractices": [
214
+ "One <Navbar> per page as the primary nav landmark. Use distinct label values if you have multiple navs (e.g. 'Primary', 'Utility').",
215
+ "Place the Navbar as the first visible element, immediately before the <main> element. The skip link's target id must match an element on the page.",
216
+ "Use transparent=true only when the content directly below is a full-bleed Hero with sufficient contrast behind text — otherwise the unscrolled state is unreadable.",
217
+ "The nav collapses to a hamburger when primary items no longer fit in a single row (content-aware via ResizeObserver). Cap item count at ~7 top-level links for desktop readability; more belongs in a dropdown/mega-menu (separate component, post-V0.1).",
218
+ "For brand: pass a logo image (brandImageSrc + brandImageAlt) or a short wordmark (brand) — when only the wordmark is set, Navbar renders a default primary-colored dot mark before it.",
219
+ "Don't put primary CTAs in the link list — use the actions slot so CTAs stay visually distinct (e.g. a <Button variant='solid'>).",
220
+ "Prefer density='comfortable' for marketing sites; switch to 'compact' for app/dashboard shells where vertical real-estate is precious."
221
+ ]
222
+ },
223
+ "constraints": [
224
+ "Uses semantic <nav> element with required aria-label",
225
+ "Link list uses <ul>/<li> semantic markup",
226
+ "Mobile menu uses native Popover API (popover='auto') — no custom show/hide JS required",
227
+ "Hamburger toggle uses Invoker Commands (commandfor/command) — no manual click handler for open/close",
228
+ "Responsive layout is content-aware: a ResizeObserver on the nav + list flips to mobile when items overflow a single row and reverts to desktop when the nav grows past the width that forced the switch. Layout flips are guarded by a `data-layout-transitioning` attribute that suppresses the popover's discrete-display transitions so the switch can't flash items",
229
+ "All CSS uses logical properties (inline-start/end, block-start/end) — RTL-safe",
230
+ "Zero raw CSS values — every color/space/font-size/radius/shadow/duration uses a design system token",
231
+ "Skip link is always the first focusable element inside <nav>",
232
+ "When brandImageSrc is provided, brandImageAlt must also be set",
233
+ "Mobile drawer is a CSS-only full-height side-sheet (position: fixed; inset-block: 0 0; inset-inline-end: 0; block-size: 100dvh). The JS anchor-positioning path early-exits on mobile so CSS owns layout",
234
+ "Desktop popover positioning is direction-aware: RTL uses the toggle's physical-left edge, LTR uses the viewport-right-to-toggle-right distance",
235
+ "Body scroll is locked (document.body.style.overflow='hidden') while the popover is open on mobile; restored on close and on destroy",
236
+ "Long-press on a drawer link calls event.preventDefault() to suppress the browser's native contextmenu, preventing in-between popover state on touch-emulation and real iOS",
237
+ "Inline position styles (desktop only) are cleared after the close transition's `opacity` transitionend (with 600ms fallback) so the popover never flashes top-left mid-animation",
238
+ "Nav content inside .ui-navbar-container is capped at the `xl` container breakpoint (80rem) and centered with margin-inline: auto on wide viewports; the bar's background stays full-width",
239
+ "Anchors are tagged with data-router-link + data-prefetch/exact-match attributes so setGlobalRouter → bindExistingRouterLinks binds them even when the router mounts after Navbar",
240
+ "The navbar sets a per-instance view-transition-name (pithy-<menuId>) so View Transitions pin it across route changes (only the outlet cross-fades), with unique names across multiple concurrent navbars"
241
+ ],
242
+ "a11y": {
243
+ "landmarks": [
244
+ "Semantic <nav> landmark with a required aria-label (WCAG 1.3.1, 2.4.1)",
245
+ "Skip-to-content link (visually hidden until :focus-visible) is the first focusable element — satisfies WCAG 2.4.1 'Bypass Blocks'. Target id defaults to #main-content and must exist in the page."
246
+ ],
247
+ "keyboard": {
248
+ "toggleActivation": "Enter or Space on the hamburger opens the popover (native Invoker Commands).",
249
+ "initialFocus": "On keyboard-open (Enter/Space), focus moves to the first focusable item inside the menu. On pointer/touch-open, focus stays on the trigger so no unrequested focus ring appears.",
250
+ "insideMenu": {
251
+ "ArrowDown": "Focus next focusable item; wraps from last to first",
252
+ "ArrowUp": "Focus previous focusable item; wraps from first to last",
253
+ "Home": "Focus first focusable item",
254
+ "End": "Focus last focusable item",
255
+ "Tab": "On mobile (hamburger visible), Tab/Shift+Tab cycles within the popover — focus trap. On desktop, Tab flows naturally through the inline menu.",
256
+ "Escape": "Closes the popover (native popover-auto); focus returns to the trigger"
257
+ },
258
+ "nonShortcuts": "No typeahead or single-character shortcuts — satisfies WCAG 2.1.4 (Character Key Shortcuts) by design."
259
+ },
260
+ "focus": {
261
+ "visible": "All interactive elements use the design-system `focus-ring` mixin; never removed (WCAG 2.4.7).",
262
+ "notObscured": "When the popover overflows its max-block-size and scrolls internally, a focusin listener scrolls the focused item into view with an 8px breathing margin. Satisfies WCAG 2.4.11 'Focus Not Obscured' (AA).",
263
+ "restore": "Closing the popover returns focus to the trigger (native popover-auto behavior)."
264
+ },
265
+ "aria": {
266
+ "navLandmark": "aria-label on <nav>",
267
+ "hamburger": "aria-label swaps between 'Open menu' and 'Close menu'; aria-expanded is synced from the popover's own toggle event (belt-and-suspenders over Invoker Commands)",
268
+ "controls": "aria-controls pairs with commandfor; no aria-haspopup because the popover is a Disclosure of links (APG Disclosure pattern), not an ARIA menu widget",
269
+ "activeItem": "Current page is indicated by router-link-active class + aria-current='page' + data-current='page' (mirror for CSS targeting without ARIA selectors)",
270
+ "decorativeIcon": "Hamburger SVG has aria-hidden='true' + focusable='false' — the button's aria-label provides the accessible name"
271
+ },
272
+ "touch": {
273
+ "targetSize": "Hamburger and nav links use --control-min-touch (≥44×44) — WCAG 2.5.5 (AAA) / 2.5.8 (AA)",
274
+ "tapDelay": "touch-action: manipulation on the toggle and links kills the legacy ~300ms tap delay without disabling pinch-zoom"
275
+ },
276
+ "viewport": {
277
+ "safeArea": "Popover max-block-size and padding-block-end subtract env(safe-area-inset-bottom) so the last item never sits under the iOS home indicator / Android gesture bar. Requires the app-level <meta name='viewport' content='...,viewport-fit=cover'> for the insets to be non-zero on iOS.",
278
+ "dynamicViewport": "max-block-size uses 100dvh (with 100vh fallback) so the cap tracks mobile browser chrome showing/hiding.",
279
+ "reflow": "Container-query-driven responsive layout; no horizontal scroll at 320px (WCAG 1.4.10). Mobile-first — desktop layout is the @container (min-width) branch."
280
+ },
281
+ "rtl": {
282
+ "logicalProperties": "All CSS uses logical properties (inline-start/end, block-start/end). Verified by test.",
283
+ "positioningMath": "JavaScript popover positioning is direction-aware: RTL uses r.left, LTR uses window.innerWidth - r.right. Resolved from the nearest explicit `dir` attribute, then getComputedStyle.direction."
284
+ },
285
+ "motion": {
286
+ "reducedMotion": "All transitions (chrome, hamburger morph, popover entrance, item stagger) are wrapped in prefers-reduced-motion: no-preference — WCAG 2.3.3.",
287
+ "bodyScrollLock": "While the mobile popover is open, body overflow is set to 'hidden' so the document can't scroll behind it; restored on close and on destroy. Not a motion concern but prevents disorienting background movement on mobile.",
288
+ "inertSiblings": "While the mobile popover is open, every direct child of document.body that does not contain the nav is marked `inert` so AT, hit-testing, and tabbing are all blocked behind the menu. Restored on close and on destroy. Belt-and-suspenders alongside body scroll lock."
289
+ },
290
+ "forcedColors": "forced-colors mode renders a ButtonText bottom border, uses system link colors, and keeps hamburger borders visible — WCAG 1.4.1 (Use of Color) parity.",
291
+ "contentAssertions": "Brand logo image requires brandImageAlt (enforced at compile time via discriminated-union prop types)."
292
+ },
293
+ "ai": {
294
+ "synonyms": [
295
+ "navbar",
296
+ "nav bar",
297
+ "top nav",
298
+ "header",
299
+ "site header",
300
+ "primary navigation",
301
+ "main nav",
302
+ "top navigation",
303
+ "menu bar",
304
+ "app bar",
305
+ "open in new tab",
306
+ "new tab",
307
+ "external link"
308
+ ],
309
+ "avoid": [
310
+ "<div class='nav'> — use <Navbar> with a semantic <nav> landmark and required label",
311
+ "Custom hamburger with JS click handlers — use the native Popover API + Invoker Commands flow Navbar provides",
312
+ "Viewport media queries for the mobile collapse — Navbar uses container queries on the nav root so it adapts to its own width",
313
+ "Stuffing CTAs into the items array — they belong in the actions slot so they read as actions, not navigation"
314
+ ],
315
+ "patterns": [
316
+ "Marketing site top bar: <Navbar label=\"'Main'\" brand=\"'Acme'\" items=\"navItems\" sticky=\"true\"/>",
317
+ "Transparent over hero: <Navbar label=\"'Main'\" brandImageSrc=\"'/logo.svg'\" brandImageAlt=\"'Acme'\" items=\"navItems\" sticky=\"true\" transparent=\"true\"/>",
318
+ "App dashboard chrome: <Navbar label=\"'App'\" brand=\"'Console'\" items=\"appNav\" density=\"'compact'\" sticky=\"true\"/>",
319
+ "With trailing CTA (projected children fall through to actions slot): <Navbar label=\"'Main'\" brand=\"'Acme'\" items=\"navItems\"><Button>Sign in</Button></Navbar>"
320
+ ]
321
+ },
322
+ "examples": [
323
+ {
324
+ "id": "basic-navbar",
325
+ "title": "Basic sticky navbar with brand and items",
326
+ "pithy": "<Navbar label=\"'Main'\" brand=\"'Acme'\" items=\"[{label:'Features',href:'/features'},{label:'Pricing',href:'/pricing'},{label:'Docs',href:'/docs'}]\" sticky=\"true\"/>",
327
+ "assertions": [
328
+ "renders <nav aria-label='Main'>",
329
+ "includes a skip-to-content link as first focusable element",
330
+ "items render as <a class='ui-nav-link'> inside <ul>/<li>",
331
+ "hamburger toggle uses commandfor/command",
332
+ "mobile menu renders with popover='auto'",
333
+ "data-sticky='true' on the root"
334
+ ]
335
+ },
336
+ {
337
+ "id": "transparent-hero-navbar",
338
+ "title": "Transparent navbar over a hero",
339
+ "pithy": "<Navbar label=\"'Main'\" brandImageSrc=\"'/logo.svg'\" brandImageAlt=\"'Acme'\" items=\"items\" sticky=\"true\" transparent=\"true\"/>",
340
+ "assertions": [
341
+ "data-transparent='true' on the root",
342
+ "brand image has alt attribute",
343
+ "no background at rest; tinted + backdrop-blur once data-scrolled='true'"
344
+ ]
345
+ },
346
+ {
347
+ "id": "compact-app-navbar",
348
+ "title": "Compact navbar for app shells",
349
+ "pithy": "<Navbar label=\"'App'\" brand=\"'Console'\" items=\"appNav\" density=\"'compact'\" sticky=\"true\"/>",
350
+ "assertions": [
351
+ "data-density='compact' reduces vertical padding one tier",
352
+ "touch targets still ≥44×44"
353
+ ]
354
+ }
355
+ ]
356
+ }
@@ -0,0 +1,197 @@
1
+ {
2
+ "kind": "component",
3
+ "id": "ui.table-of-contents",
4
+ "name": "TableOfContents",
5
+ "displayName": "Table of contents",
6
+ "version": "0.1.0",
7
+ "level": "organism",
8
+ "domain": "navigation",
9
+ "studioReady": false,
10
+ "bidi": true,
11
+ "description": "Docs sidebar listing the headings of the current page, highlighting the one the reader is at and updating on scroll. Pairs with <Prose>, whose .u-prose article is the default heading source. Renders a <nav> landmark around a real nested <ul>/<li> list of <a href=\"#id\"> links; the active entry carries aria-current=\"location\". studioReady is FALSE on purpose: the component derives its entire content from the live page DOM at construction, so there is nothing meaningful to author or preview on a studio canvas — an empty article yields an empty (hidden) nav. It is registered fully so the catalog and docs tooling can see it; it is simply not canvas-droppable.",
12
+ "props": [
13
+ {
14
+ "name": "container",
15
+ "label": "Container",
16
+ "editor": { "tab": "settings" },
17
+ "type": "string",
18
+ "default": ".u-prose",
19
+ "placeholder": ".u-prose",
20
+ "description": "Where the headings live. Defaults to '.u-prose', the design-system utility <Prose> applies to its root — so a bare <TableOfContents /> beside a <Prose> document needs no configuration. The TypeScript API additionally accepts an HTMLElement (for callers that already hold the container) or a () => HTMLElement | null getter (for a container built after the ToC, e.g. a router outlet). A selector that matches nothing renders the empty state and DEV-warns rather than throwing."
21
+ },
22
+ {
23
+ "name": "minLevel",
24
+ "label": "Shallowest heading",
25
+ "editor": { "tab": "settings" },
26
+ "type": "number",
27
+ "default": 2,
28
+ "description": "Shallowest heading level to list, 1-6. Default 2 — h1 is normally the page title, which the reader is already looking at. Values are clamped to 1-6, and an inverted minLevel/maxLevel pair is re-ordered rather than collecting nothing."
29
+ },
30
+ {
31
+ "name": "maxLevel",
32
+ "label": "Deepest heading",
33
+ "editor": { "tab": "settings" },
34
+ "type": "number",
35
+ "default": 3,
36
+ "description": "Deepest heading level to list, 1-6. Default 3. Headings deeper than this are ignored entirely — a four-level table of contents is longer than the page it indexes."
37
+ },
38
+ {
39
+ "name": "label",
40
+ "label": "Label",
41
+ "type": "string",
42
+ "default": "On this page",
43
+ "placeholder": "On this page",
44
+ "description": "Accessible name for the <nav> landmark. Always applied (never optional): a docs page typically holds several <nav> elements — site header, docs sidebar, this — and the name is what lets a screen-reader user tell them apart in a landmark list. A whitespace-only value falls back to the default rather than producing an empty accessible name."
45
+ },
46
+ {
47
+ "name": "smoothScroll",
48
+ "label": "Smooth scroll",
49
+ "editor": { "tab": "settings" },
50
+ "type": "boolean",
51
+ "default": true,
52
+ "description": "Animate the jump to a heading when a link is clicked. ALWAYS overridden by prefers-reduced-motion: reduce, which forces an instant jump regardless of this value — the media query is read at click time, not at construction, so a mid-session preference change takes effect immediately."
53
+ },
54
+ {
55
+ "name": "class",
56
+ "label": "Extra classes",
57
+ "editor": { "tab": "settings" },
58
+ "type": "string",
59
+ "required": false,
60
+ "description": "Extra class names appended after 'ui-toc'."
61
+ },
62
+ {
63
+ "name": "id",
64
+ "label": "Element id",
65
+ "editor": { "tab": "settings" },
66
+ "type": "string",
67
+ "required": false,
68
+ "description": "id for the <nav> element — useful as a skip-link target."
69
+ }
70
+ ],
71
+ "tokens": {
72
+ "typography": "--font-size-sm + --line-height-body (list text), --font-weight-semibold (current entry)",
73
+ "color": "--color-text-muted (rest), --color-text (hover + current), --color-primary via --_toc-accent (current rail + hover tint), --color-divider via --_toc-rail (rest rail), --color-border via --_toc-rail-hover",
74
+ "spacing": "--space-xs (entry gap, link block padding, sublist offset), --space-sm (link inline padding, sublist indent), --space-lg (sticky offset)",
75
+ "border": "--border-width-medium + --border-style-solid (the inline-start rail on every link)",
76
+ "radius": "--radius-xs (inline-end corners of the link box, so the rail stays square against the track)",
77
+ "focus": "--focus-ring-width / --focus-ring-offset / --ring-color via the a11y focus-ring mixin on :focus-visible",
78
+ "motion": "--motion-transition-color / --motion-transition-background / --motion-transition-border, inside prefers-reduced-motion: no-preference"
79
+ },
80
+ "spacing": {
81
+ "entry-gap": "--space-xs (Lovers) — sibling entries read as one continuous track, not as separate items",
82
+ "sublist-indent": "--space-sm inline (Close Friends) — a sub-entry belongs to the heading above it; depth reads from the indent, never from a size or colour change",
83
+ "link-padding": "--space-xs block / --space-sm inline — enough hit area without turning the list into a button stack",
84
+ "sticky-offset": "--space-lg (Acquaintances) — the gap between the ToC and the top of the viewport when it sticks"
85
+ },
86
+ "constraints": [
87
+ "Derives its content from the page DOM, not from props — it takes NO children and declares NO slot",
88
+ "The document is scanned ONCE, at construction; mount the ToC after the content it describes and re-create it on navigation",
89
+ "A heading that already has an id keeps it, always — that id may be the target of links elsewhere on the site",
90
+ "Generated ids are content-derived slugs (slugifyHeadingText), de-duplicated with -2/-3/… against BOTH the slugs this pass generated AND ids already present in the document",
91
+ "Non-latin letters survive slugification; only latin diacritics are stripped. Reducing to [a-z0-9] would erase a Japanese or Cyrillic heading and collapse a whole document into section, section-2, section-3",
92
+ "A slug starting with a digit is prefixed 'section-': #2024-notes is a legal fragment but an invalid CSS selector, so querySelector('#' + id) would throw",
93
+ "Empty and punctuation-only headings fall back to 'section' — the empty string is not a usable id",
94
+ "Long slugs are truncated at a word boundary (64 chars) and never end in a hyphen",
95
+ "h3 entries nest inside the preceding h2's <li>; an h3 with no preceding h2 is promoted to the top level rather than being dropped",
96
+ "Zero headings renders a real <nav hidden data-empty=\"true\"> with NO list. `hidden` resolves to display:none, so the element leaves BOTH layout and the accessibility tree — it does not hold a space in the caller's layout. It is still returned so the { el, destroy } handle contract holds and the caller can mount it unconditionally",
97
+ "The active entry is recomputed from the FULL observer state on every batch, never patched from the delta — that is what makes overlapping and rapid-fire batches converge deterministically",
98
+ "Several headings on screen at once resolve to the first in DOCUMENT order, not the first in the entry batch (the browser does not order entries)",
99
+ "A heading the observer has not reported on yet is at an unknown position, tracked as +Infinity — treating it as 0 would read as 'exactly at the viewport top' and hand the active state to the last heading in the document",
100
+ "The bottom-of-page fallback (the final heading may be too close to the document end ever to reach the viewport top) is gated on the document actually being scrollable — otherwise a page shorter than the viewport would pin the last entry permanently",
101
+ "Without IntersectionObserver the list still renders and still navigates; only the highlight is inert",
102
+ "destroy() disconnects the observer, removes the scroll/resize listeners and every click listener, removes ONLY the tabindex it added, and empties the nav; a second call is a no-op"
103
+ ],
104
+ "a11y": [
105
+ "Renders a <nav> landmark with an always-present accessible name (default 'On this page') — a docs page holds several <nav> elements and the name is what distinguishes them in a landmark list",
106
+ "The entries are a REAL <ul>/<li> list with a nested <ul> per sub-level, so assistive tech announces the count and the depth; a stack of <div>s would announce neither",
107
+ "Links are real <a href=\"#id\"> elements — Tab, Enter, middle-click, ctrl/cmd-click and 'copy link address' all keep their native behaviour. The click handler bails out on any modified click",
108
+ "The current entry is marked aria-current=\"location\", NOT aria-current=\"page\": every link here points inside the page the reader is already on",
109
+ "Exactly one link carries aria-current at a time",
110
+ "The current state is never conveyed by colour alone (WCAG 1.4.1): the entry gains --font-weight-semibold AND a solid accent rail on top of the muted rail every entry carries",
111
+ "Clicking an entry moves FOCUS to the target heading, so keyboard and screen-reader users land where sighted users are looking. tabindex=\"-1\" is added only when the heading has none of its own, and removed again on destroy",
112
+ "focus() is called with preventScroll so it does not jump instantly and defeat the smooth scroll",
113
+ "Smooth scrolling is gated on prefers-reduced-motion: reduce, which forces behavior: 'auto'; matchMedia is read at click time and guarded for being absent",
114
+ "CSS transitions are wrapped in prefers-reduced-motion: no-preference via the a11y motion-safe mixin",
115
+ "Forced colours redraws the rail (GrayText at rest, Highlight when current/hovered) and restores the focus outline — a11y.focus-ring draws its indicator as a box-shadow over `outline: none`, and forced-colors discards box-shadow without reinstating the suppressed outline (WCAG 2.4.7)",
116
+ "The empty state is <nav hidden>, which removes the nameless, link-less landmark from the accessibility tree instead of leaving a dead focus stop"
117
+ ],
118
+ "ai": {
119
+ "synonyms": [
120
+ "table of contents",
121
+ "toc",
122
+ "on this page",
123
+ "page outline",
124
+ "in this article",
125
+ "jump links",
126
+ "anchor nav",
127
+ "heading index",
128
+ "scroll spy",
129
+ "docs sidebar right rail"
130
+ ],
131
+ "avoid": [
132
+ "using <TableOfContents> for site navigation between pages — it only ever links within the current document; use <Navbar> or a docs sidebar for cross-page links",
133
+ "mounting it before the content it describes exists — the scan happens once, at construction, and an empty container yields a hidden, empty nav",
134
+ "pointing it at a container that holds the page chrome as well as the article — every h2/h3 in range is collected, including a heading inside a card or a form",
135
+ "setting maxLevel to 4+ on a long page — the outline becomes longer than the section it indexes",
136
+ "hand-rolling anchor links next to a <Prose> document — the ids, de-duplication, nesting, scroll-spy and reduced-motion handling are exactly what this component exists to own"
137
+ ],
138
+ "patterns": [
139
+ "Beside a Prose document, zero config: <TableOfContents /> then <Prose as=\"'article'\">…</Prose>",
140
+ "Explicit container: <TableOfContents container=\"'#docs-body'\" />",
141
+ "Deeper outline: <TableOfContents minLevel=\"2\" maxLevel=\"4\" />",
142
+ "Custom landmark name: <TableOfContents label=\"'Contents'\" />",
143
+ "Instant jumps for a dense reference page: <TableOfContents smoothScroll=\"false\" />"
144
+ ]
145
+ },
146
+ "designGuidance": {
147
+ "surface": "Transparent — the ToC sits on whatever surface the page gives it and adds no fill of its own. The only painted area is the hover tint, a --color-primary wash at the design-system hover opacity. Text is --color-text-muted at rest so the list recedes behind the article it indexes, and steps up to --color-text for the hovered and current entries.",
148
+ "depth": "Flat. No shadow, no elevation. A table of contents is a wayfinding aid beside the content, not a panel floating over it.",
149
+ "borders": "One border, used as a rail: a --border-width-medium inline-start edge on every link. It is present at all times (muted --color-divider at rest) so activation changes a colour rather than a box size — no reflow as the reader scrolls — and so the rail reads as a continuous track down the list. Only the inline-end corners are rounded, keeping the rail square against the track.",
150
+ "interactivity": "Every entry is a link, and nothing else is interactive. Hover raises the text colour, brightens the rail and adds the primary tint; :focus-visible surfaces the design-system focus ring. The current entry is the only persistent state, and it is doubled up (weight + accent rail) so it never depends on colour.",
151
+ "motion": "Colour, background and border transitions only, all inside prefers-reduced-motion: no-preference. The click-to-heading scroll is smooth by default and instant under reduced motion — that switch is read at click time, so a preference change mid-session takes effect immediately.",
152
+ "darkMode": "Nothing to do: every value is a --color-* token, so the muted/rest/current relationship inverts with the theme. The forced-colours block is the separate case — there the rail is redrawn with GrayText/Highlight because author colours are discarded outright.",
153
+ "bestPractices": "Reach for it on any page long enough that the reader needs to know where they are: docs, guides, changelogs, long-form posts. Pair it with <Prose> and leave `container` alone — the default '.u-prose' already points at the article. Keep the default 2-3 level window; a deeper outline competes with the page. The component is `position: sticky` with its own max block size, so drop it straight into a docs layout column and it will follow the reader and scroll internally when the outline is long. If the page is short enough to read without scrolling, prefer no ToC at all — the component will render one, but a two-entry outline is chrome, not navigation."
154
+ },
155
+ "examples": [
156
+ {
157
+ "id": "default",
158
+ "title": "Beside a Prose document",
159
+ "pithy": "<TableOfContents />",
160
+ "assertions": [
161
+ "renders <nav class='ui-toc' aria-label='On this page'>",
162
+ "collects h2 and h3 from the default .u-prose container",
163
+ "each entry is an <a href='#id'> inside an <li> inside a <ul>"
164
+ ]
165
+ },
166
+ {
167
+ "id": "nesting",
168
+ "title": "Nested outline",
169
+ "pithy": "<TableOfContents minLevel=\"2\" maxLevel=\"3\" />",
170
+ "assertions": [
171
+ "h3 entries render in a nested <ul> inside the preceding h2's <li>",
172
+ "an h3 before any h2 is promoted to the top level",
173
+ "each link records its source level on data-level"
174
+ ]
175
+ },
176
+ {
177
+ "id": "scroll-spy",
178
+ "title": "Scroll-spy",
179
+ "pithy": "<TableOfContents />",
180
+ "assertions": [
181
+ "the intersecting heading's link gains aria-current='location'",
182
+ "with several headings on screen the first in document order wins",
183
+ "the final heading becomes current once a scrollable page bottoms out",
184
+ "exactly one link carries aria-current at a time"
185
+ ]
186
+ },
187
+ {
188
+ "id": "custom-container",
189
+ "title": "Explicit container and label",
190
+ "pithy": "<TableOfContents container=\"'#docs-body'\" label=\"'Contents'\" />",
191
+ "assertions": [
192
+ "accepts a selector, an HTMLElement, or a () => HTMLElement getter",
193
+ "a container that matches nothing renders <nav hidden data-empty='true'> and DEV-warns"
194
+ ]
195
+ }
196
+ ]
197
+ }