@dxlbnl/ui 0.1.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 (208) hide show
  1. package/README.md +94 -0
  2. package/dist/components/cards/Card.stories.svelte +82 -0
  3. package/dist/components/cards/Card.stories.svelte.d.ts +19 -0
  4. package/dist/components/cards/Card.svelte +28 -0
  5. package/dist/components/cards/Card.svelte.d.ts +12 -0
  6. package/dist/components/cards/NoteCard.stories.svelte +94 -0
  7. package/dist/components/cards/NoteCard.stories.svelte.d.ts +19 -0
  8. package/dist/components/cards/NoteCard.svelte +89 -0
  9. package/dist/components/cards/NoteCard.svelte.d.ts +18 -0
  10. package/dist/components/cards/ProductCard.stories.svelte +98 -0
  11. package/dist/components/cards/ProductCard.stories.svelte.d.ts +19 -0
  12. package/dist/components/cards/ProductCard.svelte +150 -0
  13. package/dist/components/cards/ProductCard.svelte.d.ts +22 -0
  14. package/dist/components/cards/ProjectCard.stories.svelte +88 -0
  15. package/dist/components/cards/ProjectCard.stories.svelte.d.ts +19 -0
  16. package/dist/components/cards/ProjectCard.svelte +109 -0
  17. package/dist/components/cards/ProjectCard.svelte.d.ts +20 -0
  18. package/dist/components/cards/index.d.ts +4 -0
  19. package/dist/components/cards/index.js +4 -0
  20. package/dist/components/data/Accordion.stories.svelte +316 -0
  21. package/dist/components/data/Accordion.stories.svelte.d.ts +19 -0
  22. package/dist/components/data/Accordion.svelte +23 -0
  23. package/dist/components/data/Accordion.svelte.d.ts +9 -0
  24. package/dist/components/data/AccordionItem.svelte +112 -0
  25. package/dist/components/data/AccordionItem.svelte.d.ts +11 -0
  26. package/dist/components/data/Table.composition.stories.svelte +67 -0
  27. package/dist/components/data/Table.composition.stories.svelte.d.ts +19 -0
  28. package/dist/components/data/Table.stories.svelte +137 -0
  29. package/dist/components/data/Table.stories.svelte.d.ts +19 -0
  30. package/dist/components/data/Table.svelte +83 -0
  31. package/dist/components/data/Table.svelte.d.ts +14 -0
  32. package/dist/components/data/Tabs.stories.svelte +386 -0
  33. package/dist/components/data/Tabs.stories.svelte.d.ts +19 -0
  34. package/dist/components/data/Tabs.svelte +142 -0
  35. package/dist/components/data/Tabs.svelte.d.ts +19 -0
  36. package/dist/components/data/index.d.ts +4 -0
  37. package/dist/components/data/index.js +4 -0
  38. package/dist/components/feedback/Modal.stories.svelte +192 -0
  39. package/dist/components/feedback/Modal.stories.svelte.d.ts +4 -0
  40. package/dist/components/feedback/Modal.svelte +185 -0
  41. package/dist/components/feedback/Modal.svelte.d.ts +19 -0
  42. package/dist/components/feedback/Toast.stories.svelte +203 -0
  43. package/dist/components/feedback/Toast.stories.svelte.d.ts +19 -0
  44. package/dist/components/feedback/Toast.svelte +109 -0
  45. package/dist/components/feedback/Toast.svelte.d.ts +15 -0
  46. package/dist/components/feedback/ToastRegion.stories.svelte +193 -0
  47. package/dist/components/feedback/ToastRegion.stories.svelte.d.ts +19 -0
  48. package/dist/components/feedback/ToastRegion.svelte +102 -0
  49. package/dist/components/feedback/ToastRegion.svelte.d.ts +9 -0
  50. package/dist/components/feedback/index.d.ts +3 -0
  51. package/dist/components/feedback/index.js +3 -0
  52. package/dist/components/forms/Checkbox.stories.svelte +103 -0
  53. package/dist/components/forms/Checkbox.stories.svelte.d.ts +19 -0
  54. package/dist/components/forms/Checkbox.svelte +150 -0
  55. package/dist/components/forms/Checkbox.svelte.d.ts +11 -0
  56. package/dist/components/forms/Field.stories.svelte +113 -0
  57. package/dist/components/forms/Field.stories.svelte.d.ts +19 -0
  58. package/dist/components/forms/Field.svelte +77 -0
  59. package/dist/components/forms/Field.svelte.d.ts +17 -0
  60. package/dist/components/forms/Input.stories.svelte +58 -0
  61. package/dist/components/forms/Input.stories.svelte.d.ts +19 -0
  62. package/dist/components/forms/Input.svelte +64 -0
  63. package/dist/components/forms/Input.svelte.d.ts +9 -0
  64. package/dist/components/forms/InputWrap.composition.stories.svelte +32 -0
  65. package/dist/components/forms/InputWrap.composition.stories.svelte.d.ts +19 -0
  66. package/dist/components/forms/InputWrap.stories.svelte +53 -0
  67. package/dist/components/forms/InputWrap.stories.svelte.d.ts +19 -0
  68. package/dist/components/forms/InputWrap.svelte +128 -0
  69. package/dist/components/forms/InputWrap.svelte.d.ts +21 -0
  70. package/dist/components/forms/Radio.stories.svelte +70 -0
  71. package/dist/components/forms/Radio.stories.svelte.d.ts +19 -0
  72. package/dist/components/forms/Radio.svelte +109 -0
  73. package/dist/components/forms/Radio.svelte.d.ts +9 -0
  74. package/dist/components/forms/RadioGroup.stories.svelte +115 -0
  75. package/dist/components/forms/RadioGroup.stories.svelte.d.ts +19 -0
  76. package/dist/components/forms/RadioGroup.svelte +116 -0
  77. package/dist/components/forms/RadioGroup.svelte.d.ts +24 -0
  78. package/dist/components/forms/Select.stories.svelte +168 -0
  79. package/dist/components/forms/Select.stories.svelte.d.ts +19 -0
  80. package/dist/components/forms/Select.svelte +262 -0
  81. package/dist/components/forms/Select.svelte.d.ts +23 -0
  82. package/dist/components/forms/Switch.stories.svelte +86 -0
  83. package/dist/components/forms/Switch.stories.svelte.d.ts +19 -0
  84. package/dist/components/forms/Switch.svelte +113 -0
  85. package/dist/components/forms/Switch.svelte.d.ts +11 -0
  86. package/dist/components/forms/Textarea.stories.svelte +40 -0
  87. package/dist/components/forms/Textarea.stories.svelte.d.ts +19 -0
  88. package/dist/components/forms/Textarea.svelte +66 -0
  89. package/dist/components/forms/Textarea.svelte.d.ts +9 -0
  90. package/dist/components/forms/field-context.d.ts +7 -0
  91. package/dist/components/forms/field-context.js +1 -0
  92. package/dist/components/forms/index.d.ts +9 -0
  93. package/dist/components/forms/index.js +9 -0
  94. package/dist/components/layout/Container.stories.svelte +67 -0
  95. package/dist/components/layout/Container.stories.svelte.d.ts +19 -0
  96. package/dist/components/layout/Container.svelte +52 -0
  97. package/dist/components/layout/Container.svelte.d.ts +14 -0
  98. package/dist/components/layout/Grid.stories.svelte +109 -0
  99. package/dist/components/layout/Grid.stories.svelte.d.ts +19 -0
  100. package/dist/components/layout/Grid.svelte +54 -0
  101. package/dist/components/layout/Grid.svelte.d.ts +19 -0
  102. package/dist/components/layout/Inline.stories.svelte +136 -0
  103. package/dist/components/layout/Inline.stories.svelte.d.ts +19 -0
  104. package/dist/components/layout/Inline.svelte +46 -0
  105. package/dist/components/layout/Inline.svelte.d.ts +19 -0
  106. package/dist/components/layout/Prose.stories.svelte +423 -0
  107. package/dist/components/layout/Prose.stories.svelte.d.ts +19 -0
  108. package/dist/components/layout/Prose.svelte +176 -0
  109. package/dist/components/layout/Prose.svelte.d.ts +12 -0
  110. package/dist/components/layout/Rule.stories.svelte +80 -0
  111. package/dist/components/layout/Rule.stories.svelte.d.ts +19 -0
  112. package/dist/components/layout/Rule.svelte +33 -0
  113. package/dist/components/layout/Rule.svelte.d.ts +9 -0
  114. package/dist/components/layout/Spread.stories.svelte +118 -0
  115. package/dist/components/layout/Spread.stories.svelte.d.ts +19 -0
  116. package/dist/components/layout/Spread.svelte +38 -0
  117. package/dist/components/layout/Spread.svelte.d.ts +16 -0
  118. package/dist/components/layout/Stack.stories.svelte +90 -0
  119. package/dist/components/layout/Stack.stories.svelte.d.ts +19 -0
  120. package/dist/components/layout/Stack.svelte +37 -0
  121. package/dist/components/layout/Stack.svelte.d.ts +16 -0
  122. package/dist/components/layout/index.d.ts +7 -0
  123. package/dist/components/layout/index.js +7 -0
  124. package/dist/components/navigation/Breadcrumb.stories.svelte +122 -0
  125. package/dist/components/navigation/Breadcrumb.stories.svelte.d.ts +19 -0
  126. package/dist/components/navigation/Breadcrumb.svelte +70 -0
  127. package/dist/components/navigation/Breadcrumb.svelte.d.ts +13 -0
  128. package/dist/components/navigation/Nav.stories.svelte +323 -0
  129. package/dist/components/navigation/Nav.stories.svelte.d.ts +19 -0
  130. package/dist/components/navigation/Nav.svelte +257 -0
  131. package/dist/components/navigation/Nav.svelte.d.ts +21 -0
  132. package/dist/components/navigation/index.d.ts +2 -0
  133. package/dist/components/navigation/index.js +2 -0
  134. package/dist/components/patterns/ActivityRow.stories.svelte +45 -0
  135. package/dist/components/patterns/ActivityRow.stories.svelte.d.ts +19 -0
  136. package/dist/components/patterns/ActivityRow.svelte +69 -0
  137. package/dist/components/patterns/ActivityRow.svelte.d.ts +16 -0
  138. package/dist/components/patterns/Alert.stories.svelte +63 -0
  139. package/dist/components/patterns/Alert.stories.svelte.d.ts +19 -0
  140. package/dist/components/patterns/Alert.svelte +91 -0
  141. package/dist/components/patterns/Alert.svelte.d.ts +16 -0
  142. package/dist/components/patterns/CtaBlock.stories.svelte +62 -0
  143. package/dist/components/patterns/CtaBlock.stories.svelte.d.ts +19 -0
  144. package/dist/components/patterns/CtaBlock.svelte +80 -0
  145. package/dist/components/patterns/CtaBlock.svelte.d.ts +16 -0
  146. package/dist/components/patterns/KvList.stories.svelte +48 -0
  147. package/dist/components/patterns/KvList.stories.svelte.d.ts +19 -0
  148. package/dist/components/patterns/KvList.svelte +65 -0
  149. package/dist/components/patterns/KvList.svelte.d.ts +15 -0
  150. package/dist/components/patterns/PageHero.stories.svelte +62 -0
  151. package/dist/components/patterns/PageHero.stories.svelte.d.ts +19 -0
  152. package/dist/components/patterns/PageHero.svelte +62 -0
  153. package/dist/components/patterns/PageHero.svelte.d.ts +14 -0
  154. package/dist/components/patterns/ProgressBar.stories.svelte +83 -0
  155. package/dist/components/patterns/ProgressBar.stories.svelte.d.ts +19 -0
  156. package/dist/components/patterns/ProgressBar.svelte +71 -0
  157. package/dist/components/patterns/ProgressBar.svelte.d.ts +13 -0
  158. package/dist/components/patterns/SectionFoot.stories.svelte +37 -0
  159. package/dist/components/patterns/SectionFoot.stories.svelte.d.ts +19 -0
  160. package/dist/components/patterns/SectionFoot.svelte +70 -0
  161. package/dist/components/patterns/SectionFoot.svelte.d.ts +15 -0
  162. package/dist/components/patterns/SectionHead.stories.svelte +67 -0
  163. package/dist/components/patterns/SectionHead.stories.svelte.d.ts +19 -0
  164. package/dist/components/patterns/SectionHead.svelte +54 -0
  165. package/dist/components/patterns/SectionHead.svelte.d.ts +14 -0
  166. package/dist/components/patterns/StatCard.stories.svelte +59 -0
  167. package/dist/components/patterns/StatCard.stories.svelte.d.ts +19 -0
  168. package/dist/components/patterns/StatCard.svelte +57 -0
  169. package/dist/components/patterns/StatCard.svelte.d.ts +15 -0
  170. package/dist/components/patterns/index.d.ts +9 -0
  171. package/dist/components/patterns/index.js +9 -0
  172. package/dist/components/primitives/Button.stories.svelte +132 -0
  173. package/dist/components/primitives/Button.stories.svelte.d.ts +19 -0
  174. package/dist/components/primitives/Button.svelte +142 -0
  175. package/dist/components/primitives/Button.svelte.d.ts +16 -0
  176. package/dist/components/primitives/Heading.stories.svelte +137 -0
  177. package/dist/components/primitives/Heading.stories.svelte.d.ts +19 -0
  178. package/dist/components/primitives/Heading.svelte +107 -0
  179. package/dist/components/primitives/Heading.svelte.d.ts +23 -0
  180. package/dist/components/primitives/Led.stories.svelte +63 -0
  181. package/dist/components/primitives/Led.stories.svelte.d.ts +19 -0
  182. package/dist/components/primitives/Led.svelte +65 -0
  183. package/dist/components/primitives/Led.svelte.d.ts +11 -0
  184. package/dist/components/primitives/TagPill.stories.svelte +90 -0
  185. package/dist/components/primitives/TagPill.stories.svelte.d.ts +19 -0
  186. package/dist/components/primitives/TagPill.svelte +44 -0
  187. package/dist/components/primitives/TagPill.svelte.d.ts +9 -0
  188. package/dist/components/primitives/Text.stories.svelte +252 -0
  189. package/dist/components/primitives/Text.stories.svelte.d.ts +19 -0
  190. package/dist/components/primitives/Text.svelte +101 -0
  191. package/dist/components/primitives/Text.svelte.d.ts +25 -0
  192. package/dist/components/primitives/index.d.ts +5 -0
  193. package/dist/components/primitives/index.js +5 -0
  194. package/dist/index.d.ts +10 -0
  195. package/dist/index.js +10 -0
  196. package/dist/stores/toast.d.ts +19 -0
  197. package/dist/stores/toast.js +22 -0
  198. package/dist/storybook-utils.d.ts +11 -0
  199. package/dist/storybook-utils.js +29 -0
  200. package/dist/tokens/ColorSwatch.svelte +73 -0
  201. package/dist/tokens/ColorSwatch.svelte.d.ts +10 -0
  202. package/dist/tokens/layout.css +144 -0
  203. package/dist/tokens/patterns.css +281 -0
  204. package/dist/tokens/tokens.css +96 -0
  205. package/dist/tokens/tokens.stories.svelte +107 -0
  206. package/dist/tokens/tokens.stories.svelte.d.ts +18 -0
  207. package/dist/tokens/typography.css +159 -0
  208. package/package.json +62 -0
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ interface Crumb {
3
+ label: string
4
+ href: string
5
+ }
6
+
7
+ interface BreadcrumbProps {
8
+ /** Array of `{ label, href }` crumbs — last item gets `aria-current="page"`. */
9
+ crumbs: Crumb[]
10
+ /** Root element tag. Use `"div"` when embedding inside a `<nav>` to avoid nested landmark. @default 'nav' */
11
+ as?: string
12
+ }
13
+
14
+ let { crumbs, as = 'nav' }: BreadcrumbProps = $props()
15
+ </script>
16
+
17
+ <svelte:element this={as} class="breadcrumb" aria-label="breadcrumb">
18
+ <ol class="bc-list">
19
+ {#each crumbs as crumb, i}
20
+ <li class="bc-item">
21
+ {#if i > 0}<span class="bc-sep" aria-hidden="true">/</span>{/if}
22
+ <a
23
+ href={crumb.href}
24
+ class="bc-link"
25
+ aria-current={i === crumbs.length - 1 ? 'page' : undefined}
26
+ >{crumb.label}</a>
27
+ </li>
28
+ {/each}
29
+ </ol>
30
+ </svelte:element>
31
+
32
+ <style>
33
+ .breadcrumb {
34
+ display: block;
35
+ }
36
+
37
+ .bc-list {
38
+ display: flex;
39
+ align-items: center;
40
+ flex-wrap: wrap;
41
+ gap: 6px;
42
+ list-style: none;
43
+ padding: 0;
44
+ margin: 0;
45
+ font-family: var(--mono);
46
+ font-size: 11px;
47
+ letter-spacing: 0.08em;
48
+ }
49
+
50
+ .bc-item {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: 6px;
54
+ }
55
+
56
+ .bc-link {
57
+ color: var(--amber);
58
+ text-decoration: none;
59
+ }
60
+
61
+ .bc-link:hover {
62
+ color: var(--amber);
63
+ opacity: 0.75;
64
+ }
65
+
66
+ .bc-sep {
67
+ color: var(--ink-faint);
68
+ user-select: none;
69
+ }
70
+ </style>
@@ -0,0 +1,13 @@
1
+ interface Crumb {
2
+ label: string;
3
+ href: string;
4
+ }
5
+ interface BreadcrumbProps {
6
+ /** Array of `{ label, href }` crumbs — last item gets `aria-current="page"`. */
7
+ crumbs: Crumb[];
8
+ /** Root element tag. Use `"div"` when embedding inside a `<nav>` to avoid nested landmark. @default 'nav' */
9
+ as?: string;
10
+ }
11
+ declare const Breadcrumb: import("svelte").Component<BreadcrumbProps, {}, "">;
12
+ type Breadcrumb = ReturnType<typeof Breadcrumb>;
13
+ export default Breadcrumb;
@@ -0,0 +1,323 @@
1
+ <script module lang="ts">
2
+ import { defineMeta } from "@storybook/addon-svelte-csf";
3
+ import { expect, within } from "storybook/test";
4
+ import Nav from "./Nav.svelte";
5
+ import { resolveTokenColor, resolveTokenFgColor } from "../../storybook-utils.js";
6
+
7
+ const { Story } = defineMeta({
8
+ title: "Navigation/Nav",
9
+ component: Nav,
10
+ tags: ["autodocs"],
11
+ });
12
+ </script>
13
+
14
+ <!-- AC-B28-9 (44,45,46,47,48,49), AC-B28-3 (17), AC-B28-5 (27,28,29,30,31), B28-3 (15,18) -->
15
+ <Story name="Default" args={{
16
+ links: [
17
+ { href: '/feed', label: 'FEED' },
18
+ { href: '/catalogue', label: 'CATALOGUE', active: true },
19
+ { href: '/projects', label: 'PROJECTS' },
20
+ { href: '/notes', label: 'NOTES' },
21
+ { href: '/about', label: 'ABOUT' },
22
+ { href: '/contact', label: 'CONTACT' },
23
+ ],
24
+ 'data-testid': 'main-nav',
25
+ }}
26
+ play={async ({ canvasElement }) => {
27
+ const canvas = within(canvasElement);
28
+
29
+ // AC-44: <nav> element is present
30
+ const nav = canvas.getByRole('navigation');
31
+ await expect(nav).toBeVisible();
32
+
33
+ // AC-45: <nav> has position: fixed and z-index >= 100
34
+ await expect(getComputedStyle(nav).position).toBe('fixed');
35
+ await expect(parseInt(getComputedStyle(nav).zIndex)).toBeGreaterThanOrEqual(100);
36
+
37
+ // AC-46: <nav> has background-color matching --bg and border-bottom-color matching --rule
38
+ await expect(getComputedStyle(nav).backgroundColor).toBe(resolveTokenColor('--bg'));
39
+ await expect(getComputedStyle(nav).borderBottomColor).toBe(resolveTokenColor('--rule'));
40
+
41
+ // AC-47: .nav-inner has display: flex and height: 48px
42
+ const inner = canvasElement.querySelector('.nav-inner') as HTMLElement | null;
43
+ await expect(inner).not.toBeNull();
44
+ await expect(getComputedStyle(inner!).display).toBe('flex');
45
+ await expect(getComputedStyle(inner!).height).toBe('48px');
46
+
47
+ // AC-48: ...rest spread — data-testid forwarded onto <nav>
48
+ await expect(nav.getAttribute('data-testid')).toBe('main-nav');
49
+
50
+ // AC-49: brand link contains 'DEXTERLABS' (default siteName)
51
+ await expect(canvas.getByRole('link', { name: /DEXTERLABS/ })).toBeVisible();
52
+
53
+ // active link has aria-current="page" (AC-44 / AC-51)
54
+ const activeLink = canvas.getByRole('link', { name: 'CATALOGUE' });
55
+ await expect(activeLink.getAttribute('aria-current')).toBe('page');
56
+
57
+ // AC-31: palette toggle button present with correct accessible name
58
+ const toggleBtn = canvas.getByRole('button', { name: /toggle colour palette/i });
59
+ await expect(toggleBtn).toBeVisible();
60
+
61
+ // AC-27: palette toggle has both 'btn' and 'btn-nav' classes
62
+ await expect(toggleBtn.classList.contains('btn')).toBe(true);
63
+ await expect(toggleBtn.classList.contains('btn-nav')).toBe(true);
64
+
65
+ // AC-28: palette toggle does NOT have 'btn-ghost' class
66
+ await expect(toggleBtn.classList.contains('btn-ghost')).toBe(false);
67
+
68
+ // AC-29: palette toggle color matches --ink-faint at rest
69
+ await expect(getComputedStyle(toggleBtn).color).toBe(resolveTokenFgColor('--ink-faint'));
70
+
71
+ // AC-30: palette toggle background-color is transparent
72
+ await expect(getComputedStyle(toggleBtn).backgroundColor).toBe('rgba(0, 0, 0, 0)');
73
+
74
+ // AC-18: no .nav-hamburger in DOM
75
+ const hamburger = canvasElement.querySelector('.nav-hamburger');
76
+ await expect(hamburger).toBeNull();
77
+
78
+ // AC-18: no #nav-drawer in DOM
79
+ const drawer = canvasElement.querySelector('#nav-drawer');
80
+ await expect(drawer).toBeNull();
81
+ }} />
82
+
83
+ <!-- AC-B28-3 (15,17), AC-B28-9 (44) -->
84
+ <Story name="No Links" args={{ links: [] }}
85
+ play={async ({ canvasElement }) => {
86
+ const canvas = within(canvasElement);
87
+
88
+ // AC-44: <nav> is present
89
+ const nav = canvas.getByRole('navigation');
90
+ await expect(nav).toBeVisible();
91
+
92
+ // AC-15: no <details> element in DOM when links is empty
93
+ const details = canvasElement.querySelector('details');
94
+ await expect(details).toBeNull();
95
+
96
+ // no <ul class="nav-links">
97
+ const navLinks = canvasElement.querySelector('ul.nav-links');
98
+ await expect(navLinks).toBeNull();
99
+
100
+ // AC-17: palette toggle is still present
101
+ const toggleBtn = canvas.getByRole('button', { name: 'Toggle colour palette' });
102
+ await expect(toggleBtn).toBeVisible();
103
+ }} />
104
+
105
+ <!-- AC-B28-2 (7,8,9,10,12) -->
106
+ <Story name="With Breadcrumbs" args={{
107
+ links: [
108
+ { href: '/feed', label: 'FEED' },
109
+ { href: '/catalogue', label: 'CATALOGUE', active: true },
110
+ { href: '/projects', label: 'PROJECTS' },
111
+ { href: '/notes', label: 'NOTES', active: false },
112
+ { href: '/about', label: 'ABOUT' },
113
+ { href: '/contact', label: 'CONTACT' },
114
+ ],
115
+ breadcrumbs: [
116
+ { label: '~', href: '/' },
117
+ { label: 'NOTES', href: '/notes/' },
118
+ ]
119
+ }}
120
+ play={async ({ canvasElement }) => {
121
+ // AC-8/9: .nav-path element is present
122
+ const navPath = canvasElement.querySelector('.nav-path');
123
+ await expect(navPath).not.toBeNull();
124
+
125
+ // crumb anchors: ~ links to / and NOTES links to /notes/
126
+ const crumbLinks = navPath!.querySelectorAll('a');
127
+ const hrefs = Array.from(crumbLinks).map(a => a.getAttribute('href'));
128
+ await expect(hrefs).toContain('/');
129
+ await expect(hrefs).toContain('/notes/');
130
+
131
+ // AC-11: each crumb anchor color matches --amber and is clickable (no pointer-events: none)
132
+ const amberColor = resolveTokenFgColor('--amber');
133
+ for (const crumb of Array.from(crumbLinks) as HTMLElement[]) {
134
+ await expect(getComputedStyle(crumb).color).toBe(amberColor);
135
+ await expect(getComputedStyle(crumb).pointerEvents).not.toBe('none');
136
+ }
137
+
138
+ // AC-10: .nav-sep span contains '//'
139
+ const navSep = navPath!.querySelector('.nav-sep');
140
+ await expect(navSep).not.toBeNull();
141
+ await expect(navSep!.textContent?.trim()).toBe('//');
142
+
143
+ // AC-12: .nav-sep color matches --ink-faint
144
+ const inkFaint = resolveTokenFgColor('--ink-faint');
145
+ await expect(getComputedStyle(navSep!).color).toBe(inkFaint);
146
+ }} />
147
+
148
+ <!-- AC-B28-2 (7), AC-B28-3 (15) — breadcrumbs present but links empty -->
149
+ <Story name="Breadcrumbs Only No Links" args={{
150
+ links: [],
151
+ breadcrumbs: [
152
+ { label: '~', href: '/' },
153
+ { label: 'NOTES', href: '/notes/' },
154
+ ]
155
+ }}
156
+ play={async ({ canvasElement }) => {
157
+ // .nav-path present because breadcrumbs is non-empty
158
+ const navPath = canvasElement.querySelector('.nav-path');
159
+ await expect(navPath).not.toBeNull();
160
+
161
+ // no <details> because links is empty (AC-15)
162
+ const details = canvasElement.querySelector('details');
163
+ await expect(details).toBeNull();
164
+ }} />
165
+
166
+ <!-- AC-B28-9 (50) -->
167
+ <Story name="Custom Site Name" args={{ links: [], siteName: 'LAB' }}
168
+ play={async ({ canvasElement }) => {
169
+ // brand link contains the custom siteName
170
+ await expect(within(canvasElement).getByRole('link', { name: /LAB/ })).toBeVisible();
171
+ // must not contain the default
172
+ const text = canvasElement.textContent ?? '';
173
+ await expect(text).toContain('LAB');
174
+ }} />
175
+
176
+ <!-- AC-B28-9 (53) -->
177
+ <Story name="Palette Toggle" args={{
178
+ links: [
179
+ { href: '/feed', label: 'FEED' },
180
+ { href: '/catalogue', label: 'CATALOGUE', active: true },
181
+ { href: '/projects', label: 'PROJECTS' },
182
+ { href: '/notes', label: 'NOTES' },
183
+ { href: '/about', label: 'ABOUT' },
184
+ { href: '/contact', label: 'CONTACT' },
185
+ ]
186
+ }}
187
+ play={async ({ canvasElement, userEvent }) => {
188
+ const canvas = within(canvasElement);
189
+ const btn = canvas.getByRole('button', { name: 'Toggle colour palette' });
190
+
191
+ // record the initial data-palette value
192
+ const before = document.documentElement.getAttribute('data-palette');
193
+
194
+ // click once — palette should change
195
+ await userEvent.click(btn);
196
+ const after = document.documentElement.getAttribute('data-palette');
197
+ await expect(after).not.toBe(before);
198
+ await expect(localStorage.getItem('dxlb-palette')).toBe(after);
199
+
200
+ // click again — palette should be restored
201
+ await userEvent.click(btn);
202
+ const restored = document.documentElement.getAttribute('data-palette');
203
+ await expect(restored).toBe(before);
204
+ await expect(localStorage.getItem('dxlb-palette')).toBe(before);
205
+ }} />
206
+
207
+ <!-- AC-B28-7 (34,35) -->
208
+ <Story name="Active Link Styling" args={{
209
+ links: [
210
+ { href: '/feed', label: 'FEED' },
211
+ { href: '/catalogue', label: 'CATALOGUE' },
212
+ { href: '/projects', label: 'PROJECTS' },
213
+ { href: '/notes', label: 'NOTES', active: true },
214
+ { href: '/about', label: 'ABOUT' },
215
+ { href: '/contact', label: 'CONTACT' },
216
+ ]
217
+ }}
218
+ play={async ({ canvasElement }) => {
219
+ // get the NOTES anchor via aria-current
220
+ const activeLinks = canvasElement.querySelectorAll('.nav-link.active');
221
+ await expect(activeLinks.length).toBeGreaterThan(0);
222
+ const anchor = activeLinks[0] as HTMLElement;
223
+
224
+ // AC-34: aria-current="page"
225
+ await expect(anchor.getAttribute('aria-current')).toBe('page');
226
+
227
+ // AC-35: border-bottom-color matches --amber
228
+ const amberColor = resolveTokenFgColor('--amber');
229
+ await expect(getComputedStyle(anchor).borderBottomColor).toBe(amberColor);
230
+
231
+ // AC-34: color matches --ink
232
+ const inkColor = resolveTokenFgColor('--ink');
233
+ await expect(getComputedStyle(anchor).color).toBe(inkColor);
234
+
235
+ // AC-36: a non-active .nav-link has color matching --ink-dim
236
+ const inactiveLinks = canvasElement.querySelectorAll('.nav-link:not(.active)');
237
+ await expect(inactiveLinks.length).toBeGreaterThan(0);
238
+ const inactiveLink = inactiveLinks[0] as HTMLElement;
239
+ const inkDimColor = resolveTokenFgColor('--ink-dim');
240
+ await expect(getComputedStyle(inactiveLink).color).toBe(inkDimColor);
241
+
242
+ // AC-37: a non-active .nav-link has transparent border-bottom-color
243
+ await expect(getComputedStyle(inactiveLink).borderBottomColor).toBe('rgba(0, 0, 0, 0)');
244
+ }} />
245
+
246
+ <!-- AC-B28-4 (20,21,22,23,24) -->
247
+ <Story name="Mobile Menu Details" args={{
248
+ links: [
249
+ { href: '/feed', label: 'FEED' },
250
+ { href: '/catalogue', label: 'CATALOGUE', active: true },
251
+ { href: '/projects', label: 'PROJECTS' },
252
+ { href: '/notes', label: 'NOTES' },
253
+ { href: '/about', label: 'ABOUT' },
254
+ { href: '/contact', label: 'CONTACT' },
255
+ ]
256
+ }}
257
+ play={async ({ canvasElement }) => {
258
+ // AC-20: <details class="nav-menu"> is present
259
+ const details = canvasElement.querySelector('details.nav-menu') as HTMLDetailsElement | null;
260
+ await expect(details).not.toBeNull();
261
+
262
+ // AC-20: contains <summary class="nav-summary">
263
+ const summary = details!.querySelector('summary.nav-summary');
264
+ await expect(summary).not.toBeNull();
265
+
266
+ // AC-21: summary contains nav-icon-open (≡) and nav-icon-close (×)
267
+ const iconOpen = details!.querySelector('.nav-icon-open') as HTMLElement | null;
268
+ const iconClose = details!.querySelector('.nav-icon-close') as HTMLElement | null;
269
+ await expect(iconOpen).not.toBeNull();
270
+ await expect(iconClose).not.toBeNull();
271
+ await expect(iconOpen!.textContent?.trim()).toBe('≡');
272
+ await expect(iconClose!.textContent?.trim()).toBe('×');
273
+
274
+ // AC-22: .nav-icon-close is display:none when <details> is closed
275
+ await expect(getComputedStyle(iconClose!).display).toBe('none');
276
+
277
+ // AC-23: open the details programmatically
278
+ details!.setAttribute('open', '');
279
+ // Allow browser to re-apply styles
280
+ await new Promise(r => setTimeout(r, 0));
281
+ await expect(getComputedStyle(iconOpen!).display).toBe('none');
282
+ await expect(getComputedStyle(iconClose!).display).not.toBe('none');
283
+
284
+ // Close it again for cleanliness
285
+ details!.removeAttribute('open');
286
+
287
+ // AC-24: .nav-dropdown-link anchors exist with correct hrefs
288
+ const dropdownLinks = details!.querySelectorAll('.nav-dropdown-link');
289
+ await expect(dropdownLinks.length).toBe(6);
290
+ const hrefs = Array.from(dropdownLinks).map(a => (a as HTMLAnchorElement).getAttribute('href'));
291
+ await expect(hrefs).toContain('/feed');
292
+ await expect(hrefs).toContain('/catalogue');
293
+ await expect(hrefs).toContain('/notes');
294
+
295
+ // AC-25: active dropdown link has aria-current="page"
296
+ const activeDropdownLink = Array.from(dropdownLinks).find(
297
+ a => (a as HTMLAnchorElement).getAttribute('href') === '/catalogue'
298
+ ) as HTMLElement | undefined;
299
+ await expect(activeDropdownLink).toBeDefined();
300
+ await expect(activeDropdownLink!.getAttribute('aria-current')).toBe('page');
301
+
302
+ // AC-39: .nav-dropdown has position: absolute
303
+ const dropdown = details!.querySelector('.nav-dropdown') as HTMLElement | null;
304
+ await expect(dropdown).not.toBeNull();
305
+ await expect(getComputedStyle(dropdown!).position).toBe('absolute');
306
+
307
+ // AC-40: .nav-dropdown background-color matches --bg
308
+ await expect(getComputedStyle(dropdown!).backgroundColor).toBe(resolveTokenColor('--bg'));
309
+
310
+ // AC-41: .nav-dropdown border-bottom-color matches --rule
311
+ await expect(getComputedStyle(dropdown!).borderBottomColor).toBe(resolveTokenColor('--rule'));
312
+
313
+ // AC-42: first dropdown link has padding-top and padding-bottom of 10px
314
+ await expect(getComputedStyle(dropdownLinks[0] as HTMLElement).paddingTop).toBe('10px');
315
+ await expect(getComputedStyle(dropdownLinks[0] as HTMLElement).paddingBottom).toBe('10px');
316
+
317
+ // AC-43 (non-last): first dropdown link has border-bottom-color matching --rule
318
+ await expect(getComputedStyle(dropdownLinks[0] as HTMLElement).borderBottomColor).toBe(resolveTokenColor('--rule'));
319
+
320
+ // AC-43 (last-child): last dropdown link has border-bottom-width of 0px
321
+ const lastLink = dropdownLinks[dropdownLinks.length - 1] as HTMLElement;
322
+ await expect(getComputedStyle(lastLink).borderBottomWidth).toBe('0px');
323
+ }} />
@@ -0,0 +1,19 @@
1
+ import Nav from "./Nav.svelte";
2
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
3
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
4
+ $$bindings?: Bindings;
5
+ } & Exports;
6
+ (internal: unknown, props: {
7
+ $$events?: Events;
8
+ $$slots?: Slots;
9
+ }): Exports & {
10
+ $set?: any;
11
+ $on?: any;
12
+ };
13
+ z_$$bindings?: Bindings;
14
+ }
15
+ declare const Nav: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
16
+ [evt: string]: CustomEvent<any>;
17
+ }, {}, {}, string>;
18
+ type Nav = InstanceType<typeof Nav>;
19
+ export default Nav;
@@ -0,0 +1,257 @@
1
+ <script lang="ts">
2
+ import Led from '../primitives/Led.svelte'
3
+ import Button from '../primitives/Button.svelte'
4
+ import Breadcrumb from './Breadcrumb.svelte'
5
+
6
+ interface BreadcrumbItem {
7
+ label: string
8
+ href: string
9
+ }
10
+
11
+ interface NavLink {
12
+ href: string
13
+ label: string
14
+ active?: boolean
15
+ }
16
+
17
+ interface Props {
18
+ /** Navigation links to render in the top bar. @default [] */
19
+ links?: NavLink[]
20
+ /** Brand name shown next to the status LED. @default 'DEXTERLABS' */
21
+ siteName?: string
22
+ /** Optional breadcrumb trail. @default [] */
23
+ breadcrumbs?: BreadcrumbItem[]
24
+ [key: string]: unknown
25
+ }
26
+
27
+ let { links = [], siteName = 'DEXTERLABS', breadcrumbs = [], ...rest }: Props = $props()
28
+
29
+ type Palette = 'phosphor' | 'paper'
30
+ let palette = $state<Palette>('phosphor')
31
+
32
+ const PALETTE_KEY = 'dxlb-palette'
33
+
34
+ $effect(() => {
35
+ const stored = localStorage.getItem(PALETTE_KEY) as Palette | null
36
+ if (stored === 'paper' || stored === 'phosphor') palette = stored
37
+ document.documentElement.setAttribute('data-palette', palette)
38
+ })
39
+
40
+ function handlePaletteToggle() {
41
+ palette = palette === 'paper' ? 'phosphor' : 'paper'
42
+ document.documentElement.setAttribute('data-palette', palette)
43
+ localStorage.setItem(PALETTE_KEY, palette)
44
+ }
45
+ </script>
46
+
47
+ <nav class="nav" {...rest}>
48
+ <div class="nav-inner">
49
+ <a class="nav-brand" href="/">
50
+ <Led color="ok" aria-hidden="true" />
51
+ <span class="nav-wordmark">{siteName}</span>
52
+ </a>
53
+
54
+ {#if breadcrumbs?.length}
55
+ <div class="nav-path">
56
+ <span class="nav-sep">//</span>
57
+ <Breadcrumb crumbs={breadcrumbs} as="div" />
58
+ </div>
59
+ {/if}
60
+
61
+ <div class="nav-right">
62
+ {#if links?.length}
63
+ <ul class="nav-links">
64
+ {#each links as link}
65
+ <li>
66
+ <a href={link.href} class="nav-link" class:active={link.active}
67
+ aria-current={link.active ? 'page' : undefined}>
68
+ {link.label}
69
+ </a>
70
+ </li>
71
+ {/each}
72
+ </ul>
73
+ {/if}
74
+
75
+ <Button variant="nav" aria-label="Toggle colour palette" onclick={handlePaletteToggle}>
76
+ {palette === 'paper' ? '◑' : '◐'}
77
+ </Button>
78
+
79
+ {#if links?.length}
80
+ <details class="nav-menu">
81
+ <summary class="nav-summary">
82
+ <span class="nav-icon-open">≡</span>
83
+ <span class="nav-icon-close">×</span>
84
+ </summary>
85
+ <div class="nav-dropdown">
86
+ {#each links as link}
87
+ <a href={link.href} class="nav-dropdown-link" class:active={link.active}
88
+ aria-current={link.active ? 'page' : undefined}>{link.label}</a>
89
+ {/each}
90
+ </div>
91
+ </details>
92
+ {/if}
93
+ </div>
94
+ </div>
95
+ </nav>
96
+
97
+ <style>
98
+ .nav {
99
+ position: fixed;
100
+ top: 0;
101
+ left: 0;
102
+ right: 0;
103
+ z-index: 100;
104
+ background: var(--bg);
105
+ border-bottom: 1px solid var(--rule);
106
+ font-family: var(--mono);
107
+ font-size: var(--t-mono);
108
+ letter-spacing: 0.08em;
109
+ }
110
+
111
+ .nav-inner {
112
+ display: flex;
113
+ align-items: center;
114
+ flex-wrap: nowrap;
115
+ padding: 0 24px;
116
+ height: 48px;
117
+ max-width: 1200px;
118
+ margin: 0 auto;
119
+ }
120
+
121
+ .nav-brand {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: var(--u);
125
+ flex-shrink: 0;
126
+ color: var(--ink);
127
+ text-decoration: none;
128
+ font-weight: 700;
129
+ letter-spacing: 0.12em;
130
+ text-transform: uppercase;
131
+ }
132
+
133
+ .nav-path {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 6px;
137
+ overflow: hidden;
138
+ min-width: 0;
139
+ margin-left: 8px;
140
+ }
141
+
142
+ .nav-sep {
143
+ color: var(--ink-faint);
144
+ flex-shrink: 0;
145
+ }
146
+
147
+ .nav-right {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: var(--u2);
151
+ margin-left: auto;
152
+ flex-shrink: 0;
153
+ }
154
+
155
+ .nav-links {
156
+ display: flex;
157
+ align-items: center;
158
+ flex-wrap: wrap;
159
+ gap: var(--u2);
160
+ padding: 0;
161
+ margin: 0;
162
+ flex-shrink: 0;
163
+ text-transform: uppercase;
164
+ font-size: 12px;
165
+ list-style: none;
166
+ }
167
+
168
+ .nav-link {
169
+ color: var(--ink-dim);
170
+ text-decoration: none;
171
+ padding-bottom: 2px;
172
+ border-bottom: 1px solid transparent;
173
+ transition: color var(--transition), border-color var(--transition);
174
+ }
175
+
176
+ .nav-link:hover {
177
+ color: var(--ink);
178
+ border-bottom-color: var(--amber);
179
+ }
180
+
181
+ .nav-link.active {
182
+ color: var(--ink);
183
+ border-bottom-color: var(--amber);
184
+ }
185
+
186
+ .nav-menu {
187
+ display: none;
188
+ }
189
+
190
+ .nav-icon-close {
191
+ display: none;
192
+ }
193
+
194
+ details[open] .nav-icon-open {
195
+ display: none;
196
+ }
197
+
198
+ details[open] .nav-icon-close {
199
+ display: inline;
200
+ }
201
+
202
+ .nav-summary {
203
+ list-style: none;
204
+ cursor: pointer;
205
+ color: var(--ink-dim);
206
+ user-select: none;
207
+ }
208
+
209
+ .nav-summary:hover {
210
+ color: var(--ink);
211
+ }
212
+
213
+ .nav-summary::-webkit-details-marker {
214
+ display: none;
215
+ }
216
+
217
+ .nav-dropdown {
218
+ position: absolute;
219
+ top: 100%;
220
+ left: 0;
221
+ right: 0;
222
+ z-index: 100;
223
+ background: var(--bg);
224
+ border-bottom: 1px solid var(--rule);
225
+ display: flex;
226
+ flex-direction: column;
227
+ padding: 4px 16px 8px;
228
+ text-transform: uppercase;
229
+ }
230
+
231
+ .nav-dropdown-link {
232
+ padding: 10px 0;
233
+ border-bottom: 1px solid var(--rule);
234
+ color: var(--ink-dim);
235
+ text-decoration: none;
236
+ }
237
+
238
+ .nav-dropdown-link.active {
239
+ color: var(--amber);
240
+ }
241
+
242
+ .nav-dropdown > :last-child {
243
+ border-bottom: none;
244
+ }
245
+
246
+ @media (max-width: 720px) {
247
+ .nav-links {
248
+ display: none;
249
+ }
250
+
251
+ .nav-menu {
252
+ display: block;
253
+ margin-left: auto;
254
+ flex-shrink: 0;
255
+ }
256
+ }
257
+ </style>