@abuzareal/contour-ui 0.4.0 → 0.5.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 (136) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONTRACTS.md +7 -1
  3. package/NEXTJS.md +3 -1
  4. package/OVERLAYS.md +52 -0
  5. package/PUBLIC-API.md +179 -162
  6. package/README.md +40 -26
  7. package/RELEASING.md +1 -1
  8. package/SIZE.md +45 -0
  9. package/STYLING.md +68 -0
  10. package/dist/actions.css +7 -0
  11. package/dist/components/hero/Sculpture.js +2 -1
  12. package/dist/components/three/ObjectStudy.js +2 -1
  13. package/dist/components/ui/Alert.d.ts +2 -1
  14. package/dist/components/ui/Alert.js +3 -3
  15. package/dist/components/ui/Avatar.d.ts +3 -1
  16. package/dist/components/ui/Avatar.js +3 -3
  17. package/dist/components/ui/Badge.js +2 -2
  18. package/dist/components/ui/BrandMark.js +2 -1
  19. package/dist/components/ui/Breadcrumbs.d.ts +2 -1
  20. package/dist/components/ui/Breadcrumbs.js +3 -2
  21. package/dist/components/ui/Button.d.ts +2 -0
  22. package/dist/components/ui/Button.js +3 -2
  23. package/dist/components/ui/Card.js +2 -2
  24. package/dist/components/ui/Checkbox.js +3 -3
  25. package/dist/components/ui/CountUp.d.ts +2 -1
  26. package/dist/components/ui/CountUp.js +6 -3
  27. package/dist/components/ui/DataTable.d.ts +1 -0
  28. package/dist/components/ui/DataTable.js +12 -8
  29. package/dist/components/ui/DepthButton.js +2 -2
  30. package/dist/components/ui/Dialog.d.ts +5 -1
  31. package/dist/components/ui/Dialog.js +49 -18
  32. package/dist/components/ui/Disclosure.js +2 -1
  33. package/dist/components/ui/Divider.js +3 -2
  34. package/dist/components/ui/DropdownMenu.d.ts +11 -4
  35. package/dist/components/ui/DropdownMenu.js +109 -66
  36. package/dist/components/ui/EmptyState.js +2 -1
  37. package/dist/components/ui/Field.js +2 -2
  38. package/dist/components/ui/FlipCard.js +2 -2
  39. package/dist/components/ui/FloatingPortal.d.ts +10 -0
  40. package/dist/components/ui/FloatingPortal.js +51 -0
  41. package/dist/components/ui/IconButton.js +2 -2
  42. package/dist/components/ui/Kbd.js +2 -1
  43. package/dist/components/ui/Magnetic.js +2 -1
  44. package/dist/components/ui/Marquee.js +3 -2
  45. package/dist/components/ui/OverlayTrigger.d.ts +11 -0
  46. package/dist/components/ui/OverlayTrigger.js +5 -0
  47. package/dist/components/ui/Pagination.d.ts +5 -1
  48. package/dist/components/ui/Pagination.js +3 -2
  49. package/dist/components/ui/Popover.d.ts +18 -8
  50. package/dist/components/ui/Popover.js +58 -17
  51. package/dist/components/ui/ProgressBar.js +2 -2
  52. package/dist/components/ui/RadioGroup.js +5 -5
  53. package/dist/components/ui/Reveal.d.ts +0 -1
  54. package/dist/components/ui/Reveal.js +4 -3
  55. package/dist/components/ui/RevealHeading.js +2 -1
  56. package/dist/components/ui/ScrollProgress.js +2 -1
  57. package/dist/components/ui/SectionLabel.js +2 -1
  58. package/dist/components/ui/SegmentedControl.js +6 -3
  59. package/dist/components/ui/SelectField.js +3 -2
  60. package/dist/components/ui/Skeleton.js +2 -2
  61. package/dist/components/ui/Slider.js +3 -3
  62. package/dist/components/ui/Spinner.js +2 -2
  63. package/dist/components/ui/Stat.js +2 -1
  64. package/dist/components/ui/Stepper.js +3 -3
  65. package/dist/components/ui/Switch.d.ts +2 -0
  66. package/dist/components/ui/Switch.js +6 -7
  67. package/dist/components/ui/Tabs.js +7 -5
  68. package/dist/components/ui/Tag.d.ts +2 -1
  69. package/dist/components/ui/Tag.js +4 -3
  70. package/dist/components/ui/TextArea.js +2 -1
  71. package/dist/components/ui/TextField.js +3 -2
  72. package/dist/components/ui/ThemeScope.d.ts +13 -0
  73. package/dist/components/ui/ThemeScope.js +10 -0
  74. package/dist/components/ui/ThemeToggle.d.ts +3 -0
  75. package/dist/components/ui/ThemeToggle.js +4 -3
  76. package/dist/components/ui/TiltCard.js +3 -2
  77. package/dist/components/ui/Timeline.js +2 -2
  78. package/dist/components/ui/ToastProvider.d.ts +5 -2
  79. package/dist/components/ui/ToastProvider.js +5 -5
  80. package/dist/components/ui/Tooltip.d.ts +4 -4
  81. package/dist/components/ui/Tooltip.js +70 -10
  82. package/dist/data-display.css +5 -0
  83. package/dist/effects.css +6 -0
  84. package/dist/feedback.css +5 -0
  85. package/dist/fonts-latin.css +10 -4
  86. package/dist/fonts.css +8 -0
  87. package/dist/forms.css +5 -0
  88. package/dist/hooks/useDismiss.d.ts +12 -3
  89. package/dist/hooks/useDismiss.js +5 -21
  90. package/dist/hooks/useFloatingPanel.d.ts +14 -0
  91. package/dist/hooks/useFloatingPanel.js +157 -0
  92. package/dist/hooks/useLayerDismiss.d.ts +4 -0
  93. package/dist/hooks/useLayerDismiss.js +48 -0
  94. package/dist/hooks/useMobileMenu.d.ts +0 -1
  95. package/dist/hooks/useMobileMenu.js +11 -16
  96. package/dist/hooks/usePanelFocus.d.ts +10 -0
  97. package/dist/hooks/usePanelFocus.js +60 -0
  98. package/dist/hooks/useTheme.d.ts +1 -0
  99. package/dist/hooks/useTheme.js +18 -5
  100. package/dist/index.d.ts +5 -0
  101. package/dist/index.js +1 -0
  102. package/dist/kit/forms.css +11 -2
  103. package/dist/kit/navigation.css +4 -0
  104. package/dist/kit/overlays.css +8 -4
  105. package/dist/lib/classNames.d.ts +2 -0
  106. package/dist/lib/classNames.js +16 -1
  107. package/dist/lib/componentClasses.d.ts +1 -0
  108. package/dist/lib/componentClasses.js +2 -0
  109. package/dist/lib/keyboard.d.ts +1 -1
  110. package/dist/lib/keyboard.js +5 -2
  111. package/dist/lib/layers.d.ts +18 -0
  112. package/dist/lib/layers.js +128 -0
  113. package/dist/lib/themeScope.d.ts +5 -0
  114. package/dist/lib/themeScope.js +3 -0
  115. package/dist/minimal-reset.css +6 -0
  116. package/dist/navigation.css +5 -0
  117. package/dist/overlays.css +5 -0
  118. package/dist/scoped/base.css +183 -0
  119. package/dist/scoped/kit/actions.css +209 -0
  120. package/dist/scoped/kit/data-display.css +492 -0
  121. package/dist/scoped/kit/effects.css +140 -0
  122. package/dist/scoped/kit/feedback.css +309 -0
  123. package/dist/scoped/kit/forms.css +370 -0
  124. package/dist/scoped/kit/navigation.css +266 -0
  125. package/dist/scoped/kit/overlays.css +299 -0
  126. package/dist/scoped/marquee.css +58 -0
  127. package/dist/scoped/spinner.css +20 -0
  128. package/dist/scoped/theme-toggle.css +38 -0
  129. package/dist/scoped/tokens.css +119 -0
  130. package/dist/scoped/ui.css +127 -0
  131. package/dist/scoped/utilities.css +17 -0
  132. package/dist/scoped-tokens.css +1 -0
  133. package/dist/scoped.css +12 -0
  134. package/dist/theme-toggle.css +2 -2
  135. package/dist/tokens.json +710 -0
  136. package/package.json +26 -5
package/README.md CHANGED
@@ -6,7 +6,7 @@ Use it when you want this visual style with typed controls, feedback, navigation
6
6
 
7
7
  [Live catalogue](https://contour.abuzr.in/) · [npm package](https://www.npmjs.com/package/@abuzareal/contour-ui) · [Maintainer](https://github.com/abuzareal)
8
8
 
9
- This source checkout targets **0.4.0**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
9
+ This source checkout targets **0.5.0**. Package publication and application upgrades are separate; check the npm registry for release availability and each application's manifest for its installed version. Catalogue examples include application patterns as well as package components. Check the exports below before treating an example as an importable component.
10
10
 
11
11
  [Public API and maturity register](PUBLIC-API.md) · [Architecture, scope and compatibility](CONTRACTS.md)
12
12
 
@@ -14,44 +14,54 @@ Version 0.3.0 adds native refs for actions/choices and typed root/part props for
14
14
 
15
15
  Version 0.4.0 adds native named/external form submission and controlled/default/reset support for Switch, Slider and RadioGroup, plus caller IDs for Checkbox. See [the form guide](FORMS.md) and the copyable native/RHF source recipes shipped in examples/forms. React Hook Form is an application dependency; no runtime dependency or CSS rewrite is added. New form props are absent from 0.3.0.
16
16
 
17
+ Version 0.5 adds topmost overlay dismissal, hoverable hints, collision-aware body/modal portals, scoped/prefixed CSS and themes, local labels/formatters, selective style entries and measured size gates. Floating UI DOM is a new runtime positioning dependency. Default portal DOM placement changes ancestor selectors; see [overlay migration](OVERLAYS.md), [styling and embedding](STYLING.md) and [measured costs](SIZE.md). Legacy styles/imports remain available. This is an early 0.x release, not a 1.0 milestone.
18
+
17
19
  ## Install
18
20
 
19
21
  Use an existing React application with a bundler that resolves package CSS imports. Contour ships ESM and TypeScript declarations. The package declares Node.js 22 or newer; repository tooling uses Vite 8, which needs Node.js 22.12+ or 24+.
20
22
 
21
23
  ```sh
22
- npm install @abuzareal/contour-ui@0.4.0 --save-exact
24
+ npm install @abuzareal/contour-ui@0.5.0 --save-exact
23
25
  ```
24
26
 
25
27
  Your app must provide matching `react` and `react-dom` versions: **18.3.1 or React 19**. The compatibility script covers 18.3.1, 19.0.0, and 19.3.0; it doesn't establish support for every framework or React Server Component setup. Put interactive components inside a client boundary when your framework requires one.
26
28
 
27
29
  ## First component
28
30
 
29
- Import the styles once in your application entry. Import components directly from the package; no generated files or local re-exports are needed.
31
+ Import the styles once in your application entry. This isolated first-use recipe uses system fonts and no global reset; its scope owns only local theme state. Import components directly from the package; no generated files or local re-exports are needed.
30
32
 
31
33
  ```tsx
32
34
  import { useState } from "react";
33
- import "@abuzareal/contour-ui/reset.css";
34
- import "@abuzareal/contour-ui/fonts.css";
35
- import "@abuzareal/contour-ui/styles.css";
36
- import { Button, TextField, ThemeToggle } from "@abuzareal/contour-ui";
35
+ import "@abuzareal/contour-ui/scoped.css";
36
+ import {
37
+ Button,
38
+ TextField,
39
+ ThemeToggle,
40
+ ThemeScope,
41
+ } from "@abuzareal/contour-ui";
37
42
 
38
43
  export default function App() {
44
+ const [theme, setTheme] = useState<"light" | "dark">("light");
39
45
  const [name, setName] = useState("");
40
46
  const [savedName, setSavedName] = useState("");
41
47
 
42
48
  return (
43
- <main data-motion="off">
44
- <ThemeToggle animate={false} />
45
- <TextField
46
- label="Name"
47
- value={name}
48
- onChange={(event) => setName(event.target.value)}
49
- />
50
- <Button variant="accent" onClick={() => setSavedName(name)}>
51
- Save locally
52
- </Button>
53
- <p role="status">{savedName && `Saved for this session: ${savedName}`}</p>
54
- </main>
49
+ <ThemeScope theme={theme} onThemeChange={setTheme}>
50
+ <main data-motion="off">
51
+ <ThemeToggle animate={false} />
52
+ <TextField
53
+ label="Name"
54
+ value={name}
55
+ onChange={(event) => setName(event.target.value)}
56
+ />
57
+ <Button variant="accent" onClick={() => setSavedName(name)}>
58
+ Save locally
59
+ </Button>
60
+ <p role="status">
61
+ {savedName && `Saved for this session: ${savedName}`}
62
+ </p>
63
+ </main>
64
+ </ThemeScope>
55
65
  );
56
66
  }
57
67
  ```
@@ -72,13 +82,15 @@ import "@abuzareal/contour-ui/fonts-latin.css";
72
82
 
73
83
  | Import | Purpose |
74
84
  | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
75
- | `styles.css` | Required for the supplied appearance: tokens, component styles, and utilities. |
85
+ | `styles.css` | Legacy global aggregate: tokens, component styles and layout compatibility utilities. |
76
86
  | `reset.css` | Optional global reset. Omit it if your app already has equivalent native-control defaults, border-box sizing, focus styles, and a `[hidden]` rule. |
77
87
  | `fonts-latin.css` | Optional Latin-only WOFF2 fonts, available since 0.1.2. Use instead of `fonts.css`, not alongside it. |
78
88
  | `fonts.css` | Optional self-hosted Space Grotesk, DM Sans, and DM Mono via Fontsource. Your bundler emits the font assets. |
79
89
  | `tokens.css` | Tokens alone, for an app that supplies its own component styles. Already included by `styles.css`. |
80
90
 
81
- Override semantic tokens after importing the styles:
91
+ For new integrations, prefer `scoped.css`, optional `minimal-reset.css`, and `--contour-*` overrides. Family entries and the shipped `tokens.json` schema are documented in [STYLING.md](STYLING.md) and [SIZE.md](SIZE.md). The table and override example below describe **legacy compatibility imports**.
92
+
93
+ Override legacy semantic tokens after importing those styles:
82
94
 
83
95
  ```css
84
96
  :root {
@@ -92,13 +104,13 @@ Override semantic tokens after importing the styles:
92
104
  }
93
105
  ```
94
106
 
95
- Styles use global component classes and document-level theme tokens. Scope your application's classes to avoid collisions with names such as `.card` and `.button`. Contour's CSS isn't isolated with Shadow DOM or CSS Modules.
107
+ Legacy styles use global component classes and document-level theme tokens; isolated styles use prefixed hooks and ThemeScope. Scope your application's classes to avoid collisions with names such as `.card` and `.button`. Contour's CSS isn't isolated with Shadow DOM or CSS Modules.
96
108
 
97
- The three Fontsource packages and `lucide-react` are direct dependencies. Omitting `fonts.css` keeps its font assets out of your application's bundle; the font packages still install. React and React DOM are peers. Three.js and Lenis are optional peers. Development tools aren't installed as Contour's consumer dependencies; tests and tooling are excluded from the npm archive.
109
+ The three Fontsource packages, `lucide-react` and `@floating-ui/dom` are direct dependencies. Omitting `fonts.css` keeps its font assets out of your application's bundle; the font packages still install. React and React DOM are peers. Three.js and Lenis are optional peers. Development tools aren't installed as Contour's consumer dependencies; tests and tooling are excluded from the npm archive.
98
110
 
99
111
  ## Theme and motion
100
112
 
101
- Set `data-theme="light"` or `"dark"` on `<html>`. `ThemeToggle` and `useTheme(animate)` read the same document state. The hook returns `{ theme, toggle }`; call `toggle(buttonElement)` from a user action. Preferences use the `aa-theme` local-storage key and are local to the site's origin.
113
+ For embedded/mixed themes, use controlled ThemeScope with isolated CSS; scoped toggles do not write document state or storage. For legacy document ownership, set `data-theme="light"` or `"dark"` on `<html>`. `ThemeToggle` and `useTheme(animate)` read the same document state. The hook returns `{ theme, toggle, canToggle }`; call `toggle(buttonElement)` from a user action. Preferences use the `aa-theme` local-storage key and are local to the site's origin.
102
114
 
103
115
  For theme selection before first paint, inject `themeBootstrapScript` from `@abuzareal/contour-ui/theme-bootstrap` into the HTML head at build time. The exported string has no user interpolation. With a strict Content Security Policy, authorize that script with a nonce or hash.
104
116
 
@@ -166,7 +178,7 @@ Give scenes a sized container and a usable static fallback. `SceneBoundary` hide
166
178
  **0.2.0** adds native field refs, controlled tabs and popover/menu open state, menu keyboard improvements, native reset synchronization, Three.js lifecycle fixes and a tested Next.js App Router integration. Existing uncontrolled defaults and React 18/19 support remain. `fonts-latin.css`, introduced in 0.1.2, now uses packaged relative font URLs for webpack resolution. `CHANGELOG.md` is included in the package archive and [npm's Code view](https://www.npmjs.com/package/@abuzareal/contour-ui?activeTab=code); it contains the upgrade details for each version.
167
179
 
168
180
  ```sh
169
- npm install @abuzareal/contour-ui@0.4.0 --save-exact
181
+ npm install @abuzareal/contour-ui@0.5.0 --save-exact
170
182
  ```
171
183
 
172
184
  Commit your manifest and lockfile, run your application's checks, and review both themes, dialogs, forms, and motion before deploying. Version 0.1.1 includes a document scroll lock for dialogs and drawers. Review any app-level lock before removing it, including nested dialogs and short screens. Package publication doesn't update deployed consumers automatically.
@@ -188,6 +200,8 @@ npm pack --dry-run
188
200
 
189
201
  `validate` checks types, lint, builds, Node/component tests, dialog, native form/composition and Three.js browser behavior, and archive contents. `test:compat` builds packed React 18/19 consumers and compiles the source with React 19 types. `test:consumer` checks one packed consumer, with optional peers absent. `test:optional` verifies strict `/three` and `/scroll` declarations and builds their imports with explicit peers and Three.js types. The compatibility gate also runs shipped native/RHF recipe browser checks on React 18/19 and compiles the legacy form recipe against npm 0.3.0. It runs both optional/core checks and verifies the Latin font assets. Compatibility runs need registry access for clean installs.
190
202
 
203
+ `measure:size` / `check:size` build real Vite/Next consumers and keep JS/CSS/fonts/archive/install/network costs separate. `build:styles` generates isolated CSS from the token schema; public source links are checked against declarations.
204
+
191
205
  `src/` owns components, hooks, helpers, and public entries. `styles/` owns CSS. `tests/` owns regressions and browser checks; `scripts/` owns builds and package/consumer checks. `dist/` is generated. `RELEASING.md` in the source checkout and package archive covers validation and publication.
192
206
 
193
207
  MIT licensed. Fontsource fonts and dependencies retain their own licenses.
@@ -238,7 +252,7 @@ Custom `useThreeStage` setup can register disposal through `context.onDispose?.(
238
252
 
239
253
  ## Compatibility boundaries
240
254
 
241
- Styles are global, not a scoped CSS engine. Existing class names such as `.field` and `.menu` can collide with host CSS. Import Contour styles before deliberate application overrides; keep application selectors area-prefixed. The optional reset and fonts affect the document. Tokens use root `data-theme`; `useTheme` retains the legacy `aa-theme` local-storage key for compatibility. A multi-library page must decide which system owns its root theme.
255
+ Legacy aggregate styles remain global. The new isolated CSS path is documented in STYLING.md; it is not Shadow DOM or immunity from arbitrary host selectors. Existing class names such as `.field` and `.menu` can collide with host CSS. Import Contour styles before deliberate application overrides; keep application selectors area-prefixed. The optional reset and fonts affect the document. Tokens use root `data-theme`; `useTheme` retains the legacy `aa-theme` local-storage key for compatibility. A multi-library page must decide which system owns its root theme.
242
256
 
243
257
  Menus and popovers remain local panels without portals or collision detection; use them where the container can accommodate their content. No submenu or arbitrary trigger-composition contract is claimed. BrandMark and page/editorial helpers preserve existing identity and layout assumptions; they are optional exports rather than universal primitives. Do not copy private internals or deep-import them.
244
258
 
package/RELEASING.md CHANGED
@@ -9,7 +9,7 @@ Use [the public contract and compatibility policy](CONTRACTS.md#change-deprecati
9
9
  1. Obtain maintainer authorization to publish; validation alone is not authorization.
10
10
  2. Select an unused registry version and update package.json, its lockfile and release notes. Never reuse an existing registry version; a locally verified archive does not make it publishable under an already released version. Use a reviewed release commit on main and a matching `vVERSION` tag. The hosted workflow checks the tag version and that its source is an ancestor of origin/main. It cannot publish from a pull request.
11
11
  3. Run `npm ci --ignore-scripts`; install Chromium once with `npx playwright install chromium`. Use Node 22.12+ or 24+.
12
- 4. Set `CONTOUR_RELEASE_DIR` to an existing temporary directory and run `npm run verify:release`. It runs validate once, packs one archive, tests that archive in clean React 18/19 core and optional consumers plus the pinned Next App Router fixture, audits dependencies, and writes `verified-archive.json` with its SHA-256. Core consumers must have no Three.js or Lenis installed. Review failures and advisories instead of bypassing gates.
12
+ 4. Set `CONTOUR_RELEASE_DIR` to a temporary directory and run `npm run verify:release`. It runs validate once, packs one archive, tests that archive in clean React 18/19 core and optional consumers plus the pinned Next App Router fixtures, checks public API/generated styles and production size budgets, audits dependencies, and writes `verified-archive.json` with its SHA-256. Core consumers must have no Three.js or Lenis installed. Review failures and advisories instead of bypassing gates.
13
13
  5. Inspect the archive contents, verify its checksum against `verified-archive.json`, and publish that exact path using `npm publish /absolute/path/to/archive.tgz --ignore-scripts --access public`. `--ignore-scripts` is appropriate here because that exact archive has already passed the release gates; it is not permission to publish an unchecked package. Source `npm publish` retains a prepublishOnly guard. The hosted workflow verifies the checksum again and publishes the archive with provenance, avoiding duplicate source gates.
14
14
  6. The owner completes npm authentication/2FA personally. Never put tokens, OTPs or credentials in this repository or logs. Prefer configured trusted publishing and short-lived identity over persistent tokens.
15
15
  7. Verify registry version, tarball/integrity and publication date; install that exact registry version in each affected consumer, commit its manifest/lockfile, and run its required checks before deployment. Do not substitute a local link or source copy for release validation.
package/SIZE.md ADDED
@@ -0,0 +1,45 @@
1
+ # Package and application size
2
+
3
+ A package archive, installed dependencies and a browser download are different costs. Contour does not claim to be zero-dependency or the lightest library. `scripts/measure-size.mjs` builds public-import Button, settings-form and table journeys in Vite and pinned Next App Router/webpack, loads fresh browser pages, and writes a JSON report. An additional Button-with-ArrowRight fixture measures the incremental selected-icon journey; it is not an isolated icon-function byte count.
4
+
5
+ ```sh
6
+ npm run build
7
+ CONTOUR_SIZE_OUTPUT=/absolute/path/sizes.json npm run measure:size
8
+ CONTOUR_SIZE_OUTPUT=/absolute/path/check.json npm run check:size
9
+ ```
10
+
11
+ Set `CONTOUR_ARCHIVE` to the already packed candidate to reuse it. Fixtures install React/React DOM 19.3.0; Next uses 16.4.0, and Vite/Chromium/Node versions are recorded from the running tools. The report names package/source commit/dirty state, archive checksum/files/compressed/unpacked bytes, the fresh dependency lock hash, resolved dependency versions, fixtures, exclusions, raw/gzip file sizes and actual Resource Timing transfers. These are reproducible fixtures, not latency or production-site benchmarks. No React/framework externals are removed from the application totals. Next shared JS is the intersection of actually fetched URLs across the three fresh routes; route-specific JS is listed separately. Server build output is excluded from client bytes.
12
+
13
+ Raw minified JS/CSS is reported per actual fetched file and by fixture. Gzip level 9 is reported **per file**, never summed and advertised as an application gzip total. Network transfer, encoded body and decoded body measurements are kept separately, including document/RSC resources. CSS/font requests, archive size and library dependency-closure installed file bytes are separate categories. Install counts exclude Next/Vite/development tooling and record React peers separately. Optional Three.js, Lenis and RHF are absent from core fixtures.
14
+
15
+ ## Selective CSS
16
+
17
+ The 13 October Vite comparison used identical React 19.3.0 journeys against 0.4.0 and the 0.5 candidate. Raw fetched CSS was 34,642 bytes for the old aggregate, 45,881 bytes for the isolated aggregate, and 14,368 / 22,985 / 38,897 bytes for selective Button / settings / table imports. The isolated aggregate grows by 11,239 bytes because it supplies owned defaults, accessibility rules and prefixed hooks. Selective Button CSS saves about 59% against the old aggregate and 69% against the new aggregate; larger compositions save less. Raw application JS grew roughly 2.9–3.7 KB across Vite/Next fixtures for shared dismissal, positioning and styling support. These are whole application totals, not library-only costs. All system-font fixtures made zero font requests. Exact reports and the reviewed gate baseline record tools, dependency versions and per-file compression; these observations are not immutable promises.
18
+
19
+ `scoped.css` remains the convenient isolated aggregate; existing `styles.css` remains compatibility mode. Optional family entries are:
20
+
21
+ | Entry | Controls/patterns |
22
+ | ---------------- | ------------------------------------------------------------------------------------------------------- |
23
+ | actions.css | Button/IconButton/DepthButton, SegmentedControl, Magnetic, ThemeToggle; required loading Spinner styles |
24
+ | forms.css | Field/TextField/TextArea/SelectField, Checkbox/RadioGroup/Switch/Slider |
25
+ | navigation.css | Tabs, Breadcrumbs, Pagination, Stepper |
26
+ | data-display.css | Card/Avatar/Badge/Tag/Stat, DataTable/Timeline and value-display patterns |
27
+ | feedback.css | Alert, ToastProvider, ProgressBar, Spinner, Skeleton, EmptyState |
28
+ | overlays.css | Tooltip, Popover, DropdownMenu, Dialog/drawers |
29
+ | effects.css | TiltCard/FlipCard/Marquee and CSS effects |
30
+
31
+ Every entry includes the canonical tokens, owned base/accessibility defaults and shared UI styles. Import all families used by a composition; a table page with filters/buttons/pagination needs forms/actions/data-display/navigation. Family imports do not include fonts, page layout or the legacy reset. Shared CSS is resolved/deduplicated by the tested bundlers; import order remains tokens/base before components, application overrides after. Core JS remains named public imports, not unsupported deep paths. Browser fixtures verify selected-family production styling as well as request sizes; size alone is not a styling check.
32
+
33
+ ## Fonts and installed dependencies
34
+
35
+ System typography is the isolated default; no Fontsource CSS import is needed. Branded typography is explicit: import exactly one of `fonts-latin.css` (relative packaged assets) or `fonts.css` (existing broader Fontsource coverage). Font binaries/licenses and existing broader coverage are retained. Importing both repeats font-face declarations unnecessarily. Custom fonts can override the three documented prefixed font tokens.
36
+
37
+ The 13 October initial fresh install used Fontsource 5.3.0 packages (approximately 0.93 MB of installed files together), while Lucide 1.56.0 occupied approximately 35.8 MB of installed files. File sizes are logical byte sums, not filesystem allocation. Those values are resolved-install observations, not browser bytes or immutable promises for caret ranges. Exact versions/bytes are in the fixture report. Selected icon code is tree-shaken; compare the plain/icon Button fixture and separate install-tree cost before deciding to replace Lucide. Contour retains Lucide, its license and API; no icons were copied to advertise fewer dependencies.
38
+
39
+ Font packaging was evaluated against vendoring broader assets or creating another optional package. This release **retains** the three runtime Fontsource dependencies: they keep existing `fonts.css` imports resolvable and its current subset/license behavior intact. Moving them to devDependencies alone would break consumers. Vendoring would move cost into the archive and add coverage/license maintenance; a separate package would require a versioned migration. There is no mandatory-font-install reduction claim in 0.5. Optional browser loading is already verified; revisit installation packaging only with a separately reviewed migration.
40
+
41
+ Three.js/Lenis stay optional peers behind their entries. Core imports do not install/load them. Existing lazy Next scene and static-fallback guidance remain tested; downloading 3D after opting in is a separate journey, not an omitted core-byte claim.
42
+
43
+ ## Regression policy
44
+
45
+ `scripts/size-baseline.json` records the actual measured baseline and budgets. The gate allows 10% plus 1,024 bytes on raw fetched JS/CSS totals for deliberate review and chunk/minifier drift, and requires zero font requests in system-font fixtures. Update a baseline only after reviewing a real report and explaining intentional growth; do not automatically ratchet it to pass. Source/package/dependency changes can alter results even with the same components. Release verification and CI use the same packed archive and run this gate. The library-only samples from earlier research are not substituted for these application measurements.
package/STYLING.md ADDED
@@ -0,0 +1,68 @@
1
+ # Styling and embedding
2
+
3
+ 0.5 adds an isolated CSS path. Existing `styles.css`, `tokens.css`, `reset.css` and generic component classes remain compatibility mode; importing them still exposes global rules. They are not silently converted into isolated CSS. Choose one style path per component tree.
4
+
5
+ ## First use with system fonts
6
+
7
+ ```tsx
8
+ import { Button } from "@abuzareal/contour-ui";
9
+ import "@abuzareal/contour-ui/scoped.css";
10
+ export function FirstControl() {
11
+ return (
12
+ <div className="contour-root">
13
+ <Button variant="accent">Continue</Button>
14
+ </div>
15
+ );
16
+ }
17
+ ```
18
+
19
+ No fonts or reset import is needed. `scoped.css` defines only prefixed component hooks/tokens and scope defaults, not page width, section offsets or a site shell. `minimal-reset.css` is optional global box-sizing/body margin/form-font normalization. The original `reset.css` remains the legacy reset/layout compatibility path. Applications own section scroll margins, page-width/site-shell/split-layout/section-intro recipes and their typography/layout. Their old rules remain available through the original styles/reset imports.
20
+
21
+ Import foreign CSS first, isolated Contour styles next, and deliberate application overrides last. Components emit `contour-*` hooks **and** their old classes. Isolated CSS targets only prefixed hooks; old generic `.button/.card/.tabs/.menu/.mono` rules are not defined by it. A host rule targeting a generic compatibility class on the same Contour element can still compete; put Contour CSS after it or narrow that host rule. This is CSS isolation by names and scopes, not Shadow DOM, arbitrary hostile-selector immunity or a reset of the entire host application.
22
+
23
+ ## Themes without document ownership
24
+
25
+ ```tsx
26
+ import { useState } from "react";
27
+ import { ThemeScope, ThemeToggle, Button } from "@abuzareal/contour-ui";
28
+ export function EmbeddedControls() {
29
+ const [theme, setTheme] = useState<"light" | "dark">("light");
30
+ return (
31
+ <ThemeScope theme={theme} onThemeChange={setTheme}>
32
+ <ThemeToggle animate={false} />
33
+ <Button>Scoped action</Button>
34
+ <ThemeScope theme="dark">
35
+ <Button>Dark surface</Button>
36
+ </ThemeScope>
37
+ </ThemeScope>
38
+ );
39
+ }
40
+ ```
41
+
42
+ `ThemeScope` is a controlled div with `data-contour-theme`, a native ref and native attributes/children. Adjacent/nested light and dark scopes inherit typography/consumer overrides but set semantic color values and color-scheme locally. Omitting `onThemeChange` makes scoped ThemeToggle controls disabled/read-only. Scoped useTheme/ThemeToggle updates only the caller's state; it does not write storage, metadata or the document root and uses an instant transition. Caller-owned persistence can use an application-specific key; do not share a document key across embedded apps accidentally. Plain `[data-contour-theme]` controls CSS only; use ThemeScope for scoped React theme controls.
43
+
44
+ Without ThemeScope, the original useTheme remains document-owned: root `data-theme`, theme-color metadata, `aa-theme` storage, same-window theme events, cross-tab storage, system preference fallback and optional document view transition. `themeBootstrapScript` retains that legacy pre-paint behavior. The isolated entries omit document-wide view-transition animation rules; an application choosing animated root transitions must provide their CSS (the legacy aggregate supplies its original rules). Embedding does not require invoking it. An application must explicitly choose root ownership; a scope does not sandbox independent root hooks outside it. Storage failures remain tolerated.
45
+
46
+ Default body/modal portals carry the trigger's variables, direction and font across the DOM move, including theme changes. Native modal portals stay in the browser's top layer. See [overlay container rules](OVERLAYS.md).
47
+
48
+ ## Public tokens and precedence
49
+
50
+ [The shipped token schema](dist/tokens.json) / `@abuzareal/contour-ui/tokens.json` records each canonical name, semantic role, category, light/dark values, legacy alias and compatibility status. `tokens/schema.json` is the authored source; `npm run build:styles` generates isolated tokens and component CSS. Generated outputs are checked for drift. Schema categories cover color/status, typography, spacing, shape, motion, elevation and layer ordering. Manual semantic review remains necessary; generation does not prove contrast or usability.
51
+
52
+ Old `--bg/--fg/--acid/--surface/--font-sans/...` names map to `--contour-bg/--contour-fg/--contour-acid/--contour-surface/--contour-font-sans/...`. The old stylesheet keeps the old values/overrides; isolated styles use the new names. Migrate overrides alongside imports. Do not import both aggregates as a migration shortcut. Known old classes retain prefixed aliases; unknown caller classes are preserved. Exact whole-class-attribute comparisons/snapshots need updating for the added aliases; use class membership selectors. `buttonClassName` and `ccx` include both hook forms; `cx` continues to combine arbitrary strings/IDs without prefixing.
53
+
54
+ Defaults use low-specificity token selectors. Consumer properties on a theme scope override inherited defaults. A nested explicit theme resets themeable semantic colors; set overrides on that nested scope if wanted there. Non-theme constants such as fonts/spacing inherit. Put root/scoped overrides after Contour imports; inline custom properties on an explicit scope win at that element. Customize documented part classes/props; state/ARIA/native behavior remains component-owned. Geometry variables such as progress and tab-indicator coordinates are internal, not design tokens.
55
+
56
+ Header/focus-band/about/experience/expertise/work/footer values are legacy application/product tokens and remain only in the legacy token file. The isolated schema excludes them. Marquee uses reusable inverse-surface/accent tokens rather than inheriting a page focus-band recipe. Avatar marks, banner accents, depth bases and feedback outlines also inherit scoped theme roles. The isolated inverse accent uses the existing banner olive in dark surfaces; the legacy page-specific ticker color remains unchanged. Porting a consumer means moving those values and page/layout recipes into that application, not removing its styling from the library without a migration.
57
+
58
+ ## Fonts, density and localization
59
+
60
+ System fonts are the isolated default. Import **one** of `fonts-latin.css` or `fonts.css` after the styles to opt into DM Sans, Space Grotesk and DM Mono. The Latin entry uses packaged relative assets; the original entry keeps Fontsource's broader available character coverage. Neither promises every writing system; fallback fonts still matter. Custom fonts override the three `--contour-font-*` properties on the relevant scope. Fontsource packages still install in this release; optional browser loading is different from optional installation.
61
+
62
+ Comfortable sizes remain the current contract. Compact density is deferred: no concrete application target/long-content requirement justified another size matrix, and compressed targets could reduce usability. Button sizes remain available; do not invent a global compact mode from demo CSS.
63
+
64
+ Caller content/labels remain application-owned. New local overrides cover Button loadingLabel; Alert dismissLabel; Breadcrumbs label; Tag removeLabel; Avatar statusLabel/initials; ThemeToggle darkLabel/lightTitle/darkTitle; ToastProvider label/dismissLabel; Pagination previousLabel/nextLabel/pageLabel/formatPage; Switch requiredMessage; CountUp formatValue; DataTable column compare. Existing Dialog closeButtonProps aria-label, Spinner/Stepper labels and Slider format remain usable. Native validation messages follow the browser locale. Field-counter defaults, formatIndex and default initials/typeahead/binary table sorting are not a locale engine; use caller formatters/comparators where provided and visible caller instructions when defaults are unsuitable. No global translation or date library is installed.
65
+
66
+ Promoted forms, horizontal Tabs/SegmentedControl, pagination icons, menu placement and text alignment are checked in RTL; the keyboard helper accepts an optional direction while retaining LTR defaults. CSS uses logical inline spacing/borders where applicable. Coordinate-driven tab indicators remain physical measurements. No universal RTL promise is made for experimental 3D/effects or application scenes.
67
+
68
+ Forced-color rules preserve focus, selected control outlines, radio marks, invalid field borders and floating panel boundaries. Production emulation checks both themes and focus; they do not certify every Windows theme or manual screen-reader workflow. Branded paper/ink/lime colors, outlines, typography and depth are retained by compatibility mode and by the isolated branded-font recipe; system-font first use is an intentional option.
@@ -0,0 +1,7 @@
1
+ @import "./scoped/tokens.css";
2
+ @import "./scoped/base.css";
3
+ @import "./scoped/utilities.css";
4
+ @import "./scoped/ui.css";
5
+ @import "./scoped/kit/actions.css";
6
+ @import "./scoped/theme-toggle.css";
7
+ @import "./scoped/spinner.css";
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Hero sculpture: three chrome/lime links that follow the pointer and drift while motion is on. */
4
5
  import { useRef } from "react";
5
6
  import * as THREE from "three";
@@ -52,5 +53,5 @@ export default function Sculpture({ motionEnabled }) {
52
53
  setup: setupSculpture,
53
54
  cameraDistance,
54
55
  });
55
- return (_jsx("div", { ref: hostRef, className: "sculpture-canvas", "data-state": "loading" }));
56
+ return (_jsx("div", { ref: hostRef, className: ccx("sculpture-canvas"), "data-state": "loading" }));
56
57
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Lays out a set of 3D objects in a responsive grid; each turns slowly and the set leans toward the pointer. */
4
5
  import { useRef } from "react";
5
6
  import * as THREE from "three";
@@ -65,5 +66,5 @@ export default function ObjectStudy({ label, motionEnabled, study, }) {
65
66
  };
66
67
  },
67
68
  });
68
- return (_jsx("div", { ref: hostRef, className: "stage-canvas", "data-state": "loading", role: "img", "aria-label": label }));
69
+ return (_jsx("div", { ref: hostRef, className: ccx("stage-canvas"), "data-state": "loading", role: "img", "aria-label": label }));
69
70
  }
@@ -6,8 +6,9 @@ export type AlertProps = {
6
6
  title: string;
7
7
  children?: ReactNode;
8
8
  action?: ReactNode;
9
+ dismissLabel?: string;
9
10
  onDismiss?: () => void;
10
11
  /** "banner" spans its container edge to edge on one line. */
11
12
  layout?: "inline" | "banner";
12
13
  };
13
- export default function Alert({ tone, title, children, action, onDismiss, layout, }: AlertProps): import("react").JSX.Element;
14
+ export default function Alert({ tone, title, children, action, onDismiss, dismissLabel, layout, }: AlertProps): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Asterisk, CircleCheck, CircleX, Info, TriangleAlert, X, } from "lucide-react";
4
- import { cx } from "../../lib/classNames.js";
4
+ import { ccx } from "../../lib/classNames.js";
5
5
  const toneIcons = {
6
6
  neutral: _jsx(Asterisk, { size: 18 }),
7
7
  accent: _jsx(Asterisk, { size: 18 }),
@@ -10,7 +10,7 @@ const toneIcons = {
10
10
  warning: _jsx(TriangleAlert, { size: 18 }),
11
11
  danger: _jsx(CircleX, { size: 18 }),
12
12
  };
13
- export default function Alert({ tone = "neutral", title, children, action, onDismiss, layout = "inline", }) {
13
+ export default function Alert({ tone = "neutral", title, children, action, onDismiss, dismissLabel = "Dismiss", layout = "inline", }) {
14
14
  const urgent = tone === "warning" || tone === "danger";
15
- return (_jsxs("div", { role: urgent ? "alert" : "status", className: cx("alert", `alert-${tone}`, `alert-${layout}`), children: [_jsx("span", { className: "alert-icon", "aria-hidden": "true", children: toneIcons[tone] }), _jsxs("div", { className: "alert-content", children: [_jsx("strong", { className: "alert-title", children: title }), children && _jsx("div", { className: "alert-body", children: children })] }), action && _jsx("div", { className: "alert-action", children: action }), onDismiss && (_jsx("button", { type: "button", className: "alert-dismiss", "aria-label": "Dismiss", onClick: onDismiss, children: _jsx(X, { size: 16 }) }))] }));
15
+ return (_jsxs("div", { role: urgent ? "alert" : "status", className: ccx("alert", `alert-${tone}`, `alert-${layout}`), children: [_jsx("span", { className: ccx("alert-icon"), "aria-hidden": "true", children: toneIcons[tone] }), _jsxs("div", { className: ccx("alert-content"), children: [_jsx("strong", { className: ccx("alert-title"), children: title }), children && _jsx("div", { className: ccx("alert-body"), children: children })] }), action && _jsx("div", { className: ccx("alert-action"), children: action }), onDismiss && (_jsx("button", { type: "button", className: ccx("alert-dismiss"), "aria-label": dismissLabel, onClick: onDismiss, children: _jsx(X, { size: 16 }) }))] }));
16
16
  }
@@ -1,7 +1,9 @@
1
1
  export type AvatarProps = {
2
2
  name: string;
3
+ initials?: string;
3
4
  src?: string;
4
5
  size?: "sm" | "md" | "lg" | "xl";
6
+ statusLabel?: string;
5
7
  status?: "online" | "away";
6
8
  };
7
- export default function Avatar({ name, src, size, status, }: AvatarProps): import("react").JSX.Element;
9
+ export default function Avatar({ name, initials, src, size, status, statusLabel, }: AvatarProps): import("react").JSX.Element;
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /** Round avatar showing a photo or Space Grotesk initials, with an optional presence dot. */
3
- import { cx } from "../../lib/classNames.js";
3
+ import { ccx } from "../../lib/classNames.js";
4
4
  const initialsOf = (name) => name
5
5
  .split(" ")
6
6
  .map((part) => part[0])
7
7
  .slice(0, 2)
8
8
  .join("")
9
9
  .toUpperCase();
10
- export default function Avatar({ name, src, size = "md", status, }) {
11
- return (_jsxs("span", { className: cx("avatar", `avatar-${size}`), children: [src ? (_jsx("img", { src: src, alt: name, loading: "lazy", decoding: "async" })) : (_jsx("span", { role: "img", "aria-label": name, className: "avatar-initials", children: initialsOf(name) })), status && (_jsx("span", { className: cx("avatar-status", `avatar-status-${status}`), role: "img", "aria-label": status === "online" ? "Online" : "Away" }))] }));
10
+ export default function Avatar({ name, initials, src, size = "md", status, statusLabel, }) {
11
+ return (_jsxs("span", { className: ccx("avatar", `avatar-${size}`), children: [src ? (_jsx("img", { src: src, alt: name, loading: "lazy", decoding: "async" })) : (_jsx("span", { role: "img", "aria-label": name, className: ccx("avatar-initials"), children: initials ?? initialsOf(name) })), status && (_jsx("span", { className: ccx("avatar-status", `avatar-status-${status}`), role: "img", "aria-label": statusLabel ?? (status === "online" ? "Online" : "Away") }))] }));
12
12
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { cx } from "../../lib/classNames.js";
2
+ import { ccx } from "../../lib/classNames.js";
3
3
  export default function Badge({ children, tone = "accent", dot = false, icon, }) {
4
- return (_jsxs("span", { className: cx("badge mono", `badge-${tone}`), children: [dot && _jsx("span", { className: "badge-dot", "aria-hidden": "true" }), icon, children] }));
4
+ return (_jsxs("span", { className: ccx("badge mono", `badge-${tone}`), children: [dot && _jsx("span", { className: ccx("badge-dot"), "aria-hidden": "true" }), icon, children] }));
5
5
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  import { safeHref } from "../../lib/safeHref.js";
4
5
  export default function BrandMark({ label, href = "#hero", onClick, }) {
5
- return (_jsxs("a", { href: safeHref(href), className: "brand", "aria-label": label, onClick: onClick, children: ["aa", _jsx("span", { children: "." })] }));
6
+ return (_jsxs("a", { href: safeHref(href), className: ccx("brand"), "aria-label": label, onClick: onClick, children: ["aa", _jsx("span", { children: "." })] }));
6
7
  }
@@ -5,6 +5,7 @@ type Crumb = {
5
5
  };
6
6
  export type BreadcrumbsProps = {
7
7
  items: Crumb[];
8
+ label?: string;
8
9
  };
9
- export default function Breadcrumbs({ items }: BreadcrumbsProps): import("react").JSX.Element;
10
+ export default function Breadcrumbs({ items, label, }: BreadcrumbsProps): import("react").JSX.Element;
10
11
  export {};
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ccx } from "../../lib/classNames.js";
2
3
  import { safeHref } from "../../lib/safeHref.js";
3
- export default function Breadcrumbs({ items }) {
4
- return (_jsx("nav", { "aria-label": "Breadcrumb", className: "breadcrumbs mono", children: _jsx("ol", { children: items.map((item, index) => {
4
+ export default function Breadcrumbs({ items, label = "Breadcrumb", }) {
5
+ return (_jsx("nav", { "aria-label": label, className: ccx("breadcrumbs mono"), children: _jsx("ol", { children: items.map((item, index) => {
5
6
  const current = index === items.length - 1;
6
7
  return (_jsx("li", { children: current || !item.href ? (_jsx("span", { "aria-current": current ? "page" : undefined, children: item.label })) : (_jsx("a", { href: safeHref(item.href), children: item.label })) }, item.label));
7
8
  }) }) }));
@@ -8,6 +8,7 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
8
8
  icon?: ReactNode;
9
9
  leadingIcon?: ReactNode;
10
10
  loading?: boolean;
11
+ loadingLabel?: string;
11
12
  };
12
13
  declare const Button: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
13
14
  variant?: ButtonVariant;
@@ -16,5 +17,6 @@ declare const Button: import("react").ForwardRefExoticComponent<ButtonHTMLAttrib
16
17
  icon?: ReactNode;
17
18
  leadingIcon?: ReactNode;
18
19
  loading?: boolean;
20
+ loadingLabel?: string;
19
21
  } & import("react").RefAttributes<HTMLButtonElement>>;
20
22
  export default Button;
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Pill button in four emphasis levels and three sizes, with loading and icon slots. */
4
5
  import { forwardRef } from "react";
5
6
  import { buttonClassName, } from "../../lib/classNames.js";
6
7
  import Spinner from "./Spinner.js";
7
- const Button = forwardRef(function Button({ variant, size, icon, leadingIcon, loading = false, disabled, className, children, type = "button", ...rest }, ref) {
8
- return (_jsxs("button", { ref: ref, ...rest, type: type, className: buttonClassName(variant, size, className), disabled: disabled || loading, "aria-busy": loading || undefined, children: [loading ? _jsx(Spinner, { size: "sm", label: "Loading" }) : leadingIcon, _jsx("span", { children: children }), icon && _jsx("span", { className: "button-icon", children: icon })] }));
8
+ const Button = forwardRef(function Button({ variant, size, icon, leadingIcon, loading = false, loadingLabel = "Loading", disabled, className, children, type = "button", ...rest }, ref) {
9
+ return (_jsxs("button", { ref: ref, ...rest, type: type, className: buttonClassName(variant, size, className), disabled: disabled || loading, "aria-busy": loading || undefined, children: [loading ? _jsx(Spinner, { size: "sm", label: loadingLabel }) : leadingIcon, _jsx("span", { children: children }), icon && _jsx("span", { className: ccx("button-icon"), children: icon })] }));
9
10
  });
10
11
  export default Button;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { cx } from "../../lib/classNames.js";
2
+ import { ccx } from "../../lib/classNames.js";
3
3
  export default function Card({ title, children, eyebrow, footer, icon, tone = "surface", interactive = false, }) {
4
- return (_jsxs("article", { className: cx("card", `card-${tone}`, interactive && "card-interactive"), children: [(eyebrow || icon) && (_jsxs("div", { className: "card-top mono", children: [eyebrow && _jsx("span", { children: eyebrow }), icon && _jsx("span", { className: "card-icon", children: icon })] })), _jsx("h3", { className: "card-title", children: title }), children && _jsx("div", { className: "card-body", children: children }), footer && _jsx("div", { className: "card-footer", children: footer })] }));
4
+ return (_jsxs("article", { className: ccx("card", `card-${tone}`, interactive && "card-interactive"), children: [(eyebrow || icon) && (_jsxs("div", { className: ccx("card-top mono"), children: [eyebrow && _jsx("span", { children: eyebrow }), icon && _jsx("span", { className: ccx("card-icon"), children: icon })] })), _jsx("h3", { className: ccx("card-title"), children: title }), children && _jsx("div", { className: ccx("card-body"), children: children }), footer && _jsx("div", { className: ccx("card-footer"), children: footer })] }));
5
5
  }
@@ -5,7 +5,7 @@ import { forwardRef, useEffect, useId } from "react";
5
5
  import { Check, Minus } from "lucide-react";
6
6
  import useControlRef from "../../hooks/useControlRef.js";
7
7
  import useFormReset from "../../hooks/useFormReset.js";
8
- import { cx } from "../../lib/classNames.js";
8
+ import { ccx } from "../../lib/classNames.js";
9
9
  const Checkbox = forwardRef(function Checkbox({ label, description, error, indeterminate = false, className, id: providedId, ...rest }, forwardedRef) {
10
10
  const generatedId = useId();
11
11
  const id = providedId ?? generatedId;
@@ -21,12 +21,12 @@ const Checkbox = forwardRef(function Checkbox({ label, description, error, indet
21
21
  ref.current.checked = rest.checked;
22
22
  ref.current.indeterminate = indeterminate;
23
23
  });
24
- return (_jsxs("div", { className: cx("choice", error && "choice-invalid", className), children: [_jsxs("span", { className: "choice-control", children: [_jsx("input", { ...rest, ref: attach, id: id, type: "checkbox", className: "choice-input", "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [
24
+ return (_jsxs("div", { className: ccx("choice", error && "choice-invalid", className), children: [_jsxs("span", { className: ccx("choice-control"), children: [_jsx("input", { ...rest, ref: attach, id: id, type: "checkbox", className: ccx("choice-input"), "aria-invalid": error ? true : rest["aria-invalid"], "aria-describedby": [
25
25
  rest["aria-describedby"],
26
26
  description && `${id}-description`,
27
27
  error && `${id}-error`,
28
28
  ]
29
29
  .filter(Boolean)
30
- .join(" ") || undefined }), _jsx("span", { className: "choice-box", "aria-hidden": "true", children: indeterminate ? (_jsx(Minus, { size: 13, strokeWidth: 3 })) : (_jsx(Check, { size: 13, strokeWidth: 3 })) })] }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description })), error && (_jsx("span", { id: `${id}-error`, className: "choice-error mono", children: error }))] })] }));
30
+ .join(" ") || undefined }), _jsx("span", { className: ccx("choice-box"), "aria-hidden": "true", children: indeterminate ? (_jsx(Minus, { size: 13, strokeWidth: 3 })) : (_jsx(Check, { size: 13, strokeWidth: 3 })) })] }), _jsxs("label", { htmlFor: id, className: ccx("choice-text"), children: [_jsx("span", { className: ccx("choice-label"), children: label }), description && (_jsx("span", { id: `${id}-description`, className: ccx("choice-description"), children: description })), error && (_jsx("span", { id: `${id}-error`, className: ccx("choice-error mono"), children: error }))] })] }));
31
31
  });
32
32
  export default Checkbox;
@@ -5,5 +5,6 @@ export type CountUpProps = {
5
5
  /** Pads to this many digits, e.g. 2 renders 05. */
6
6
  digits?: number;
7
7
  suffix?: string;
8
+ formatValue?: (value: number) => string;
8
9
  };
9
- export default function CountUp({ to, duration, digits, suffix, }: CountUpProps): import("react").JSX.Element;
10
+ export default function CountUp({ to, duration, digits, suffix, formatValue, }: CountUpProps): import("react").JSX.Element;
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Number that counts up the first time it scrolls into view; screen readers get the final value. */
4
5
  import { useEffect, useRef, useState } from "react";
5
6
  import { motionDisabledFor } from "../../lib/motion.js";
6
7
  const easeOutCubic = (progress) => 1 - (1 - progress) ** 3;
7
- export default function CountUp({ to, duration = 1400, digits = 0, suffix = "", }) {
8
+ export default function CountUp({ to, duration = 1400, digits = 0, suffix = "", formatValue, }) {
8
9
  const ref = useRef(null);
9
10
  const [current, setCurrent] = useState(0);
10
11
  useEffect(() => {
@@ -46,6 +47,8 @@ export default function CountUp({ to, duration = 1400, digits = 0, suffix = "",
46
47
  cancelAnimationFrame(frame);
47
48
  };
48
49
  }, [to, duration]);
49
- const format = (value) => `${String(value).padStart(digits, "0")}${suffix}`;
50
- return (_jsxs("span", { ref: ref, className: "count-up", children: [_jsx("span", { "aria-hidden": "true", children: format(current) }), _jsx("span", { className: "visually-hidden", children: format(to) })] }));
50
+ const format = (value) => formatValue
51
+ ? formatValue(value)
52
+ : `${String(value).padStart(digits, "0")}${suffix}`;
53
+ return (_jsxs("span", { ref: ref, className: ccx("count-up"), children: [_jsx("span", { "aria-hidden": "true", children: format(current) }), _jsx("span", { className: ccx("visually-hidden"), children: format(to) })] }));
51
54
  }
@@ -5,6 +5,7 @@ export type TableColumn<Row> = {
5
5
  header: string;
6
6
  align?: "start" | "end";
7
7
  sortable?: boolean;
8
+ compare?: (a: Row, b: Row) => number;
8
9
  render?: (row: Row) => ReactNode;
9
10
  };
10
11
  export type DataTableProps<Row> = {
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ccx } from "../../lib/classNames.js";
3
4
  /** Editorial data table with mono headers, optional column sorting, and horizontal scroll on small screens. */
4
5
  import { useMemo, useState } from "react";
5
6
  import { ArrowDown, ArrowUp } from "lucide-react";
@@ -8,18 +9,21 @@ export default function DataTable({ caption, columns, rows, rowKey, }) {
8
9
  const sortedRows = useMemo(() => {
9
10
  if (!sort)
10
11
  return rows;
12
+ const compare = columns.find((column) => column.key === sort.key)?.compare;
11
13
  const factor = sort.direction === "ascending" ? 1 : -1;
12
- return [...rows].sort((a, b) => a[sort.key] > b[sort.key]
13
- ? factor
14
- : a[sort.key] < b[sort.key]
15
- ? -factor
16
- : 0);
17
- }, [rows, sort]);
14
+ return [...rows].sort((a, b) => compare
15
+ ? factor * compare(a, b)
16
+ : a[sort.key] > b[sort.key]
17
+ ? factor
18
+ : a[sort.key] < b[sort.key]
19
+ ? -factor
20
+ : 0);
21
+ }, [rows, sort, columns]);
18
22
  const toggleSort = (key) => setSort((current) => current?.key === key && current.direction === "ascending"
19
23
  ? { key, direction: "descending" }
20
24
  : { key, direction: "ascending" });
21
- return (_jsx("div", { className: "table-scroll", tabIndex: 0, role: "region", "aria-label": caption, children: _jsxs("table", { className: "data-table", children: [_jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { className: "mono", children: _jsx("tr", { children: columns.map((column) => {
25
+ return (_jsx("div", { className: ccx("table-scroll"), tabIndex: 0, role: "region", "aria-label": caption, children: _jsxs("table", { className: ccx("data-table"), children: [_jsx("caption", { className: ccx("visually-hidden"), children: caption }), _jsx("thead", { className: ccx("mono"), children: _jsx("tr", { children: columns.map((column) => {
22
26
  const direction = sort?.key === column.key ? sort.direction : undefined;
23
- return (_jsx("th", { scope: "col", "aria-sort": column.sortable ? (direction ?? "none") : undefined, "data-align": column.align, children: column.sortable ? (_jsxs("button", { type: "button", onClick: () => toggleSort(column.key), children: [column.header, direction === "descending" ? (_jsx(ArrowDown, { size: 12, "aria-hidden": "true" })) : (_jsx(ArrowUp, { size: 12, "aria-hidden": "true", className: direction ? undefined : "data-table-idle" }))] })) : (column.header) }, column.key));
27
+ return (_jsx("th", { scope: "col", "aria-sort": column.sortable ? (direction ?? "none") : undefined, "data-align": column.align, children: column.sortable ? (_jsxs("button", { type: "button", onClick: () => toggleSort(column.key), children: [column.header, direction === "descending" ? (_jsx(ArrowDown, { size: 12, "aria-hidden": "true" })) : (_jsx(ArrowUp, { size: 12, "aria-hidden": "true", className: ccx(direction ? undefined : "data-table-idle") }))] })) : (column.header) }, column.key));
24
28
  }) }) }), _jsx("tbody", { children: sortedRows.map((row) => (_jsx("tr", { children: columns.map((column) => (_jsx("td", { "data-align": column.align, children: column.render ? column.render(row) : row[column.key] }, column.key))) }, String(row[rowKey])))) })] }) }));
25
29
  }