@thomasfosterau/components 0.0.0-stage → 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 (340) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +817 -2
  3. package/dist/canvas/VectorCanvas.svelte +239 -0
  4. package/dist/canvas/VectorCanvas.svelte.d.ts +87 -0
  5. package/dist/canvas/history.d.ts +105 -0
  6. package/dist/canvas/history.js +147 -0
  7. package/dist/canvas/index.d.ts +15 -0
  8. package/dist/canvas/index.js +14 -0
  9. package/dist/canvas/types.d.ts +91 -0
  10. package/dist/canvas/types.js +13 -0
  11. package/dist/canvas/utils.d.ts +66 -0
  12. package/dist/canvas/utils.js +114 -0
  13. package/dist/charts/Chart.svelte +300 -0
  14. package/dist/charts/Chart.svelte.d.ts +50 -0
  15. package/dist/charts/index.d.ts +11 -0
  16. package/dist/charts/index.js +10 -0
  17. package/dist/charts/types.d.ts +28 -0
  18. package/dist/charts/types.js +6 -0
  19. package/dist/citations/BookCitation.svelte +53 -0
  20. package/dist/citations/BookCitation.svelte.d.ts +41 -0
  21. package/dist/citations/ChapterCitation.svelte +38 -0
  22. package/dist/citations/ChapterCitation.svelte.d.ts +50 -0
  23. package/dist/citations/Citation.svelte +47 -0
  24. package/dist/citations/Citation.svelte.d.ts +24 -0
  25. package/dist/citations/CreditList.svelte +69 -0
  26. package/dist/citations/CreditList.svelte.d.ts +47 -0
  27. package/dist/citations/DOILink.svelte +28 -0
  28. package/dist/citations/DOILink.svelte.d.ts +19 -0
  29. package/dist/citations/Footnote.svelte +39 -0
  30. package/dist/citations/Footnote.svelte.d.ts +17 -0
  31. package/dist/citations/FootnoteList.svelte +36 -0
  32. package/dist/citations/FootnoteList.svelte.d.ts +21 -0
  33. package/dist/citations/ISBNLink.svelte +29 -0
  34. package/dist/citations/ISBNLink.svelte.d.ts +19 -0
  35. package/dist/citations/ISSNLink.svelte +29 -0
  36. package/dist/citations/ISSNLink.svelte.d.ts +19 -0
  37. package/dist/citations/Publisher.svelte +34 -0
  38. package/dist/citations/Publisher.svelte.d.ts +14 -0
  39. package/dist/citations/ReferenceList.svelte +53 -0
  40. package/dist/citations/ReferenceList.svelte.d.ts +21 -0
  41. package/dist/citations/ReportCitation.svelte +41 -0
  42. package/dist/citations/ReportCitation.svelte.d.ts +36 -0
  43. package/dist/citations/ScholarlyArticleCitation.svelte +64 -0
  44. package/dist/citations/ScholarlyArticleCitation.svelte.d.ts +41 -0
  45. package/dist/citations/ThesisCitation.svelte +35 -0
  46. package/dist/citations/ThesisCitation.svelte.d.ts +29 -0
  47. package/dist/citations/bibliography.svelte.d.ts +55 -0
  48. package/dist/citations/bibliography.svelte.js +78 -0
  49. package/dist/citations/citation.d.ts +71 -0
  50. package/dist/citations/citation.js +121 -0
  51. package/dist/citations/csl.d.ts +102 -0
  52. package/dist/citations/csl.js +20 -0
  53. package/dist/citations/index.d.ts +31 -0
  54. package/dist/citations/index.js +36 -0
  55. package/dist/citations/list-format.d.ts +34 -0
  56. package/dist/citations/list-format.js +38 -0
  57. package/dist/context.svelte.d.ts +46 -0
  58. package/dist/context.svelte.js +51 -0
  59. package/dist/data/DataGrid.svelte +150 -0
  60. package/dist/data/DataGrid.svelte.d.ts +118 -0
  61. package/dist/data/DescriptionList.svelte +47 -0
  62. package/dist/data/DescriptionList.svelte.d.ts +26 -0
  63. package/dist/data/DescriptionRow.svelte +55 -0
  64. package/dist/data/DescriptionRow.svelte.d.ts +21 -0
  65. package/dist/data/DiffText.svelte +29 -0
  66. package/dist/data/DiffText.svelte.d.ts +19 -0
  67. package/dist/data/ItemList.svelte +80 -0
  68. package/dist/data/ItemList.svelte.d.ts +52 -0
  69. package/dist/data/LinkListItem.svelte +46 -0
  70. package/dist/data/LinkListItem.svelte.d.ts +21 -0
  71. package/dist/data/Pagination.svelte +136 -0
  72. package/dist/data/Pagination.svelte.d.ts +71 -0
  73. package/dist/data/PaginationFooter.svelte +41 -0
  74. package/dist/data/PaginationFooter.svelte.d.ts +59 -0
  75. package/dist/data/ResultCount.svelte +27 -0
  76. package/dist/data/ResultCount.svelte.d.ts +18 -0
  77. package/dist/data/Table.svelte +145 -0
  78. package/dist/data/Table.svelte.d.ts +75 -0
  79. package/dist/data/TreeList.svelte +41 -0
  80. package/dist/data/TreeList.svelte.d.ts +17 -0
  81. package/dist/data/TreeListItem.svelte +29 -0
  82. package/dist/data/TreeListItem.svelte.d.ts +10 -0
  83. package/dist/data/description-context.d.ts +33 -0
  84. package/dist/data/description-context.js +35 -0
  85. package/dist/data/diff.d.ts +14 -0
  86. package/dist/data/diff.js +10 -0
  87. package/dist/data/index.d.ts +35 -0
  88. package/dist/data/index.js +35 -0
  89. package/dist/data/list-item.d.ts +30 -0
  90. package/dist/data/list-item.js +19 -0
  91. package/dist/data/pagination.d.ts +81 -0
  92. package/dist/data/pagination.js +79 -0
  93. package/dist/data/table-types.d.ts +64 -0
  94. package/dist/data/table-types.js +35 -0
  95. package/dist/feedback/ConfirmDialog.svelte +79 -0
  96. package/dist/feedback/ConfirmDialog.svelte.d.ts +82 -0
  97. package/dist/feedback/Dialog.svelte +77 -0
  98. package/dist/feedback/Dialog.svelte.d.ts +37 -0
  99. package/dist/feedback/LoadingBoundary.svelte +61 -0
  100. package/dist/feedback/LoadingBoundary.svelte.d.ts +34 -0
  101. package/dist/feedback/ProgressBar.svelte +46 -0
  102. package/dist/feedback/ProgressBar.svelte.d.ts +28 -0
  103. package/dist/feedback/SegmentBar.svelte +83 -0
  104. package/dist/feedback/SegmentBar.svelte.d.ts +36 -0
  105. package/dist/feedback/StatTile.svelte +77 -0
  106. package/dist/feedback/StatTile.svelte.d.ts +49 -0
  107. package/dist/feedback/Stats.svelte +18 -0
  108. package/dist/feedback/Stats.svelte.d.ts +16 -0
  109. package/dist/feedback/Timeline.svelte +86 -0
  110. package/dist/feedback/Timeline.svelte.d.ts +52 -0
  111. package/dist/feedback/Toaster.svelte +78 -0
  112. package/dist/feedback/Toaster.svelte.d.ts +32 -0
  113. package/dist/feedback/index.d.ts +31 -0
  114. package/dist/feedback/index.js +27 -0
  115. package/dist/feedback/sizes.d.ts +27 -0
  116. package/dist/feedback/sizes.js +41 -0
  117. package/dist/feedback/toaster.d.ts +48 -0
  118. package/dist/feedback/toaster.js +29 -0
  119. package/dist/fields/Checkbox.svelte +33 -0
  120. package/dist/fields/Checkbox.svelte.d.ts +24 -0
  121. package/dist/fields/CheckboxGroup.svelte +41 -0
  122. package/dist/fields/CheckboxGroup.svelte.d.ts +40 -0
  123. package/dist/fields/Combobox.svelte +222 -0
  124. package/dist/fields/Combobox.svelte.d.ts +98 -0
  125. package/dist/fields/DateInput.svelte +31 -0
  126. package/dist/fields/DateInput.svelte.d.ts +29 -0
  127. package/dist/fields/FieldErrors.svelte +30 -0
  128. package/dist/fields/FieldErrors.svelte.d.ts +11 -0
  129. package/dist/fields/Fieldset.svelte +48 -0
  130. package/dist/fields/Fieldset.svelte.d.ts +26 -0
  131. package/dist/fields/FileInput.svelte +67 -0
  132. package/dist/fields/FileInput.svelte.d.ts +33 -0
  133. package/dist/fields/FormField.svelte +54 -0
  134. package/dist/fields/FormField.svelte.d.ts +29 -0
  135. package/dist/fields/ImageInput.svelte +87 -0
  136. package/dist/fields/ImageInput.svelte.d.ts +39 -0
  137. package/dist/fields/PushToggle.svelte +93 -0
  138. package/dist/fields/PushToggle.svelte.d.ts +55 -0
  139. package/dist/fields/RadioGroup.svelte +85 -0
  140. package/dist/fields/RadioGroup.svelte.d.ts +41 -0
  141. package/dist/fields/SearchInput.svelte +57 -0
  142. package/dist/fields/SearchInput.svelte.d.ts +33 -0
  143. package/dist/fields/Select.svelte +36 -0
  144. package/dist/fields/Select.svelte.d.ts +29 -0
  145. package/dist/fields/TextInput.svelte +26 -0
  146. package/dist/fields/TextInput.svelte.d.ts +30 -0
  147. package/dist/fields/Textarea.svelte +22 -0
  148. package/dist/fields/Textarea.svelte.d.ts +25 -0
  149. package/dist/fields/Toggle.svelte +72 -0
  150. package/dist/fields/Toggle.svelte.d.ts +32 -0
  151. package/dist/fields/VideoInput.svelte +35 -0
  152. package/dist/fields/VideoInput.svelte.d.ts +40 -0
  153. package/dist/fields/field-types.d.ts +51 -0
  154. package/dist/fields/field-types.js +60 -0
  155. package/dist/fields/index.d.ts +34 -0
  156. package/dist/fields/index.js +34 -0
  157. package/dist/formatting/DateTime.svelte +35 -0
  158. package/dist/formatting/DateTime.svelte.d.ts +26 -0
  159. package/dist/formatting/FormatList.svelte +31 -0
  160. package/dist/formatting/FormatList.svelte.d.ts +46 -0
  161. package/dist/formatting/Highlight.svelte +47 -0
  162. package/dist/formatting/Highlight.svelte.d.ts +15 -0
  163. package/dist/formatting/Number.svelte +35 -0
  164. package/dist/formatting/Number.svelte.d.ts +43 -0
  165. package/dist/formatting/RelativeTime.svelte +69 -0
  166. package/dist/formatting/RelativeTime.svelte.d.ts +26 -0
  167. package/dist/formatting/Year.svelte +39 -0
  168. package/dist/formatting/Year.svelte.d.ts +21 -0
  169. package/dist/formatting/index.d.ts +12 -0
  170. package/dist/formatting/index.js +14 -0
  171. package/dist/forms/EntityForm.svelte +177 -0
  172. package/dist/forms/EntityForm.svelte.d.ts +57 -0
  173. package/dist/forms/FormActions.svelte +29 -0
  174. package/dist/forms/FormActions.svelte.d.ts +37 -0
  175. package/dist/forms/FormErrors.svelte +21 -0
  176. package/dist/forms/FormErrors.svelte.d.ts +16 -0
  177. package/dist/forms/HiddenField.svelte +22 -0
  178. package/dist/forms/HiddenField.svelte.d.ts +11 -0
  179. package/dist/forms/SearchForm.svelte +43 -0
  180. package/dist/forms/SearchForm.svelte.d.ts +36 -0
  181. package/dist/forms/entity-form.d.ts +12 -0
  182. package/dist/forms/entity-form.js +12 -0
  183. package/dist/forms/index.d.ts +14 -0
  184. package/dist/forms/index.js +14 -0
  185. package/dist/hotkeys/HotkeyBadge.svelte +39 -0
  186. package/dist/hotkeys/HotkeyBadge.svelte.d.ts +25 -0
  187. package/dist/hotkeys/HotkeyHelpDialog.svelte +68 -0
  188. package/dist/hotkeys/HotkeyHelpDialog.svelte.d.ts +35 -0
  189. package/dist/hotkeys/HotkeyProvider.svelte +92 -0
  190. package/dist/hotkeys/HotkeyProvider.svelte.d.ts +4 -0
  191. package/dist/hotkeys/context.svelte.d.ts +47 -0
  192. package/dist/hotkeys/context.svelte.js +72 -0
  193. package/dist/hotkeys/index.d.ts +23 -0
  194. package/dist/hotkeys/index.js +22 -0
  195. package/dist/hotkeys/match.d.ts +65 -0
  196. package/dist/hotkeys/match.js +133 -0
  197. package/dist/hotkeys/registry.svelte.d.ts +55 -0
  198. package/dist/hotkeys/registry.svelte.js +110 -0
  199. package/dist/hotkeys/types.d.ts +72 -0
  200. package/dist/hotkeys/types.js +18 -0
  201. package/dist/index.d.ts +36 -0
  202. package/dist/index.js +36 -0
  203. package/dist/infobox/Infobox.svelte +79 -0
  204. package/dist/infobox/Infobox.svelte.d.ts +26 -0
  205. package/dist/infobox/InfoboxHeader.svelte +27 -0
  206. package/dist/infobox/InfoboxHeader.svelte.d.ts +11 -0
  207. package/dist/infobox/InfoboxImage.svelte +38 -0
  208. package/dist/infobox/InfoboxImage.svelte.d.ts +21 -0
  209. package/dist/infobox/InfoboxRow.svelte +50 -0
  210. package/dist/infobox/InfoboxRow.svelte.d.ts +25 -0
  211. package/dist/infobox/InfoboxSection.svelte +24 -0
  212. package/dist/infobox/InfoboxSection.svelte.d.ts +12 -0
  213. package/dist/infobox/index.d.ts +21 -0
  214. package/dist/infobox/index.js +20 -0
  215. package/dist/infobox/types.d.ts +86 -0
  216. package/dist/infobox/types.js +27 -0
  217. package/dist/layout/Breadcrumbs.svelte +52 -0
  218. package/dist/layout/Breadcrumbs.svelte.d.ts +18 -0
  219. package/dist/layout/DetailLayout.svelte +50 -0
  220. package/dist/layout/DetailLayout.svelte.d.ts +19 -0
  221. package/dist/layout/FilterBar.svelte +40 -0
  222. package/dist/layout/FilterBar.svelte.d.ts +12 -0
  223. package/dist/layout/Footer.svelte +32 -0
  224. package/dist/layout/Footer.svelte.d.ts +12 -0
  225. package/dist/layout/Main.svelte +30 -0
  226. package/dist/layout/Main.svelte.d.ts +15 -0
  227. package/dist/layout/Navbar.svelte +68 -0
  228. package/dist/layout/Navbar.svelte.d.ts +31 -0
  229. package/dist/layout/PageHeader.svelte +77 -0
  230. package/dist/layout/PageHeader.svelte.d.ts +33 -0
  231. package/dist/layout/Section.svelte +72 -0
  232. package/dist/layout/Section.svelte.d.ts +40 -0
  233. package/dist/layout/Sidebar.svelte +54 -0
  234. package/dist/layout/Sidebar.svelte.d.ts +27 -0
  235. package/dist/layout/Tabs.svelte +85 -0
  236. package/dist/layout/Tabs.svelte.d.ts +42 -0
  237. package/dist/layout/index.d.ts +29 -0
  238. package/dist/layout/index.js +29 -0
  239. package/dist/layout/types.d.ts +52 -0
  240. package/dist/layout/types.js +27 -0
  241. package/dist/links/ContextLinks.svelte +28 -0
  242. package/dist/links/ContextLinks.svelte.d.ts +20 -0
  243. package/dist/links/EmailLink.svelte +26 -0
  244. package/dist/links/EmailLink.svelte.d.ts +20 -0
  245. package/dist/links/index.d.ts +7 -0
  246. package/dist/links/index.js +7 -0
  247. package/dist/media/Figure.svelte +26 -0
  248. package/dist/media/Figure.svelte.d.ts +12 -0
  249. package/dist/media/Image.svelte +76 -0
  250. package/dist/media/Image.svelte.d.ts +41 -0
  251. package/dist/media/Video.svelte +65 -0
  252. package/dist/media/Video.svelte.d.ts +29 -0
  253. package/dist/media/image-dimensions.d.ts +28 -0
  254. package/dist/media/image-dimensions.js +34 -0
  255. package/dist/media/index.d.ts +10 -0
  256. package/dist/media/index.js +10 -0
  257. package/dist/search/CommandPalette.svelte +257 -0
  258. package/dist/search/CommandPalette.svelte.d.ts +59 -0
  259. package/dist/search/index.d.ts +13 -0
  260. package/dist/search/index.js +13 -0
  261. package/dist/search/types.d.ts +37 -0
  262. package/dist/search/types.js +20 -0
  263. package/dist/seo/Head.svelte +190 -0
  264. package/dist/seo/Head.svelte.d.ts +86 -0
  265. package/dist/seo/JsonLd.svelte +30 -0
  266. package/dist/seo/JsonLd.svelte.d.ts +20 -0
  267. package/dist/seo/index.d.ts +18 -0
  268. package/dist/seo/index.js +17 -0
  269. package/dist/seo/json-ld.d.ts +33 -0
  270. package/dist/seo/json-ld.js +29 -0
  271. package/dist/seo/og-image.d.ts +71 -0
  272. package/dist/seo/og-image.js +81 -0
  273. package/dist/seo/types.d.ts +43 -0
  274. package/dist/seo/types.js +6 -0
  275. package/dist/theme.css +161 -0
  276. package/dist/tips/PopoverTip.svelte +80 -0
  277. package/dist/tips/PopoverTip.svelte.d.ts +33 -0
  278. package/dist/tips/Tip.svelte +91 -0
  279. package/dist/tips/Tip.svelte.d.ts +25 -0
  280. package/dist/tips/index.d.ts +15 -0
  281. package/dist/tips/index.js +14 -0
  282. package/dist/tips/tip-stack.d.ts +25 -0
  283. package/dist/tips/tip-stack.js +37 -0
  284. package/dist/tips/tip-store.svelte.d.ts +74 -0
  285. package/dist/tips/tip-store.svelte.js +158 -0
  286. package/dist/tips/types.d.ts +46 -0
  287. package/dist/tips/types.js +6 -0
  288. package/dist/ui/Alert.svelte +61 -0
  289. package/dist/ui/Alert.svelte.d.ts +37 -0
  290. package/dist/ui/Badge.svelte +39 -0
  291. package/dist/ui/Badge.svelte.d.ts +32 -0
  292. package/dist/ui/Button.svelte +43 -0
  293. package/dist/ui/Button.svelte.d.ts +29 -0
  294. package/dist/ui/Card.svelte +49 -0
  295. package/dist/ui/Card.svelte.d.ts +44 -0
  296. package/dist/ui/Chip.svelte +58 -0
  297. package/dist/ui/Chip.svelte.d.ts +21 -0
  298. package/dist/ui/ConfirmButton.svelte +75 -0
  299. package/dist/ui/ConfirmButton.svelte.d.ts +48 -0
  300. package/dist/ui/Disclosure.svelte +39 -0
  301. package/dist/ui/Disclosure.svelte.d.ts +22 -0
  302. package/dist/ui/EmptyState.svelte +37 -0
  303. package/dist/ui/EmptyState.svelte.d.ts +22 -0
  304. package/dist/ui/ExternalLink.svelte +38 -0
  305. package/dist/ui/ExternalLink.svelte.d.ts +34 -0
  306. package/dist/ui/Heading.svelte +58 -0
  307. package/dist/ui/Heading.svelte.d.ts +22 -0
  308. package/dist/ui/Link.svelte +34 -0
  309. package/dist/ui/Link.svelte.d.ts +20 -0
  310. package/dist/ui/LinkButton.svelte +51 -0
  311. package/dist/ui/LinkButton.svelte.d.ts +33 -0
  312. package/dist/ui/Menu.svelte +54 -0
  313. package/dist/ui/Menu.svelte.d.ts +34 -0
  314. package/dist/ui/MenuItem.svelte +75 -0
  315. package/dist/ui/MenuItem.svelte.d.ts +21 -0
  316. package/dist/ui/MenuSeparator.svelte +26 -0
  317. package/dist/ui/MenuSeparator.svelte.d.ts +8 -0
  318. package/dist/ui/Popover.svelte +63 -0
  319. package/dist/ui/Popover.svelte.d.ts +40 -0
  320. package/dist/ui/ShareButton.svelte +64 -0
  321. package/dist/ui/ShareButton.svelte.d.ts +39 -0
  322. package/dist/ui/Skeleton.svelte +43 -0
  323. package/dist/ui/Skeleton.svelte.d.ts +22 -0
  324. package/dist/ui/ThemeToggle.svelte +65 -0
  325. package/dist/ui/ThemeToggle.svelte.d.ts +24 -0
  326. package/dist/ui/Tooltip.svelte +54 -0
  327. package/dist/ui/Tooltip.svelte.d.ts +35 -0
  328. package/dist/ui/VisuallyHidden.svelte +23 -0
  329. package/dist/ui/VisuallyHidden.svelte.d.ts +10 -0
  330. package/dist/ui/colour-token.d.ts +41 -0
  331. package/dist/ui/colour-token.js +49 -0
  332. package/dist/ui/index.d.ts +44 -0
  333. package/dist/ui/index.js +44 -0
  334. package/dist/ui/menu-context.d.ts +22 -0
  335. package/dist/ui/menu-context.js +26 -0
  336. package/dist/ui/slug.d.ts +19 -0
  337. package/dist/ui/slug.js +25 -0
  338. package/dist/ui/tones.d.ts +68 -0
  339. package/dist/ui/tones.js +150 -0
  340. package/package.json +101 -3
package/README.md CHANGED
@@ -1,3 +1,818 @@
1
- # Temporary Holding Version
1
+ # @thomasfosterau/components
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Accessible Svelte 5 UI primitives, styled with [Tailwind CSS 4](https://tailwindcss.com) + [daisyUI 5](https://daisyui.com) and built on [bits-ui](https://bits-ui.com). The core families are in the root barrel and the heavier ones behind their own [entry points](#entry-points). The maps components live in the sibling package [`@thomasfosterau/components-mapbox`](../maps/README.md).
4
+
5
+ Four rules hold across all of them:
6
+
7
+ - **daisyUI semantic classes, never raw colour.** `bg-base-200`, `text-error`, `btn-primary` — never a hex, an `oklch()` or `bg-green-700`.
8
+ - **bits-ui owns interaction.** Anything with focus management, keyboard semantics, a popover or an ARIA relationship between elements is built on a bits-ui primitive. Native elements are used where they already give the semantics (`<details>`, `<progress>`, `<time>`, `<data>`, `<dl>`).
9
+ - **Every user-facing string is a prop with an English default.** There is no message catalogue — `dismissLabel = 'Dismiss'`, `previousLabel = 'Previous'`. Accessible-name props end in `Label`. An i18n app passes its own messages at the call site.
10
+ - **No Effect outside `/entity-form`.** Async callbacks are `(…, signal: AbortSignal) => Promise<T>`; optional values are `T | undefined`. Effect adapters stay in the consuming app.
11
+
12
+ Every component spreads the native attributes of the element it renders and merges the caller's `class` last, so nothing here ever swallows an `id`, an `aria-*` attribute or a `data-*` hook.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pnpm add @thomasfosterau/components
18
+ ```
19
+
20
+ ### Peer requirements
21
+
22
+ | Package | Range | Why |
23
+ | --- | --- | --- |
24
+ | `svelte` | `^5.40.0` | A declared peer. The components are compiled by your build, not pre-compiled. |
25
+ | `tailwindcss` | `^4.0.0` | Not a declared peer, but **required**: the components emit Tailwind classes and ship none. |
26
+ | `daisyui` | `^5.0.0` | Same — the class vocabulary is daisyUI's (`btn`, `input`, `bg-base-200`, `text-error`). |
27
+ | `@thomasfosterau/effect-forms-svelte` | `^0.2.0` | An **optional** declared peer, needed only by the `/entity-form` entry (and bringing its own required peer, `effect` 4). Nothing else in the package imports it. |
28
+
29
+ Tailwind 4 has no `tailwind.config.js`, so wire it up from your CSS entry. Two lines matter: `@source`, which is what makes Tailwind scan this package's `dist/` and emit the classes it uses, and the `theme.css` import (or your own themes), which supplies the daisyUI tokens the components are designed against.
30
+
31
+ ```css
32
+ @import 'tailwindcss';
33
+ @source '../node_modules/@thomasfosterau/components/dist';
34
+ @plugin "daisyui" {
35
+ themes: false;
36
+ }
37
+ @import '@thomasfosterau/components/theme.css';
38
+ ```
39
+
40
+ Without the `@source` line the components render unstyled — Tailwind has no way to know which classes `dist/` asks for. `themes: false` matters too: daisyUI's own built-in theme set also defines `light` and `dark`, and enabling both means two definitions of each fighting over the cascade.
41
+
42
+ ### Bringing your own theme
43
+
44
+ `theme.css` declares two daisyUI themes, `light` and `dark`, on deliberately neutral values (achromatic surfaces, one slate brand hue) — a starting point, not a brand. Override them by declaring your own `@plugin "daisyui/theme"` blocks after the import, or drop the import entirely and declare `light` / `dark` yourself. The components only ever reach for semantic names, so any pair of daisyUI themes with those two names works. If you use `@thomasfosterau/components/charts`, also replace the four `--color-chart-*` custom properties with your own measured set (see [Charts § Colour, and the slot cap](#charts)).
45
+
46
+ ## App integration
47
+
48
+ Every component that needs to resolve a link, navigate, know the current page or format a locale-aware value reads from a small injected context instead of importing `$app/*` directly — that is what keeps the package usable outside SvelteKit (Storybook, a test, a plain Svelte app). Call `setComponentsContext` once, in your root layout:
49
+
50
+ ```svelte
51
+ <!-- +layout.svelte -->
52
+ <script lang="ts">
53
+ import { setComponentsContext } from '@thomasfosterau/components';
54
+ import { goto } from '$app/navigation';
55
+ import { page } from '$app/state';
56
+ import { getLocale, localizeHref } from '$lib/paraglide/runtime';
57
+
58
+ setComponentsContext({
59
+ locale: getLocale,
60
+ href: localizeHref,
61
+ navigate: goto,
62
+ isCurrent: (href) => page.url.pathname === href,
63
+ });
64
+ </script>
65
+ ```
66
+
67
+ | Member | Type | Used for |
68
+ | --- | --- | --- |
69
+ | `locale` | `() => string \| readonly string[] \| undefined` | The default for every component's `locales` prop (`DateTime`, `NumberFormat`, `Pagination`, …). A function, not a value, so a reactive i18n runtime is re-read on every render. |
70
+ | `href` | `(path: string) => string` | Resolving an app-relative path to the href actually rendered — base-path or locale-prefix logic lives here, once. |
71
+ | `navigate` | `(url: string) => Promise<void> \| void` | Client-side navigation (`ConfirmDialog`'s `redirectTo`, `ConfirmButton`'s `redirectTo`, `CommandPalette`'s `href` rows). |
72
+ | `isCurrent` | `(href: string) => boolean` | `aria-current="page"` decisions (`Breadcrumbs`, `Tabs` in `links` mode, `FilterBar`, `Sidebar`). |
73
+
74
+ Every member is optional in the object you pass — an omitted one falls back individually to `defaultComponentsContext` (identity `href`, `window.location.assign` for navigation, pathname equality for `isCurrent`, runtime-default locale), so every component works unwired in a story or a test.
75
+
76
+ **Passing i18n messages.** There is no message catalogue in this package — every accessible-name prop has an English default and nothing more. An app using [Paraglide](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) (or any other i18n library) passes its own messages at the call site:
77
+
78
+ ```svelte
79
+ <script lang="ts">
80
+ import { FormActions } from '@thomasfosterau/components';
81
+ import * as m from '$lib/paraglide/messages';
82
+ </script>
83
+
84
+ <FormActions submitLabel={m.save()} pendingLabel={m.saving()} cancelLabel={m.cancel()} />
85
+ ```
86
+
87
+ ## Entry points
88
+
89
+ Heavier families are separate `exports` entries so a consumer that never uses them never loads them:
90
+
91
+ | Entry point | Families | Notes |
92
+ | --- | --- | --- |
93
+ | `@thomasfosterau/components` | ui, layout, feedback, fields, forms, data, infobox, formatting, links, media, hotkeys, search | The core barrel — everything below in one import. |
94
+ | `@thomasfosterau/components/citations` | citations | `Citation`, `Footnote`, `ReferenceList`, `FootnoteList`, `CreditList`, `DOILink`/`ISBNLink`/`ISSNLink`, the CSL-type renderers (`BookCitation`, `ChapterCitation`, `ScholarlyArticleCitation`, `ThesisCitation`, `ReportCitation`, `Publisher`), `formatCitation`, `BibliographyManager`. |
95
+ | `@thomasfosterau/components/canvas` | canvas | `VectorCanvas` and the pan/zoom/history/geometry toolkit underneath it. |
96
+ | `@thomasfosterau/components/charts` | charts | `Chart` — one component, plain SVG, no charting dependency. |
97
+ | `@thomasfosterau/components/tips` | tips | `Tip`, `PopoverTip`, `createTipStore`, `createTipStack`. |
98
+ | `@thomasfosterau/components/entity-form` | forms | `EntityForm` — needs the optional peer `@thomasfosterau/effect-forms-svelte` (see [entity-form](#entity-form)). |
99
+ | `@thomasfosterau/components/seo` | seo | `Head`, `JsonLd`, `serializeJsonLd`, `ogImageUrl` and the share-card constants. |
100
+ | `@thomasfosterau/components/theme.css` | — | The daisyUI theme skeleton (see [Install](#install)). |
101
+
102
+ ---
103
+
104
+ ## ui
105
+
106
+ Buttons, badges and surfaces — the family every other family builds on: `Alert` is what a form's errors surface in, `Card` is the surface a section is drawn on, `Button` is what a dialog's actions are.
107
+
108
+ | Component | Purpose | Props | Snippets | ARIA |
109
+ | --- | --- | --- | --- | --- |
110
+ | `Button` | The daisyUI `btn` | `tone?: Tone`, `variant?: 'solid'\|'outline'\|'soft'\|'dash'\|'ghost'\|'link'`, `size?: Size`, `loading?`, `block?`, `type='button'` + `HTMLButtonAttributes` | `icon?`, `children` | native `<button>`; `loading` is `disabled` **and** `aria-busy="true"`; spinner `aria-hidden` so the name never changes |
111
+ | `LinkButton` | Navigation that reads as an action | `href`, `tone?`, `variant?`, `size?`, `disabled?`, `block?`, `external?` + `HTMLAnchorAttributes` | `icon?`, `children` | `disabled` drops `href` and restores `role="button"` + `aria-disabled` + `tabindex="-1"`; `external` → `target="_blank"` + `rel="noreferrer"` |
112
+ | `Link` | The in-text anchor | `href`, `variant?: 'primary'\|'muted'\|'plain'`, `external?` | `children` | native `<a>`; `external` pairs `target` and `rel` |
113
+ | `ExternalLink` | A link off the site | `href`, `variant?`, `untrusted? = true`, `showIcon? = true`, `newTabLabel? = '(opens in a new tab)'` | `children` | `rel="noreferrer nofollow"` when untrusted, `"noreferrer"` otherwise; the new tab is **in the accessible name**, icon `aria-hidden` |
114
+ | `Badge` | Short inline label | `tone? = 'neutral'`, `variant? = 'soft'`, `size? = 'md'`, `colourToken?` | `children` | none by design — the text carries the meaning |
115
+ | `Chip` | Colour swatch + short code | `label`, `title?`, `href?`, `colourToken?`, `size?: 'sm'\|'md'` | — | `<abbr title>` exposes the expansion; swatch `aria-hidden`; a link when `href` is set |
116
+ | `Card` | The surface | `as? = 'article'`, `title?`, `headingLevel? = 2`, `variant? = 'border'`, `size? = 'md'`, `padded? = true`, `bodyClass?` | `figure?`, `actions?`, `children` | `title` is a real heading at `headingLevel`; `as` picks the semantics (`article`/`section`/`div`) |
117
+ | `Alert` | Tone-coloured notice | `tone? = 'info'`, `variant? = 'soft'`, `title?`, `live?: 'assertive'\|'polite'\|'off'`, `onDismiss?`, `dismissLabel? = 'Dismiss'` | `icon?`, `children` | `live` → `role="alert"` \| `role="status"` \| no role; defaults assertive for `error`, polite otherwise; dismiss is a labelled `Button` |
118
+ | `EmptyState` | The quiet "nothing here" card | `title`, `description?`, `headingLevel? = 2` | `icon?`, `action?` | real heading; icon wrapped `aria-hidden` |
119
+ | `Skeleton` | Loading placeholders | `variant?: 'block'\|'text'\|'circle'`, `lines? = 3`, `label? = 'Loading'` | — | `role="status"` + `aria-busy="true"`, named by `label`; every shimmering box `aria-hidden` |
120
+ | `Heading` | Addressable section heading | `level: 1…6`, `id?`, `anchor?`, `anchorLabel? = 'Link to this section'`, `children: string \| Snippet` | `children` | real `<h1…6>`; id slugged from string children at render (SSR-safe) |
121
+ | `VisuallyHidden` | Content for assistive tech only | — | `children` | `sr-only` |
122
+ | `Disclosure` | Expandable section | `open? ⚓`, `title: string \| Snippet`, `variant?: 'arrow'\|'plus'\|'plain'`, `contentClass?` | `title?`, `children` | native `<details>`/`<summary>` — no hand-written `aria-expanded` |
123
+ | `Tooltip` | Hover/focus supplement | `text?`, `side? = 'top'`, `sideOffset? = 6`, `openDelay? = 400`, `open? ⚓` | `content?`, `children({ props })` | bits-ui `aria-describedby`; Escape closes |
124
+ | `Popover` | Floating interactive panel | `open? ⚓`, `side? = 'bottom'`, `align? = 'center'`, `sideOffset? = 8`, `title?`, `closeLabel? = 'Close'` | `trigger({ props })`, `children` | bits-ui dialog ARIA: `aria-haspopup="dialog"`, `aria-expanded`, focus trap, Escape, focus return |
125
+ | `Menu` | Nav list or dropdown menu | `mode?: 'dropdown'\|'nav'` (default `nav`), `label`, `open? ⚓`, `side?`, `align?` | `trigger?({ props })`, `children` | nav: `<nav aria-label>` + `<ul class="menu">`; dropdown: bits-ui `role="menu"` |
126
+ | `MenuItem` | One entry of a `Menu` | `href?`, `onSelect?`, `current?`, `disabled?` | `icon?`, `children` | nav: `aria-current="page"` + `menu-active`; dropdown: bits-ui `role="menuitem"` |
127
+ | `MenuSeparator` | Rule between item groups | — | — | `role="separator"` (dropdown mode) inside a `role="none"` `<li>` |
128
+ | `ShareButton` | Platform share sheet | `title?`, `text?`, `url?`, `label? = 'Share'`, `tone?`, `variant? = 'ghost'`, `size? = 'sm'`, `onShare?`, `onError?` | `children?` | renders **nothing** without `navigator.share` (decided after mount) |
129
+ | `ThemeToggle` | Light / dark switch | `lightTheme`, `darkTheme`, `storageKey? = 'theme'`, `label? = 'Toggle dark mode'`, `value? ⚓` | — | checkbox with `role="switch"` named by `label` |
130
+ | `ConfirmButton` | Two-step inline confirm | `action: () => Promise<unknown>`, `idleLabel? = 'Delete'`, `confirmLabel?`, `cancelLabel?`, `pendingLabel?`, `confirmMessage?`, `failureMessage?`, `tone? = 'error'`, `redirectTo?` | — | failure is an `Alert live="assertive"`; both buttons `disabled` while pending; the confirm button keeps a name (`pendingLabel`) |
131
+
132
+ `ui` also exports `tones.ts` (`Tone`, `Size`, `HeadingLevel` and the literal `Record<K, string>` class tables — `buttonTone`, `badgeVariant`, `cardVariant`, …), `colour-token.ts` (`colourTokenStyle`, for binding a component's colour custom property to a theme token), and `slug.ts` (`slugify`, used by `Heading`).
133
+
134
+ ```svelte
135
+ <script lang="ts">
136
+ import { Button, LinkButton, Link, Card, EmptyState, Alert, Heading, Disclosure } from '@thomasfosterau/components';
137
+ import { Plus } from '@lucide/svelte';
138
+ </script>
139
+
140
+ <Button tone="primary" loading={saving} onclick={save}>
141
+ {#snippet icon()}<Plus class="size-4" aria-hidden="true" />{/snippet}
142
+ Add a plant
143
+ </Button>
144
+ <LinkButton href="/gardens/new" tone="primary">Start a garden</LinkButton>
145
+
146
+ <!-- A bare Card is exactly `card card-border bg-base-100 shadow-sm` plus a padded body. -->
147
+ <Card title="Tomato 'Black Krim'">
148
+ <p>Sown 14 September; two of six seedlings survived the frost.</p>
149
+ {#snippet actions()}<Button tone="primary" size="sm">Open</Button>{/snippet}
150
+ </Card>
151
+
152
+ {#if gardens.length === 0}
153
+ <EmptyState title="No gardens yet" description="Start with one and add the plants later." />
154
+ {/if}
155
+
156
+ {#if error}<Alert tone="error" title="Could not save">{error}</Alert>{/if}
157
+
158
+ <Heading level={2} anchor>Growing conditions</Heading>
159
+ <Disclosure title="Add an anatomy record" bind:open={sectionOpen}>
160
+ <AnatomyForm />
161
+ </Disclosure>
162
+ ```
163
+
164
+ ```svelte
165
+ <script lang="ts">
166
+ import { Menu, MenuItem, MenuSeparator, Button, ThemeToggle } from '@thomasfosterau/components';
167
+ import { ChevronDown } from '@lucide/svelte';
168
+ </script>
169
+
170
+ <!-- Sidebar: a landmark, a list, links, aria-current from the context. -->
171
+ <Menu label="Primary">
172
+ <MenuItem href="/gardens">Gardens</MenuItem>
173
+ <MenuItem href="/taxa">Taxa</MenuItem>
174
+ </Menu>
175
+
176
+ <!-- Account menu: bits-ui roving focus, typeahead, Escape, focus return. -->
177
+ <Menu mode="dropdown" label="Account">
178
+ {#snippet trigger({ props })}
179
+ <Button {...props} variant="ghost">Ada Lovelace<ChevronDown class="size-4" aria-hidden="true" /></Button>
180
+ {/snippet}
181
+ <MenuItem href="/account">Your profile</MenuItem>
182
+ <MenuSeparator />
183
+ <MenuItem onSelect={signOut}>Sign out</MenuItem>
184
+ </Menu>
185
+
186
+ <ThemeToggle lightTheme="garden-light" darkTheme="garden-dark" storageKey="garden:theme" />
187
+ ```
188
+
189
+ ## layout
190
+
191
+ The landmarks a page is assembled from, and the blocks that sit inside them. Structure only, never copy: every component takes the app's content as snippets and owns just the element that carries the semantics.
192
+
193
+ | Component | Purpose | Props | Snippets | ARIA |
194
+ | --- | --- | --- | --- | --- |
195
+ | `Main` | The page content column | `as? = 'main'` (`'main'\|'section'`) + `HTMLAttributes<HTMLElement>` | `children` | `<main>` is THE landmark, one per page |
196
+ | `PageHeader` | What the page is about, and what you can do to it | `title: string \| Snippet`, `subtitle?`, `headingLevel? = 1`, `actionsLabel? = 'Page actions'` | `eyebrow?`, `icon?`, `meta?`, `actions?`, `children?` | `title` is a real heading; `actions` is a `role="group"` named by `actionsLabel`; `icon` is `aria-hidden` |
197
+ | `Breadcrumbs` | The ancestor trail | `items: readonly Crumb[]`, `label? = 'Breadcrumb'` | `item?(crumb, index)` | `<nav aria-label>` + `<ol>`; ancestors are links, the trailing crumb is text with `aria-current="page"` |
198
+ | `Navbar` | The site banner | `label? = 'Primary'`, `menuLabel? = 'Menu'`, `menuOpen? ⚓` | `start`, `center?`, `end?`, `menu?` | `<header>` (`banner`) > `<nav aria-label>`; the responsive toggle is a bits-ui `Collapsible` |
199
+ | `Footer` | The site footer | — + `HTMLAttributes<HTMLElement>` | `children`, `aside?` | `<footer>` (`contentinfo`); `aside` renders first |
200
+ | `DetailLayout` | The two-column detail shell | `asideLabel? = 'Summary'` | `header`, `body`, `aside?` | `<aside aria-label>` (`complementary`); mobile order title → aside → body by reordering one render, not rendering twice |
201
+ | `Section` | A titled block of a page | `title`, `headingLevel? = 2`, `headingId?`, `count?`, `description?`, `empty? = false`, `emptyMessage? = 'Nothing here yet.'`, `locales?` | `children`, `actions?`, `emptyState?` | `<section aria-labelledby>` pointing at its own heading; `headingId` defaults to a slug of `title` |
202
+ | `Tabs` | One strip, two things | `items: readonly TabItem[]`, `mode? = 'panels'`, `value? ⚓`, `label? = 'Sections'`, `variant? = 'border'`, `size? = 'md'` | `panel?(item)` | panels: bits-ui `Tabs` (tablist/tab/tabpanel, roving tabindex, arrows); links: `<nav>` of anchors with `aria-current="page"` |
203
+ | `FilterBar` | The filter row above a list | `options: readonly FilterOption[]`, `label` | — | `<nav aria-label>` of `btn`-styled anchors; the active one carries `aria-current="page"` |
204
+ | `Sidebar` | The standing navigation column | `label?`, `title?`, `headingLevel? = 2` | `brand?`, `footer?`, `children` (the `MenuItem`s) | the landmark is `Menu mode="nav"`'s; deliberately not an `<aside>` — `complementary` is the wrong landmark for primary navigation |
205
+
206
+ `layout` also exports `types.ts` (`Crumb`, `TabItem`, `FilterOption`, `TabsMode`, `TabsVariant`, `tabsVariant`, `tabsSize`).
207
+
208
+ ```svelte
209
+ <script lang="ts">
210
+ import { Main, PageHeader, Breadcrumbs, Section, DetailLayout, Infobox, Tabs, Sidebar, MenuItem } from '@thomasfosterau/components';
211
+ </script>
212
+
213
+ <Main class="mx-auto max-w-5xl space-y-8 p-6">
214
+ <PageHeader title="Tomato 'Black Krim'" subtitle="Solanum lycopersicum" actionsLabel="Plant actions">
215
+ {#snippet eyebrow()}
216
+ <Breadcrumbs items={[{ label: 'Gardens', href: '/gardens' }, { label: 'Tomato' }]} />
217
+ {/snippet}
218
+ </PageHeader>
219
+
220
+ <!-- `count` is the unpaged total; the rows are a preview, so the two differ. -->
221
+ <Section title="Observations" count={1284} description="The most recent three.">
222
+ <ul>{#each recent as row (row.id)}<li>{row.label}</li>{/each}</ul>
223
+ </Section>
224
+ </Main>
225
+
226
+ <!-- DetailLayout opens NO landmark of its own: it goes inside `Main`, so the page keeps one `<main>`. -->
227
+ <Main>
228
+ <DetailLayout asideLabel="Key facts">
229
+ {#snippet header()}<PageHeader title="Rosa canina" subtitle="Dog rose" />{/snippet}
230
+ {#snippet aside()}<Infobox title="Rosa canina" rows={facts} />{/snippet}
231
+ {#snippet body()}<p>{article}</p>{/snippet}
232
+ </DetailLayout>
233
+ </Main>
234
+
235
+ <!-- A widget: one document, panels swapped in place. -->
236
+ <Tabs items={views} bind:value={tab}>
237
+ {#snippet panel(item)}<ResultsTable view={item.value} />{/snippet}
238
+ </Tabs>
239
+ <!-- Navigation: separate routes — anchors and `aria-current`, never `role="tab"`. -->
240
+ <Tabs items={views} mode="links" label="Results views" />
241
+
242
+ <Sidebar title="Browse">
243
+ {#snippet brand()}<a href="/" rel="home">Home</a>{/snippet}
244
+ <MenuItem href="/posts">Posts</MenuItem>
245
+ <MenuItem href="/tags">Tags</MenuItem>
246
+ </Sidebar>
247
+ ```
248
+
249
+ ## feedback
250
+
251
+ Dialogs, toasts, progress — chrome around something that just happened, or is happening now. What these share is the live region and the announcement order, not the markup.
252
+
253
+ | Component | Purpose | Props | Snippets | ARIA |
254
+ | --- | --- | --- | --- | --- |
255
+ | `Dialog` | The modal | `open? ⚓`, `title`, `description?`, `closeLabel? = 'Close'`, `size?: 'sm'\|'md'\|'lg'\|'xl'` | `trigger?({ props })`, `children`, `actions?({ close })` | bits-ui `Dialog` owns it all: `role="dialog"` + `aria-modal`, `aria-labelledby`/`describedby`, focus trap, Escape, scroll lock, focus return. daisyUI `modal-box` is styling only |
256
+ | `ConfirmDialog` | A destructive action behind a confirmation | `open? ⚓`, `title`, `description?`, `confirmLabel? = 'Delete'`, `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Working…'`, `failureMessage?`, `closeLabel?`, `tone? = 'error'`, `size?`, **either** `onConfirm: () => Promise<unknown>` **or** `form: { action, method? = 'POST', fields?, enhance? }`, `redirectTo?` | `trigger?({ props })`, `children?` | `Dialog`'s, plus a failure as an `Alert live="assertive"` inside the still-open dialog; both buttons `disabled` while pending |
257
+ | `Toaster` | The notification region | `position? = 'bottom-end'`, `regionLabel? = 'Notifications'`, `dismissLabel? = 'Dismiss'`, `durationMs? = 6000` | `children` (the app) | ONE `aria-live="polite"` region, present from mount, named by `regionLabel`; nothing live nested inside it — `getToaster()` gives `add({ title, description?, tone? }, durationMs?) → id` and `dismiss(id)` |
258
+ | `LoadingBoundary` | Chrome around one read | `state: 'loading'\|'refreshing'\|'error'\|'ready'`, `loadingLabel? = 'Loading…'`, `errorLabel?`, `tone?: 'page'\|'section'` | `children`, `loading?`, `failed?({ message })` | loading chrome `role="status"`; failure `role="alert"`; children stay mounted through `refreshing` |
259
+ | `ProgressBar` | Determinate or indeterminate progress | `label`, `value?`, `max? = 100`, `tone? = 'primary'`, `showValue?`, `locales?` | — | native `<progress>`, named by `label`; no `value` attribute at all when indeterminate |
260
+ | `SegmentBar` | A proportional stacked bar | `label`, `segments: { label, fraction, colourToken?, tone? }[]`, `showValues?`, `marker?`, `size?: 'sm'\|'md'\|'lg'`, `locales?` | — | ONE `role="img"` whose `aria-label` spells out every segment ("Storage: Photos 62%, Video 31%") |
261
+ | `StatTile` | One headline figure | `label`, `value: string \| number`, `delta?: { value, unit?, label? }`, `description?`, `upLabel?`, `downLabel?`, `flatLabel?`, `locales?` | `figure?` | reads label → value → change; the trend arrow is `aria-hidden`, direction is in visually hidden text |
262
+ | `Stats` | The wrapper a row of tiles sits in | `orientation?: 'horizontal'\|'vertical'\|'responsive'` (default `responsive`) | `children` | none of its own — name it with `aria-label` when a page has several |
263
+ | `Timeline` | A dated sequence | `items: TimelineEntry[]`, `label? = 'Timeline'`, `presentLabel? = 'present'`, `toLabel? = 'to'`, `currentLabel? = 'Current'` | `item?(entry)` | `<ol>` named by `label`; an open-ended entry is introduced by `currentLabel` |
264
+
265
+ `feedback` also exports `sizes.ts` (`DialogSize`, `ToastPosition`, `BarSize`, `StatsOrientation` and their class tables) and `toaster.ts` (`getToaster`, `setToaster`, `Toast`, `NewToast`, `ToastTone`, `ToasterApi`). `colourToken` on a `SegmentBar` segment or a `TimelineEntry` is a CSS custom-property name your theme declares — see `ui`'s `colourTokenStyle`.
266
+
267
+ ```svelte
268
+ <script lang="ts">
269
+ import { Dialog, ConfirmDialog, Toaster, getToaster, LoadingBoundary, ProgressBar, Stats, StatTile, Button, Skeleton } from '@thomasfosterau/components';
270
+ </script>
271
+
272
+ <Dialog title="Move this plant?" description="It keeps its history — only the bed changes.">
273
+ {#snippet trigger({ props })}<Button {...props} variant="outline">Move plant</Button>{/snippet}
274
+ <p>'Black Krim' moves from North bed to the Glasshouse.</p>
275
+ {#snippet actions({ close })}<Button tone="primary" onclick={close}>Move it</Button>{/snippet}
276
+ </Dialog>
277
+
278
+ <ConfirmDialog title="Delete this garden?" onConfirm={() => deleteGarden(id)} redirectTo="/gardens">
279
+ {#snippet trigger({ props })}<Button {...props} tone="error">Delete garden</Button>{/snippet}
280
+ </ConfirmDialog>
281
+
282
+ <!-- Root layout: one region for the whole app. -->
283
+ <Toaster position="bottom-end">{@render children()}</Toaster>
284
+ ```
285
+
286
+ ```svelte
287
+ <script lang="ts">
288
+ // Anywhere below a Toaster.
289
+ const toaster = getToaster();
290
+ toaster.add({ title: 'Bed saved', tone: 'success' });
291
+ toaster.add({ title: 'Could not save the bed', tone: 'error' }, 0); // 0 = sticky
292
+
293
+ // 'loading' only for a FIRST read; 'refreshing' while a re-fetch runs over content already on screen.
294
+ const state = $derived(query.error ? 'error' : query.data === undefined ? 'loading' : query.pending ? 'refreshing' : 'ready');
295
+ </script>
296
+
297
+ <LoadingBoundary {state} loadingLabel="Loading beds…" errorLabel="The beds didn't load.">
298
+ {#snippet loading()}<Skeleton variant="text" lines={3} label="Loading beds" />{/snippet}
299
+ <BedList beds={query.data} />
300
+ </LoadingBoundary>
301
+
302
+ <ProgressBar label="Seeds germinated" value={62} showValue />
303
+ <Stats aria-label="Garden summary">
304
+ <StatTile label="Plants recorded" value={1284} delta={{ value: 46, label: 'this month' }} />
305
+ </Stats>
306
+ ```
307
+
308
+ ## fields
309
+
310
+ Form controls wired through the `FormField` + `FieldControl` contract. `FormField` (one control) or `Fieldset` (a group) owns the label, hint and errors; it derives `hintId`/`errorId` from a single `id` and hands `children` a `FieldControl`:
311
+
312
+ ```ts
313
+ interface FieldControl {
314
+ id: string;
315
+ describedBy: string;
316
+ invalid: boolean;
317
+ required: boolean;
318
+ }
319
+ ```
320
+
321
+ Every primitive accepts it by spread — `<TextInput {...control} bind:value />` — so `for`/`id`, `aria-describedby`, `aria-invalid` and `required` cannot drift apart. `errors` is always `readonly string[]`; a field that fails three rules says three things.
322
+
323
+ | Component | Purpose | Props | Snippets | ARIA |
324
+ | --- | --- | --- | --- | --- |
325
+ | `FormField` | Label + hint + errors around one control | `id?`, `label`, `hint?`, `errors?: readonly string[]`, `required?`, `requiredLabel? = '(required)'` + `HTMLAttributes<HTMLDivElement>` | `children(control)` | `<label for>` names the control; `describedBy` lists hint id then error id; `aria-invalid` when there are errors |
326
+ | `Fieldset` | The same frame for a GROUP | `legend`, `hint?`, `errors?`, `required?`, `requiredLabel?` + `HTMLAttributes<HTMLFieldSetElement>` | `children` | real `<fieldset>`/`<legend>`; hint/errors are `aria-describedby` on the fieldset; `data-invalid` instead of `aria-invalid` (the implicit `group` role rejects it) |
327
+ | `TextInput` | The daisyUI `input` | `value ⚓`, `type? = 'text'` (`text\|email\|url\|password\|tel\|number\|search`), `size?: FieldSize`, `invalid?`, `describedBy?` + `HTMLInputAttributes` | — | native `<input>` |
328
+ | `Textarea` | The daisyUI `textarea` | `value ⚓`, `rows? = 4`, `size?`, `invalid?`, `describedBy?` | — | native `<textarea>` |
329
+ | `Select` | One choice, natively | `options: (FieldOption \| string)[]`, `value ⚓`, `placeholder?`, `size?`, `invalid?`, `describedBy?` | — | native `<select>`; placeholder is a disabled first `<option>` |
330
+ | `Checkbox` | One box with its own label | `label`, `checked ⚓`, `size?`, `invalid?`, `describedBy?` + `HTMLInputAttributes` | — | `<label>`-wrapped `<input type="checkbox">` — no `id`/`for` pair needed |
331
+ | `CheckboxGroup` | Several boxes, one question | `legend`, `options`, `values ⚓: string[]`, `name?`, `hint?`, `errors?`, `required?`, `disabled?`, `size?` | — | a `Fieldset` of `Checkbox` |
332
+ | `RadioGroup` | One choice from a short list | `legend`, `options`, `value ⚓`, `orientation? = 'vertical'`, `name?`, `hint?`, `errors?`, `required?`, `disabled?` | — | bits-ui `RadioGroup`: one tab stop, arrows move and select, wrap-around |
333
+ | `Toggle` | A setting that takes effect now | `checked ⚓`, `label`, `description?`, `onChange?: (next) => Promise<boolean> \| boolean`, `size?`, `invalid?`, `describedBy?` | — | `role="switch"` on a native checkbox; `description` is `aria-describedby`, never folded into the name; `aria-busy` while `onChange` is in flight |
334
+ | `PushToggle` | Web Push for THIS browser behind a `Toggle`: reads the subscription on mount, subscribes / unsubscribes through the app's own client | `available: boolean`, `enable: (signal) => Promise<PushEnableResult>` (`{ _tag: 'enabled' \| 'unsupported' \| 'unavailable' \| 'denied' }` — resolves, never throws), `disable: (signal) => Promise<unknown>`, `supported?` / `readSubscribed?` (default: the browser's own checks), `label? = 'Send push notifications to this browser'`, `description?`, `unavailableMessage?`, `unsupportedMessage?`, `deniedMessage?` (English defaults) | — | `Toggle`'s; the refusal message is a polite live region |
335
+ | `DateInput` | An ISO calendar date | `value ⚓` (`YYYY-MM-DD`), `min?`, `max?`, `size?`, `invalid?`, `describedBy?` | — | native `<input type="date">` |
336
+ | `FileInput` | A file, with a size cap | `file ⚓: File \| undefined`, `accept?`, `maxBytes?`, `tooLargeMessage? = 'That file is too large.'`, `onSelect?`, `size?`, `invalid?`, `describedBy?` | — | native file input; an over-size file is cleared and explained in an `alert` |
337
+ | `ImageInput` | `FileInput` + preview + dimension probe | `file ⚓`, `width ⚓`, `height ⚓`, `previewAlt? = 'Preview of the selected image'`, `name?`, `accept?`, `maxBytes?`, `tooLargeMessage?` | — | preview `<img>` named by `previewAlt`; emits hidden `width`/`height` inputs once `name` is set |
338
+ | `VideoInput` | `FileInput` + a progress readout | `file ⚓`, `progress ⚓` (0–100), `progressLabel? = 'Upload progress'`, `onSelect?`, `accept? = 'video/*'`, `maxBytes?`, `tooLargeMessage?` | — | native `<progress>` named by `progressLabel`, shown only while `0 < progress < 100` |
339
+ | `SearchInput` | A search field with clear and spinner | `label`, `value ⚓`, `onInput?`, `onSubmit?`, `onClear?`, `pending?`, `clearLabel? = 'Clear search'`, `size?`, `name? = 'q'`, `placeholder? = 'Search'` | — | composite `<div class="input">`; icon/spinner `aria-hidden`; clear button labelled |
340
+ | `Combobox<T>` | Typeahead over a list or a search | `value ⚓: string \| undefined`, `items?: readonly T[]` **or** `onSearch: (query, signal) => Promise<readonly T[]>`, `debounceMs? = 150`, `getId`, `getLabel`, `getBadge?`, `label`, `placeholder?`, `name?`, `emptyLabel? = 'No matches'`, `pendingLabel? = 'Searching…'`, `errorMessage?`, `id?`, `describedBy?`, `invalid?`, `required?`, `size?` | `item?(item)`, `footer?({ query, pending })`, `empty?({ query })` | bits-ui listbox ARIA; Enter with nothing highlighted lets a wrapping `<form>` submit |
341
+ | `FieldErrors` | The messages under one control | `errors: readonly string[]`, `id?` + `HTMLAttributes<HTMLDivElement>` | — | ONE `role="alert"` region holding one `<p>` per message; renders nothing while empty |
342
+
343
+ `fields` also exports `field-types.ts` (`FieldControl`, `FieldSize`, `FieldOption`, `FieldOptionInput`, `toFieldOptions`) and the daisyUI size tables (`inputSize`, `selectSize`, …).
344
+
345
+ ```svelte
346
+ <script lang="ts">
347
+ import { FormField, TextInput, Select, Textarea, CheckboxGroup, RadioGroup, Toggle, Combobox } from '@thomasfosterau/components';
348
+ </script>
349
+
350
+ <!-- One id becomes the label's `for`, the input's `aria-describedby`/`aria-invalid`, and the error region's id. -->
351
+ <FormField label="Scientific name" hint="Genus and species, as published." errors={nameErrors} required>
352
+ {#snippet children(control)}<TextInput {...control} name="scientificName" bind:value={name} />{/snippet}
353
+ </FormField>
354
+
355
+ <FormField label="Rank">
356
+ {#snippet children(control)}<Select {...control} options={['Family', 'Genus', 'Species']} bind:value={rank} />{/snippet}
357
+ </FormField>
358
+
359
+ <CheckboxGroup legend="Growing conditions" name="conditions" options={conditionOptions} bind:values={conditions} />
360
+
361
+ <!-- onChange may refuse: whatever it resolves to is written back, so a denied permission never leaves the switch reading "on". -->
362
+ <Toggle label="Notify this browser" checked={registered} onChange={(next) => (next ? registerPush() : unregisterPush())} />
363
+
364
+ <!-- Debounced, latest-wins, previous results stay on screen while the next are in flight. -->
365
+ <Combobox
366
+ label="Taxon" name="taxonId" onSearch={searchTaxa}
367
+ getId={(taxon) => taxon.id} getLabel={(taxon) => taxon.name} getBadge={(taxon) => taxon.rank}
368
+ bind:value={taxonId}
369
+ />
370
+ ```
371
+
372
+ ## forms
373
+
374
+ The parts of a form that are not one control. The split from `fields` is by who owns the message: a `FormErrors` message has no field to attach to, and a `FormActions` row belongs to the form rather than anything in it.
375
+
376
+ | Component | Purpose | Props | Snippets | ARIA |
377
+ | --- | --- | --- | --- | --- |
378
+ | `FormErrors` | Whole-form messages | `messages?: readonly string[] \| undefined`, `tone? = 'error'`, `class?` | — | one `Alert live="assertive"` per message; renders nothing while the list is empty |
379
+ | `FormActions` | The submit / cancel row | `submitLabel? = 'Save'`, `pendingLabel? = 'Saving…'`, `pending?`, `disabled?`, `tone? = 'primary'`, `cancelHref?`, `cancelLabel? = 'Cancel'` + `HTMLAttributes<HTMLDivElement>` | `children?` (extra controls) | submit is real `type="submit"` (Enter in any field still submits); while `pending` it is `disabled` **and** `aria-busy="true"`, its label swaps, and it never loses its name |
380
+ | `SearchForm` | The site search box | `action`, `name? = 'q'`, `value ⚓`, `label? = 'Search'`, `submitLabel? = 'Search'`, `placeholder?`, `required? = true` + `HTMLFormAttributes` | — | `<search>` landmark around a GET form; label is visually hidden but real, never a placeholder |
381
+ | `HiddenField` | A fixed posted value | `name`, `value: string \| number` + `HTMLInputAttributes` | — | none, deliberately — not in the accessibility tree |
382
+
383
+ ```svelte
384
+ <script lang="ts">
385
+ import { FormActions, FormErrors, HiddenField, FormField, TextInput } from '@thomasfosterau/components';
386
+ </script>
387
+
388
+ <form method="post" action="/gardens/42/plants">
389
+ <FormErrors messages={form?.errors?._errors} />
390
+ <HiddenField name="gardenId" value={garden.id} />
391
+ <FormField label="Plant name" errors={form?.errors?.name} required>
392
+ {#snippet children(control)}<TextInput {...control} name="name" bind:value={name} />{/snippet}
393
+ </FormField>
394
+ <FormActions submitLabel="Add plant" pendingLabel="Adding…" pending={saving} cancelHref={`/gardens/${garden.id}`} />
395
+ </form>
396
+
397
+ <!-- A GET form on purpose: a search result should be a URL people can bookmark, share and go back to. -->
398
+ <SearchForm action="/search" name="query" label="Search the site" placeholder="Scientific or common name" />
399
+ ```
400
+
401
+ ## data
402
+
403
+ The tables, lists and pagers an index page and a detail page are built from. Two shapes run through it: **`Table` and `ItemList` present a window the caller already chose** (they never decide which rows exist, so a server-paged 40,000 rows is as easy as four). **`DataGrid` sorts, filters and pages a bounded set the page already holds, on the client**, composing the other two rather than duplicating them.
404
+
405
+ | Component | Purpose | Props | Snippets | ARIA |
406
+ | --- | --- | --- | --- | --- |
407
+ | `Table<T>` | Column-driven `table` over a window the caller chose | `rows`, `columns: TableColumn<T>[]`, `rowKey`, `caption?`, `captionHidden?`, `size?: Size`, `zebra?`, `pinRows?`, `sort? ⚓: SortState`, `onSort?`, `onSelect?(row)`, `emptyMessage? = 'Nothing to show.'`, `sortLabel?(column)`, `containerClass?` + `HTMLTableAttributes` | `emptyState?`, `foot?` | `<caption>` is the accessible name (`sr-only` when `captionHidden`); `<th scope="col">`; sortable header is a real `<button>`, `<th>` carries `aria-sort`; `onSelect` rows take `role="button"` + `tabindex="0"` + Enter/Space |
408
+ | `DataGrid<T>` | Client-side sort + filter + page over a bounded set | as `Table` plus `filterable?`, `paginated? = true`, `pageSize? = 10`, `pageSizeOptions?`, `filterLabel? = 'Search'`, `filterPlaceholder?`, `rowsPerPageLabel?`, `showingLabel?(start,end,total)`, `pageOfLabel?(page,pages)`, `previousLabel?`, `nextLabel?`, `paginationLabel?`, `locales?` | `emptyState?` | the `Table`'s and the `Pagination`'s combined |
409
+ | `Pagination` | The one pager: numbered window or prev/next, links or callbacks | `offset`, `limit`, `total`, `variant?: 'window'\|'prevNext'`, `href?(offset)` **or** `onPage?(offset)`, `siblings? = 1`, `label? = 'Pagination'`, `previousLabel?`, `nextLabel?`, `pageLabel?(page)`, `size?: Size`, `showRange?`, `rangeLabel?(start,end,total)`, `statusLabel?(page,pages)`, `locales?` | — | `<nav aria-label>`; `rel="prev"`/`rel="next"`; unavailable arm is an `aria-disabled` `<span>`, never a dead link |
410
+ | `ResultCount` | "Showing 24 of 4,310" under a windowed list | `shown`, `total`, `label?(shown,total)`, `locales?` | — | plain `<p>`, deliberately not a live region |
411
+ | `PaginationFooter` | `ResultCount` + `Pagination` from one set of numbers | `shown`, `offset`, `limit`, `total`, `href?`/`onPage?`, `variant?`, `siblings?`, `size?`, `label?`, `previousLabel?`, `nextLabel?`, `pageLabel?`, `countLabel?`, `locales?` | — | its two parts' |
412
+ | `ItemList<T>` | The list container, keying and empty state | `items`, `variant?: 'compact'\|'stacked'\|'grid'\|'cards'`, `getKey(item,index)`, `emptyMessage? = 'Nothing here yet.'`, `feed?`, `label?`, `busy?` | `item(item, index)`, `empty?` | default: `<ul>`/`<li>`; `feed`: `<div role="feed" aria-label aria-busy>` of `<article aria-posinset aria-setsize tabindex="-1">` |
413
+ | `LinkListItem` | The common row body | `href`, `title`, `subtitle?`, `badges?`, `headingLevel?` | `meta?` | the title is the row's only interactive element |
414
+ | `DescriptionList` | `<dl>` of label/value pairs in four shapes | `variant?: 'grid'\|'card'\|'divided'\|'inline'`, `mono?`, `showEmpty?`, `emptyLabel? = 'unrecorded'` | `children` | a real `<dl>` |
415
+ | `DescriptionRow` | One pair inside it | `label`, `value?` **or** `children`, `valueClass?`, `labelClass?`, `showEmpty?`, `emptyLabel?` | `children?` | `<dt>`/`<dd>`; a row with nothing renders **nothing** (or `emptyLabel` when `showEmpty`) |
416
+ | `TreeList` | One collapsible node of a hierarchy | `open? ⚓ = true`, `title: string \| Snippet` | `title?`, `children?` | native `<details>`/`<summary>` inside `<ul>`/`<li>`; deliberately **not** `role="tree"` |
417
+ | `TreeListItem` | One leaf under it | — | `children?` | a plain `<li>` |
418
+ | `DiffText` | A word- or character-level diff inline | `segments: DiffSegment[]`, `separator? = ' '`, `insertLabel?`, `deleteLabel?` | — | `<ins>`/`<del>` carry the meaning; colour and strike-through are the second copy, never the only one |
419
+
420
+ `data` also exports `pageWindow`, `pageHref`, `nextSortState`, `columnAriaSort`, and the types `TableColumn`, `SortState`, `LinkListItemData`, `DiffSegment`. `pageHref(base, page, names?)` writes the position into `base`'s query, preserving every other parameter and the hash (JSON:API's `page[offset]`/`page[limit]` by default) — the callback shape a filtered list's pager needs so its own filter survives.
421
+
422
+ ```svelte
423
+ <script lang="ts">
424
+ import { Table, DataGrid, PaginationFooter, pageHref, ItemList, LinkListItem, DescriptionList, DescriptionRow, type SortState, type TableColumn } from '@thomasfosterau/components';
425
+
426
+ let sort = $state<SortState | undefined>({ key: 'name', direction: 'asc' });
427
+ const columns: readonly TableColumn<Plant>[] = [
428
+ { key: 'name', header: 'Plant', value: (p) => p.name, sortable: true },
429
+ { key: 'family', header: 'Family', value: (p) => p.family },
430
+ ];
431
+ const href = (offset: number) => pageHref(page.url.search === '' ? '/plants' : `/plants${page.url.search}`, { offset, limit });
432
+ </script>
433
+
434
+ <Table rows={plants} {columns} rowKey={(p) => p.id} caption="Everything sown this season" bind:sort onSort={reload} />
435
+
436
+ <!-- A bounded set the page already holds — so the grid does the work. -->
437
+ <DataGrid rows={devices} {columns} rowKey={(d) => d.id} filterable pageSize={10} caption="Your devices" />
438
+
439
+ <PaginationFooter shown={plants.length} {offset} {limit} total={meta.totalCount} {href} />
440
+
441
+ <ItemList items={gardens} variant="cards" getKey={(g) => g.id} emptyMessage="No gardens yet.">
442
+ {#snippet item(garden)}<LinkListItem href={garden.href} title={garden.title} subtitle={garden.subtitle} />{/snippet}
443
+ </ItemList>
444
+
445
+ <DescriptionList variant="card">
446
+ <DescriptionRow label="Captured"><DateTime value={media.capturedAt} dateStyle="long" /></DescriptionRow>
447
+ <!-- No licence recorded? The row is simply not there. -->
448
+ <DescriptionRow label="Licence">{#if media.licence}<Link href={media.licence.href}>{media.licence.name}</Link>{/if}</DescriptionRow>
449
+ </DescriptionList>
450
+ ```
451
+
452
+ ## infobox
453
+
454
+ One family, two ways to fill it. Composed primitives (`InfoboxHeader`, `InfoboxImage`, `InfoboxSection`, `InfoboxRow`) let a page build bespoke markup; a typed `rows` convenience on `Infobox` itself lets a page instead hand over a `$derived<InfoboxRowSpec[]>` built from sparse entity attributes — both render through the same primitives, so a fix to row layout lands once.
455
+
456
+ | Component | Purpose | Props | Snippets | ARIA |
457
+ | --- | --- | --- | --- | --- |
458
+ | `Infobox` | The summary rail frame | `title?`, `subtitle?`, `label='Key facts'`, `image?: InfoboxImageSpec`, `rows?: readonly InfoboxRowSpec[]`, `float?` + `HTMLAttributes<HTMLElement>` | `children?` (extra composed markup, rendered after `rows`) | `<aside aria-label={title ?? label}>` |
459
+ | `InfoboxHeader` | The title block | `title`, `subtitle?` + `HTMLAttributes<HTMLDivElement>` | — | `<h3>`, deliberately outside the document outline |
460
+ | `InfoboxImage` | The identifying picture | `src`, `alt`, `caption?`, `width?`, `height?`, `eager?` | — | `<figure>`/`<img>`/`<figcaption>`; default lazy |
461
+ | `InfoboxSection` | A titled group of rows | `title?` + `HTMLAttributes<HTMLDListElement>` | `children` (the rows) | owns the `<dl>` |
462
+ | `InfoboxRow` | One label/value pair | `label`, `value?: string \| number`, `href?` (through the components context), `external?`, `emptyLabel='unrecorded'`, `showEmpty?` + `HTMLAttributes<HTMLDivElement>` | `children?` (wins over `value`) | `<dt>`/`<dd>` pair; **empty by default** — `showEmpty` opts into an em dash captioned by `aria-label={emptyLabel}` |
463
+
464
+ `InfoboxRowSpec` is a discriminated union — `{ kind: 'fact', label, value }` \| `{ kind: 'link', label, text, href, external? }` \| `{ kind: 'links', label, links: InfoboxLinkEntry[] }` \| `{ kind: 'image', ...InfoboxImageSpec }` \| `{ kind: 'custom', label, content: Snippet }` — filtered by the exported `infoboxRowIsPresent(row)`.
465
+
466
+ ```svelte
467
+ <script lang="ts">
468
+ import { Infobox, InfoboxSection, InfoboxRow, type InfoboxRowSpec } from '@thomasfosterau/components';
469
+ </script>
470
+
471
+ <!-- Composed. -->
472
+ <Infobox title="Solanum lycopersicum" subtitle="Tomato">
473
+ <InfoboxSection title="Classification">
474
+ <InfoboxRow label="Family" value="Solanaceae" />
475
+ <InfoboxRow label="Genus" href="/taxa/solanum" value="Solanum" />
476
+ <!-- Renders nothing at all — the row is simply absent. -->
477
+ <InfoboxRow label="Frost tolerance" />
478
+ </InfoboxSection>
479
+ </Infobox>
480
+
481
+ <!-- Typed rows — same primitives, one array. -->
482
+ <script lang="ts">
483
+ const rows: InfoboxRowSpec[] = [
484
+ { kind: 'image', src: taxon.image?.url, alt: taxon.name, eager: true },
485
+ { kind: 'fact', label: 'Family', value: taxon.family },
486
+ { kind: 'links', label: 'Synonyms', links: taxon.synonyms.map((s) => ({ label: s.name, href: s.href })) },
487
+ ];
488
+ </script>
489
+ <Infobox title={taxon.name} {rows} />
490
+ ```
491
+
492
+ ## formatting
493
+
494
+ Locale-aware rendering of dates, numbers, lists, relative time and search-result highlighting. Every `Intl` constructor reads `locales`, then falls back to `getComponentsContext().locale()`.
495
+
496
+ | Component | Purpose | Props | Snippets | ARIA |
497
+ | --- | --- | --- | --- | --- |
498
+ | `DateTime` | `<time>` with an ISO `datetime` and `Intl.DateTimeFormat` text | `value: Date \| number \| string`, `locales?`, `dateStyle? = 'medium'`, `timeStyle?`, `options?: Intl.DateTimeFormatOptions` (wins over the styles) | `children?` — replaces the text; `datetime` stays machine-readable | native `<time>` semantics only |
499
+ | `NumberFormat` (also exported as `Number`; `NumberFormat` is primary, since a `Number` export would shadow the global) | `<data>` with a raw `value` attribute and `Intl.NumberFormat` text | `value: number \| bigint`, `locales?`, `style?`, `unit?`, `unitDisplay?`, `currency?`, `currencyDisplay?`, `maximumFractionDigits?`, `minimumFractionDigits?`, `signDisplay?`, `notation?`, `options?: Intl.NumberFormatOptions` (wins) | — | native `<data>` semantics only |
500
+ | `FormatList<T>` | Renders items through `Intl.ListFormat.formatToParts`, so conjunction/disjunction text ("and", ", ") comes from the runtime | `items: readonly T[]`, `style? = 'long'`, `type? = 'conjunction'`, `locales?` | `children(item: T)` | no wrapper element |
501
+ | `RelativeTime` | "3 hours ago" / "in 2 days" via `Intl.RelativeTimeFormat`, picking the largest unit whose magnitude rounds to ≥ 1 | `value: Date \| number \| string`, `now?` (pin it in tests), `locales?`, `numeric? = 'auto'`, `style? = 'long'` | — | `<time datetime=ISO title=absolute>` — the absolute instant is never hidden |
502
+ | `Year` | `<time>` with just a year — `datetime="1925"` — and its `Intl.DateTimeFormat` rendering | `value: Date \| number \| string` (a number is a calendar year; an instant contributes its UTC year), `locales?`, `era?`, `representation? = 'numeric'` (`'2-digit'`) | — | native `<time>`; `datetime` is a valid HTML year string (ISO instant before 1 CE) |
503
+ | `Highlight` (+ exported `tokenise`) | Best-effort client-side token highlight for search results | `text: string`, `query: string` | — | `<mark>` per matched token; naive — a UX nicety, not the authoritative FTS match |
504
+
505
+ ```svelte
506
+ <DateTime value={post.publishedAt} dateStyle="long" />
507
+ <NumberFormat value={price} style="currency" currency="AUD" />
508
+ <p><FormatList items={tags}>{#snippet children(tag)}<Badge>{tag}</Badge>{/snippet}</FormatList></p>
509
+ <RelativeTime value={comment.createdAt} />
510
+ <Year value={book.yearPublished} itemprop="datePublished" />
511
+ <p><Highlight text={result.title} query={searchQuery} /></p>
512
+ ```
513
+
514
+ ## links
515
+
516
+ | Component | Purpose | Props | Snippets | ARIA |
517
+ | --- | --- | --- | --- | --- |
518
+ | `EmailLink` | A `mailto:` anchor whose query string is built with `URLSearchParams`, so every value is correctly encoded | `email: string`, `subject?`, `body?`, `cc?`, `bcc?`, `class` merged last, spreads `HTMLAnchorAttributes` | `children?` — replaces the link text; defaults to the address | native `<a>` — visible text is the accessible name |
519
+ | `ContextLinks` | The "About: X, Y" row under a piece of content. Renders nothing for an empty list | `links: readonly ContextLinkItem[]` (`{ href, label }`; each `href` resolved through the context), `label? = 'About'`, `class` merged last, spreads `HTMLAttributes<HTMLParagraphElement>` | — | a paragraph of links; each link's text is its name |
520
+
521
+ ```svelte
522
+ <EmailLink email="hello@thomasfoster.au" subject="Question about your garden">
523
+ {#snippet children()}Contact us{/snippet}
524
+ </EmailLink>
525
+ <ContextLinks links={[{ href: `/taxa/${taxon.id}`, label: taxon.name }]} />
526
+ ```
527
+
528
+ ## media
529
+
530
+ | Component | Purpose | Props | Snippets | ARIA |
531
+ | --- | --- | --- | --- | --- |
532
+ | `Image` | Responsive `<img>`, optionally wrapped in `<picture>` | `alt: string` (required, no default), `src?` **or** `id?` + `base? = '/images'` + `widths? = [320,640,960,1280,1920]` (bucketed `srcset`), `sources?: { srcset, type?, media? }[]` (→ `<picture>`), `width?`, `height?`, `sizes? = '100vw'`, `eager? = false`, `decoding? = 'async'`; spreads `HTMLImgAttributes` | — | `alt` is required and undefaulted — an intentionally decorative image passes `alt=""` explicitly |
533
+ | `Video` | Player for an HLS-served video (Cloudflare Stream shape) | `hlsUrl?`, `posterUrl?`, `title: string` (accessible name), `width?`, `height?`, `uploadState? = 'ready'` (`'pending'\|'processing'\|'ready'\|'failed'`), `statusLabel?`, `onReady?: (el: HTMLVideoElement) => void`, `captionsSrc?` | — | `title` → `aria-label` on `<video>`; a non-playable placeholder's `statusLabel` is visible text, not colour alone |
534
+ | `Figure` | `<figure>` with an optional `<figcaption>` | `caption?: string \| Snippet<[]>`, spreads `HTMLAttributes<HTMLElement>`, `class` merged last | `children` (required), `caption` (when a snippet) | native `<figure>`/`<figcaption>` association |
535
+
536
+ ```svelte
537
+ <Image id={photo.id} alt="Tomato seedlings in early spring" width={1280} height={720} />
538
+ <Video hlsUrl={video.hlsUrl} posterUrl={video.posterUrl} title="Spring garden tour" onReady={attachHlsJs} />
539
+ <Figure caption="A row of tomato seedlings"><img src={photo.src} alt="Tomato seedlings" /></Figure>
540
+ ```
541
+
542
+ `imageDimensions(file: Blob): Promise<{ width, height }>` reads an image file's intrinsic pixel size with `createImageBitmap`, in the browser, so an upload form can submit `width` / `height` with the binary (it is what `ImageInput` measures with). It rejects with `ImageDimensionsUnavailable` where there is no `createImageBitmap` (on a server), and with the browser's own error for a file that does not decode.
543
+
544
+ ## hotkeys
545
+
546
+ One registry answers "what can the viewer do right now"; `HotkeyProvider` matches keystrokes against it, `HotkeyHelpDialog` lists what carries a trigger, and `search`'s `CommandPalette` lists what is palette-visible. An app creates the registry in its root layout, renders `HotkeyProvider` once, and registers actions from anywhere below — global ones from the layout, page ones from the page, torn down on unmount.
547
+
548
+ | Component | Purpose | Props | Snippets | ARIA |
549
+ | --- | --- | --- | --- | --- |
550
+ | `HotkeyProvider` | The single global `keydown` listener: chords, sequences, the editable guard | — (reads the registry from context) | — | renders nothing. Its obligation is negative — it never steals a keystroke from a control that needs one. While focus is in an editable target, or while `paletteOpen`/`helpOpen` is set, only `allowInEditable` actions fire |
551
+ | `HotkeyBadge` | A trigger as `kbd` chips | `trigger: HotkeyTrigger`, `commandLabel? = '⌘'`, `controlLabel? = 'Ctrl'`, `thenLabel? = 'then'` + `HTMLAttributes<HTMLSpanElement>` | — | `<kbd>` per key; `$mod` renders as `controlLabel` on the server and resolves per platform after mount |
552
+ | `HotkeyHelpDialog` | The `?` overlay listing every live hotkey | `title? = 'Keyboard shortcuts'`, `closeLabel? = 'Close'`, `emptyLabel?`, `groupLabels? = { page: 'This page', global: 'Everywhere' }`, `commandLabel?`, `controlLabel?`, `thenLabel?` | — | this library's `Dialog`; each scope is a `<section>` with a heading over a `<ul>`. Opened/closed through the registry's `helpOpen` |
553
+
554
+ | Export | What it is |
555
+ | --- | --- |
556
+ | `createHotkeyRegistry({ isAdmin, dev? })` | Creates the registry and puts it in context. Call once, from the root layout. `isAdmin` is a function, so the gate is reactive. |
557
+ | `getHotkeyRegistry()` / `tryGetHotkeyRegistry()` | Read it back; the second returns `undefined` instead of throwing — the seam `CommandPalette` uses to work with no registry. |
558
+ | `registerHotkeys(() => actions, { scope? })` | Declarative registration for the calling component's lifetime; `scope` defaults to `'page'`. |
559
+ | `HotkeyTrigger` | Either a **chord** (`'$mod+k'`) — modifiers held together, `$mod` being ⌘ on Apple platforms and Ctrl elsewhere — or a **sequence** (`'e'`, `['g', 'p']`) of plain keys pressed in order, buffered for `SEQUENCE_TIMEOUT_MS`. |
560
+
561
+ ```svelte
562
+ <!-- Root layout, once. -->
563
+ <script lang="ts">
564
+ import { createHotkeyRegistry, registerHotkeys, HotkeyProvider, HotkeyHelpDialog, CommandPalette } from '@thomasfosterau/components';
565
+
566
+ const hotkeys = createHotkeyRegistry({ isAdmin: () => user?.isAdmin === true, dev });
567
+ registerHotkeys(() => [
568
+ { id: 'palette.toggle', label: 'Open the command palette', group: 'actions', trigger: '$mod+k', allowInEditable: true, paletteVisible: false, run: () => (hotkeys.paletteOpen = !hotkeys.paletteOpen) },
569
+ { id: 'nav.plants', label: 'Go to plants', group: 'navigation', trigger: ['g', 'p'], run: () => goto('/plants') },
570
+ ], { scope: 'global' });
571
+ </script>
572
+
573
+ <HotkeyProvider />
574
+ <HotkeyHelpDialog />
575
+ <CommandPalette />
576
+ ```
577
+
578
+ ```svelte
579
+ <!-- A page, contributing its own actions for as long as it is mounted. -->
580
+ <script lang="ts">
581
+ import { registerHotkeys } from '@thomasfosterau/components';
582
+ registerHotkeys(() => bed.canEdit ? [{ id: 'bed.edit', label: 'Edit this bed', group: 'actions', trigger: 'e', run: () => goto(`/beds/${bed.id}/edit`) }] : []);
583
+ </script>
584
+ ```
585
+
586
+ ## search
587
+
588
+ One component, because a palette is one thing: a dialog, a query box, and rows from wherever the app has them. It composes this library's `Dialog` with bits-ui's `Command`, taking rows from up to three places at once — a mounted hotkey registry's `paletteActions`, a static `items` prop, and an asynchronous `onSearch` — none required.
589
+
590
+ | Component | Purpose | Props | Snippets | ARIA |
591
+ | --- | --- | --- | --- | --- |
592
+ | `CommandPalette` | The ⌘K palette: actions, navigation and search results in one list | `open? ⚓` (two-way bound to the registry's `paletteOpen` when a registry exists), `label? = 'Command palette'`, `placeholder? = 'Type a command or search…'`, `loadingLabel? = 'Searching…'`, `emptyLabel? = 'No results'`, `closeLabel? = 'Close'`, `groups?: readonly { id, label }[]`, `items?: readonly CommandItem[]`, `onSearch?: (query, signal) => Promise<readonly CommandItem[]>`, `debounceMs? = 150`, `onSelect?: (item) => void` | `item?(item)` | `Dialog`'s plus `Command`'s: the input is a `combobox` owning a `listbox`, each row an `option`, `aria-activedescendant` follows the arrow keys. The pending row is a `progressbar` named by `loadingLabel` |
593
+
594
+ `onSearch` follows the same debounce / latest-wins (`AbortController`) / stale-while-revalidate rules as `fields/Combobox`. `CommandItem` is `{ id, label, group, hint?, badge?, run?, href? }`; activation closes the palette, calls `onSelect`, then `run`, then navigates to `href` through the components context.
595
+
596
+ ```svelte
597
+ <!-- With a hotkey registry mounted, no props are needed at all. -->
598
+ <CommandPalette />
599
+ ```
600
+
601
+ ```svelte
602
+ <script lang="ts">
603
+ import { CommandPalette, type CommandItem } from '@thomasfosterau/components';
604
+
605
+ const items: readonly CommandItem[] = [
606
+ { id: 'bed.create', label: 'New bed', group: 'actions', run: () => goto('/beds/new') },
607
+ ];
608
+ const onSearch = async (query: string, signal: AbortSignal): Promise<readonly CommandItem[]> => {
609
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
610
+ return (await response.json()).map((row) => ({ id: row.id, label: row.name, group: 'results', href: `/plants/${row.id}` }));
611
+ };
612
+ </script>
613
+
614
+ <CommandPalette bind:open {items} {onSearch} />
615
+ ```
616
+
617
+ ## citations
618
+
619
+ `@thomasfosterau/components/citations`. A page's `Citation` and `Footnote` markers register into a shared `BibliographyManager` (provided by `setupBibliography`); `ReferenceList` and `FootnoteList` render what accumulated. `CreditList` renders a locale-joined byline for one role.
620
+
621
+ | Component | Purpose | Props | Snippets | ARIA |
622
+ | --- | --- | --- | --- | --- |
623
+ | `Citation` | An inline reference marker; registers its source and renders the number it was given | `citation: CitationData`, `format? = 'superscript'` (`'inline'`), `linkToReference? = true`, `referenceLabel? = (n, title) => \`Reference ${n}: ${title}\`` | — | the marker's accessible name comes from `referenceLabel`; a real `<a>` when `linkToReference`, otherwise a `<span>` |
624
+ | `Footnote` | An inline note marker, tracked separately from citations | `id: string`, `content: string`, `symbol?: string`, `footnoteLabel? = (marker, content) => \`Footnote ${marker}: ${content}\`` | — | accessible name from `footnoteLabel` on the `<a href="#footnote-<id>">` |
625
+ | `ReferenceList` | The numbered bibliography a page's `Citation`s accumulated, in citation order. Renders nothing until something has been cited | `title? = 'References'`, `showTitle? = true`, `headingLevel? = 2` | — | a real heading; each entry anchors `#ref-<id>` |
626
+ | `FootnoteList` | The notes a page's `Footnote`s accumulated. Renders nothing until something has been noted | `title? = 'Notes'`, `showTitle? = true`, `headingLevel? = 2` | — | a real heading; each entry anchors `#footnote-<id>` |
627
+ | `CreditList` | A locale-joined byline for one role — authors, editors, translators, or a caller's own. Renders nothing for an empty list | `credits: readonly Credit[]`, `role? = 'author'` (`'editor'\|'translator'\|string`), `roleLabel?: (count) => string`, `roleLabelPosition?: 'before'\|'after'`, `itemprop?`, `locales?`, `style?`, `type?`, `class?` | — | Schema.org microdata (`itemprop`, `itemscope`, `itemtype`) per resolved credit |
628
+ | `DOILink` | A DOI as a resolvable `https://doi.org/<doi>` link | `doi: string`, `itemprop?`, `children?` (defaults to the bare DOI), plus native anchor attributes | `children?` | `target="_blank" rel="noreferrer"` |
629
+ | `ISBNLink` | An ISBN as a resolvable WorldCat lookup link | `isbn: string`, `itemprop?`, `children?`, plus native anchor attributes | `children?` | as `DOILink` |
630
+ | `ISSNLink` | An ISSN as a resolvable WorldCat lookup link | `issn: string`, `itemprop?`, `children?`, plus native anchor attributes | `children?` | as `DOILink` |
631
+ | `BookCitation` | A book as a compact author–date reference: "Authors, _Title_ editors translators (3rd edition: 1969) _Series_ Place: Publisher pp 304 available online at … ISBN" | `book: BookRecord`, `itemprop?`, `untitledLabel? = 'Untitled book'`, `noDateLabel? = 'no date'`, `editionLabel? = (e) => \`${e} edition\``, `pageCountLabel? = 'pp'`, `availableOnlineLabel? = 'available online at'`, `locales?` | — | Schema.org `Book` microdata; contributors through `CreditList`, in credit order |
632
+ | `ChapterCitation` | "Authors, 'Chapter', in `BookCitation` ch 4 (pp 101–130)" — the containing book nested under `isPartOf` when the record carries it | `chapter: ChapterRecord`, `untitledLabel? = 'Untitled chapter'`, `inLabel? = 'in'`, `chapterLabel? = 'ch'`, `pageLabel? = 'p'`, `pagesLabel? = 'pp'`, `bookLabels?` (forwarded), `locales?` | — | Schema.org `Chapter` microdata |
633
+ | `ScholarlyArticleCitation` | "Authors, 'Title' (2019) _Periodical_ 12 (3) pp 45–67 DOI ISSN" | `article: ScholarlyArticleRecord`, `itemid?`, `untitledLabel? = 'Untitled article'`, `noDateLabel?`, `pageLabel?`, `pagesLabel?`, `availableOnlineLabel?`, `locales?` | — | `ScholarlyArticle` → `PublicationIssue` → `PublicationVolume` → `Periodical` microdata |
634
+ | `ThesisCitation` | "Author, _Title_ (2019) PhD thesis, Institution" | `thesis: ThesisRecord`, `untitledLabel? = 'Untitled thesis'`, `degreeLabel? = (d) => d ? \`${d} thesis\` : 'thesis'`, `availableOnlineLabel?`, `locales?` | — | Schema.org `Thesis`; the institution is `sourceOrganization` |
635
+ | `ReportCitation` | "Authors, _Title_ editors translators (2021) Place: Publisher pp 88" | `report: ReportRecord`, `itemid?`, `untitledLabel? = 'Untitled report'`, `noDateLabel?`, `pageCountLabel?`, `availableOnlineLabel?`, `locales?` | — | Schema.org `Report` microdata |
636
+ | `Publisher` | "Place: Name" — a publisher or awarding institution | `publisher: PublisherRecord` (`{ name, itemtype? = 'Organization', address?: { locality?, region?, country? } }`), `itemprop? = 'publisher'` | — | `Person` / `Organization` with a `PostalAddress` of the present parts |
637
+
638
+ **The CSL-type records are local.** `BookRecord`, `ChapterRecord`, `ScholarlyArticleRecord`, `ThesisRecord`, `ReportRecord`, `PublisherRecord` and `CitationDate` are defined in this package for now, because the bibliographic model they will align with, `@thomasfosterau/biblio`, is not published yet. Contributors are resolved `Credit`s in credit order, not linkage. An app maps its resources onto these shapes at its own seam. The JSON:API `included` resolver belongs to effect-jsonapi's client helpers, not here.
639
+
640
+ `formatCitation`, `BibliographyManager`, `setupBibliography`, `getBibliographyContext`, and the `CitationData`/`CitationKind`/`FootnoteData`/`Credit` types are also exported. (`Citation` and `Footnote` name the components; the data shapes are `CitationData`/`FootnoteData` to avoid a value/type collision.)
641
+
642
+ ```svelte
643
+ <script lang="ts">
644
+ import { Citation, Footnote, ReferenceList, FootnoteList, setupBibliography, CreditList } from '@thomasfosterau/components/citations';
645
+
646
+ setupBibliography(); // once, high in the page
647
+ const revision = { id: 'revision', kind: 'article', title: 'A revision of Rosa section Caninae', authors: ['Smith, J.'], year: 1998, container: 'Australian Systematic Botany' };
648
+ </script>
649
+
650
+ <p><em>Rosa canina</em> is circumscribed narrowly here<Citation citation={revision} />.</p>
651
+ <ReferenceList />
652
+
653
+ <!-- "Smith, J. and Jones, A." -->
654
+ <CreditList role="author" credits={[{ name: 'Smith, J.', href: '/people/smith-j', itemtype: 'Person' }, { name: 'Jones, A.' }]} />
655
+ ```
656
+
657
+ A `Citation` or `Footnote` rendered with no `setupBibliography()` provider above it still works — `getBibliographyContext()` falls back to a fresh, standalone manager rather than throwing.
658
+
659
+ ## entity-form
660
+
661
+ `@thomasfosterau/components/entity-form`. The renderer for [`@thomasfosterau/effect-forms-svelte`](https://github.com/thomasfosterau/effect-forms)'s `defineEntityForm` definitions. It is the only entry point that reaches Effect, so it is kept separate and its form library is an optional peer: `pnpm add @thomasfosterau/effect-forms-svelte effect` if you use it.
662
+
663
+ | Component | Purpose | Props | Snippets | ARIA |
664
+ | --- | --- | --- | --- | --- |
665
+ | `EntityForm` | Walks `definition.entriesFor(mode)` in declaration order. Scalar entries render as `FormField` + `TextInput` (`url` / numeric `inputmode`), `DateInput` (`type: 'date'`) or `Textarea` (multiline). Select entries render as `Select` over the field's derived enum values. Widget entries render the entry's own component through the bridge | `definition: EntityFormDefinition`, `mode: 'create' \| 'update'`, `cancelHref?`, `action?` (a named action), `initial?: Record<string, string>` (seeded once, at mount, so it lands in SSR HTML), `initialRows?` (per `rows` widget), `fieldProps?` (per widget key), `enhance?: (form) => attributes` (default: the store's own `form.props`), `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Saving…'`, `selectPlaceholder? = 'Choose…'`, `requiredLabel?`, plus native `<form>` attributes | — | each control is labelled by its `FormField`. Errors are `aria-describedby`. Root errors are a `FormErrors` alert |
666
+
667
+ Field labels, submit labels and option labels come from the definition's own message thunks. Every other string is a prop.
668
+
669
+ **The widget bridge.** A `custom` or `rows` widget receives its definition's `props()`, then your `fieldProps[key]`, then `name` / `value` / `onChange` / `errors`, in that order. The bridge goes last, so neither of the first two can sever the store binding. A `custom` widget bridges a string field of the store. A `rows` widget bridges a row list held by the renderer and serialises it itself. A `gate: true` widget disables submit while its value is empty. A create form whose command carries a client id gets a `crypto.randomUUID()` minted once per mount, so a retried submission stays idempotent.
670
+
671
+ **SvelteKit.** This package cannot import `$app/forms`, so pass your app's `enhanceForm` (the `$app/forms`-backed spreadable attributes, which effect-forms#2 moves into `@thomasfosterau/effect-forms-svelte/kit`):
672
+
673
+ ```svelte
674
+ <script lang="ts">
675
+ import { EntityForm } from '@thomasfosterau/components/entity-form';
676
+ import { enhanceForm } from '$lib/effect-form';
677
+ import { bookForm } from '$lib/entity-forms/book';
678
+
679
+ let { data } = $props();
680
+ </script>
681
+
682
+ <EntityForm definition={bookForm} mode="update" initial={data.initial} cancelHref={`/books/${data.id}`} enhance={enhanceForm} />
683
+ ```
684
+
685
+ ## seo
686
+
687
+ `@thomasfosterau/components/seo`. Per-page `<head>` metadata and structured data. Both components render only into `<svelte:head>` — nothing in the body.
688
+
689
+ | Component | Purpose | Props | Snippets | ARIA |
690
+ | --- | --- | --- | --- | --- |
691
+ | `Head` | Title (with a site-name suffix), description, keywords, robots, canonical URL, `hreflang` alternates, a feed link, Open Graph + Twitter / X tags, share image, `article:*`, `prev` / `next` | `title: string`, `siteName?`, `titleSeparator? = ' \| '`, `description?`, `keywords?`, `type?: SchemaPageType` (derives `og:type`), `openGraphType?`, `canonical?`, `pathname?` (defaults to `window.location.pathname`; pass `page.url.pathname` for SSR), `canonicalHref?` (defaults to the context `href`), `alternates?` / `locales?`, `ogLocale?` (defaults to the context locale, `-` → `_`), `feed?: { path, title? = 'Latest changes', type? = 'application/feed+json' }`, `robots?`, `image?` → `shareCard?` → `defaultImage?` (precedence), `shareCardPath? = '/og'`, `shareCardAlt?` (defaults to `"<title> — <siteName>"`), `published?`, `modified?`, `section?`, `tags?`, `twitterCard? = 'summary_large_image'`, `twitterSite?`, `twitterCreator?`, `prevUrl?`, `nextUrl?` | — | — (head metadata only) |
692
+ | `JsonLd` | One `<script type="application/ld+json">` block, serialised by `serializeJsonLd` | `data: JsonLdObject \| null \| undefined` — nullish renders nothing | — | — |
693
+
694
+ `serializeJsonLd(data)` escapes `<`, `>`, `&`, U+2028 and U+2029 so authored text cannot close the script element; the output is still valid JSON. `ogImageUrl(card, { href?, basePath? })` builds the content-addressed share-card URL `<basePath>/<slug>.png?t=…&d=…&v=…`, where the `v` token is the card's `updatedAt` in epoch milliseconds, so a content change always mints a new URL (link-preview scrapers only re-fetch a URL they have not seen). It returns `undefined` for a blank title. The param names (`OG_TITLE_PARAM`, `OG_SUBTITLE_PARAM`, `OG_VERSION_PARAM`), the length caps and `OG_IMAGE_WIDTH` / `OG_IMAGE_HEIGHT` are exported so a card renderer reads back exactly what `Head` wrote. The renderer itself is server code and does not live here.
695
+
696
+ **Canonical on the production origin.** `Head` resolves the canonical URL, the feed and the share card through `canonicalHref`, which defaults to the components context's `href`. If your `href` returns relative, locale-prefixed paths, pass an absolute resolver instead:
697
+
698
+ ```svelte
699
+ <script lang="ts">
700
+ import { Head, JsonLd } from '@thomasfosterau/components/seo';
701
+ import { page } from '$app/state';
702
+ import { locales } from '$lib/paraglide/runtime';
703
+ import * as m from '$lib/paraglide/messages';
704
+
705
+ const canonicalHref = (path: string) => new URL(path, 'https://example.org').href;
706
+ </script>
707
+
708
+ <Head
709
+ title={post.title}
710
+ siteName={m.site_name()}
711
+ description={post.summary}
712
+ type="BlogPosting"
713
+ pathname={page.url.pathname}
714
+ {canonicalHref}
715
+ {locales}
716
+ shareCard={{ id: post.id, title: post.title, description: post.summary, updatedAt: post.updatedAt }}
717
+ defaultImage={{ url: canonicalHref('/og-default.png'), alt: m.site_name(), width: 1200, height: 630, type: 'image/png' }}
718
+ feed={{ path: '/feed.json', title: m.feed_title() }}
719
+ />
720
+ <JsonLd data={buildBlogPostingJsonLd(post)} />
721
+ ```
722
+
723
+ ## tips
724
+
725
+ `@thomasfosterau/components/tips`. TipKit — contextual, dismissible guidance: an inline tip, a tip anchored to its trigger, the store that decides when either may show, and the stack that decides which one Escape closes.
726
+
727
+ | Component | Purpose | Props | Snippets | ARIA |
728
+ | --- | --- | --- | --- | --- |
729
+ | `Tip` | One piece of inline guidance — title, optional message, actions. Shows itself only when its store says it may, and remembers that it did | `config: TipConfig`, `store: TipStore`, `stack?: TipStack`, `dismissLabel? = 'Dismiss'`, `class?` | — | `role="status" aria-live="polite"`; the dismiss button has an accessible name from `dismissLabel` |
730
+ | `PopoverTip` | A `Tip` anchored to the thing it explains, floating beside it via bits-ui's `Popover` | `config: TipConfig`, `store: TipStore`, `stack?: TipStack`, `placement? = 'top'` (`'top'\|'bottom'\|'left'\|'right'`), `dismissLabel? = 'Dismiss'`, `class?` | `children` — the trigger content | bits-ui `Popover` dialog ARIA on the trigger; the bubble carries `Tip`'s `role="status"` |
731
+
732
+ `createTipStore`, `createTipStack`, `soloTipStack`, `memoryTipStorage`, `browserTipStorage` and the `Tip*` types are also exported.
733
+
734
+ ```ts
735
+ import { createTipStore } from '@thomasfosterau/components/tips';
736
+
737
+ const store = createTipStore({ prefix: 'myapp:tips:' }); // storage defaults to browserTipStorage()
738
+ store.register({
739
+ id: 'welcome',
740
+ title: 'Welcome',
741
+ message: 'Track what you grow.',
742
+ frequency: 'once',
743
+ });
744
+ ```
745
+
746
+ ```svelte
747
+ <script lang="ts">
748
+ import { Tip, PopoverTip, createTipStore } from '@thomasfosterau/components/tips';
749
+ const store = createTipStore();
750
+ store.register({ id: 'welcome', title: 'Welcome', message: 'Track what you grow.' });
751
+ </script>
752
+
753
+ <Tip config={{ id: 'welcome', title: 'Welcome', message: 'Track what you grow.' }} {store} />
754
+
755
+ <PopoverTip config={{ id: 'search', title: 'Search across everything', message: 'One box, everything.' }} {store} placement="bottom">
756
+ <button type="button" class="btn btn-primary">Search</button>
757
+ </PopoverTip>
758
+ ```
759
+
760
+ `open` on `PopoverTip` is derived purely from the store's own state; the trigger never toggles it by clicking. Escape is arbitrated by a shared `TipStack` (`createTipStack()`), so two `PopoverTip`s on one page close in "last shown, first closed" order — pass the same `stack` to both.
761
+
762
+ ## charts
763
+
764
+ `@thomasfosterau/components/charts`. One component, over a declarative series spec, drawn as plain SVG — no charting dependency.
765
+
766
+ | Component | Purpose | Props | Snippets | ARIA |
767
+ | --- | --- | --- | --- | --- |
768
+ | `Chart` | A line, area or bar chart over one y axis, with a data table always present and a hover readout | `series`, `kind? = 'line'` (`'line'\|'area'\|'bar'`), `title`, `description?`, `height? = 240`, `showTable?`, `legendLabel? = 'Series'`, `tableLabel?` (defaults to `title`), `categoryLabel?`, `formatValue?`, `class?` | — | `<svg role="img">` named by `title`; the figures are always reachable through an (optionally hidden) `<table>` captioned by `tableLabel`; the hover readout is `role="status"` |
769
+
770
+ ```svelte
771
+ <script lang="ts">
772
+ import { Chart, type ChartSeries } from '@thomasfosterau/components/charts';
773
+
774
+ const series: ChartSeries[] = [
775
+ { key: 'flowering', label: 'Flowering', points: [{ label: 'Sep', value: 4 }, { label: 'Oct', value: 9 }] },
776
+ { key: 'pest', label: 'Pest', points: [{ label: 'Sep', value: 6 }, { label: 'Oct', value: 5 }] },
777
+ ];
778
+ </script>
779
+
780
+ <Chart {series} kind="bar" title="Observations by kind" formatValue={(v) => `${v} kg`} showTable />
781
+ ```
782
+
783
+ Rules it enforces rather than leaving to the caller: one y axis (two measures of different scale are two charts); the zero line is always drawn; identity is never colour alone (a legend for 2+ series, a direct end-of-line label for ≤4); the numbers are always readable as a `<table>`, visually hidden by default. `key` is stable identity and picks the colour, so a filtered-out series never repaints the survivors that remain.
784
+
785
+ Series colours come from four validated theme slots, `--color-chart-1…4`, assigned by series order and never cycled — a fifth series draws in `--color-neutral` and the legend says which one it is. See [`theme.css`](src/lib/theme.css) for the measured separations, and `packages/components/CLAUDE.md`'s Terms section for the rules those measurements exist to keep.
786
+
787
+ ## canvas
788
+
789
+ `@thomasfosterau/components/canvas`. A low-level toolkit for vector-editing surfaces: pan, zoom, a grid, and pointer gestures reported in canvas coordinates. The application defines its element types, its tools and its renderer — nothing here knows what the elements mean.
790
+
791
+ | Component | Purpose | Props | Snippets | ARIA |
792
+ | --- | --- | --- | --- | --- |
793
+ | `VectorCanvas` | A pan-and-zoom SVG surface over a `VectorDocument`, with elements drawn by a renderer the caller supplies. Reports gestures; mutates nothing | `doc`, `selected?`, `tool?`, `width?`, `height?`, `interactive?`, `label? = 'Vector editing canvas'`, `onviewportchange?`, `oninteract?`, `class?`, plus the native `<svg>` attributes | `element(shape, selected)` — draws one element; omitted, a fallback marker is drawn | `<svg role="application">` named by `label`. `application` is the honest role for a surface whose whole vocabulary is pointer gestures — and a promise the page must keep by offering a non-pointer route to the same edits |
794
+
795
+ ```svelte
796
+ <script lang="ts">
797
+ import { VectorCanvas, type CanvasElement, type Viewport } from '@thomasfosterau/components/canvas';
798
+ let doc = $state<VectorDocument>(plan);
799
+ </script>
800
+
801
+ <VectorCanvas {doc} tool="pan" width={640} height={360} label="Floor plan"
802
+ onviewportchange={(viewport: Viewport) => { doc = { ...doc, viewport }; }}>
803
+ {#snippet element(shape: CanvasElement, selected: boolean)}
804
+ <rect x={shape.position.x} y={shape.position.y} width="120" height="80" fill={shape.fillColor}
805
+ stroke={selected ? 'var(--color-primary)' : shape.strokeColor} />
806
+ {/snippet}
807
+ </VectorCanvas>
808
+ ```
809
+
810
+ `oninteract` reports `{ phase: 'down'|'move'|'up', tool, point, detail, native }` in CANVAS coordinates, already snapped when `doc.grid.snap` is set. `canvas/history.ts` gives pure undo/redo over `Transform`s rather than document snapshots (`emptyHistory`, `applyTransform`, `invertTransform`, `diffTransform`, `recordEdit`, `undo`, `redo`, `HISTORY_LIMIT = 100`) — a history of edits is bounded by the size of the edits, not the size of the plan. `canvas/utils.ts` gives the geometry underneath it: `screenToCanvas`/`canvasToScreen`, `snapToGrid`, `distance`, `pointInRect`, `pointNearLine`, `sortByZIndex`, `boundingBox`, `rotatePoint`, `panBy`, `zoomAbout`, `generateId`.
811
+
812
+ ## maps — moved
813
+
814
+ Maps live in their own package, [`@thomasfosterau/components-mapbox`](../maps/README.md) (`pnpm add @thomasfosterau/components-mapbox mapbox-gl`), because they need `mapbox-gl`; nothing in this package imports it, and `@thomasfosterau/components/maps` is no longer an entry point.
815
+
816
+ ## License
817
+
818
+ MIT © Thomas Foster