@stonedogcode/style 0.9.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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +699 -0
  4. package/package.json +95 -0
  5. package/src/components/DictationControls.tsx +141 -0
  6. package/src/components/DictationPrompt.tsx +78 -0
  7. package/src/components/StyledBox.tsx +174 -0
  8. package/src/components/StyledButton.tsx +144 -0
  9. package/src/components/StyledCollapsible.tsx +127 -0
  10. package/src/components/StyledDefinitionList.tsx +134 -0
  11. package/src/components/StyledFieldset.tsx +157 -0
  12. package/src/components/StyledFlex.tsx +13 -0
  13. package/src/components/StyledFooter.tsx +399 -0
  14. package/src/components/StyledFormLabel.tsx +141 -0
  15. package/src/components/StyledGrid.tsx +109 -0
  16. package/src/components/StyledGridItem.tsx +19 -0
  17. package/src/components/StyledHStack.tsx +145 -0
  18. package/src/components/StyledHeading.tsx +79 -0
  19. package/src/components/StyledHrRule.tsx +33 -0
  20. package/src/components/StyledIcon.tsx +172 -0
  21. package/src/components/StyledIconButton.tsx +135 -0
  22. package/src/components/StyledInputBool.tsx +81 -0
  23. package/src/components/StyledInputRadio.tsx +141 -0
  24. package/src/components/StyledInputSelect.tsx +115 -0
  25. package/src/components/StyledInputSlider.tsx +83 -0
  26. package/src/components/StyledInputText.tsx +146 -0
  27. package/src/components/StyledInputTextArea.tsx +119 -0
  28. package/src/components/StyledInputToggle.tsx +224 -0
  29. package/src/components/StyledList.tsx +188 -0
  30. package/src/components/StyledScrollbar.tsx +53 -0
  31. package/src/components/StyledSearch.tsx +78 -0
  32. package/src/components/StyledSeparator.tsx +38 -0
  33. package/src/components/StyledSidebar.tsx +555 -0
  34. package/src/components/StyledSimpleGrid.tsx +99 -0
  35. package/src/components/StyledSparkLine.tsx +119 -0
  36. package/src/components/StyledSpinner.tsx +91 -0
  37. package/src/components/StyledStack.tsx +62 -0
  38. package/src/components/StyledText.tsx +99 -0
  39. package/src/components/StyledTooltip.tsx +398 -0
  40. package/src/components/StyledVStack.tsx +143 -0
  41. package/src/components/TitleLogo.tsx +223 -0
  42. package/src/components/create-icon.tsx +66 -0
  43. package/src/components/create-intent-button.tsx +134 -0
  44. package/src/components/dictation.ts +71 -0
  45. package/src/components/intent-buttons.ts +154 -0
  46. package/src/config/can-hover.ts +75 -0
  47. package/src/config/density.ts +138 -0
  48. package/src/config/font-size.ts +113 -0
  49. package/src/config/intent-icons.tsx +116 -0
  50. package/src/config/logger.ts +60 -0
  51. package/src/config/style-config.tsx +263 -0
  52. package/src/config/types.ts +137 -0
  53. package/src/index.ts +259 -0
  54. package/src/preset/index.ts +243 -0
  55. package/src/preset/recipes/arrows.ts +29 -0
  56. package/src/preset/recipes/box.ts +122 -0
  57. package/src/preset/recipes/button.ts +161 -0
  58. package/src/preset/recipes/dl-list.ts +109 -0
  59. package/src/preset/recipes/drawer.ts +125 -0
  60. package/src/preset/recipes/form.ts +95 -0
  61. package/src/preset/recipes/icon-button.ts +161 -0
  62. package/src/preset/recipes/icon.ts +34 -0
  63. package/src/preset/recipes/input-bool.ts +184 -0
  64. package/src/preset/recipes/input-dropdown.ts +93 -0
  65. package/src/preset/recipes/input-radio.ts +158 -0
  66. package/src/preset/recipes/input-surface.ts +152 -0
  67. package/src/preset/recipes/input-text.ts +17 -0
  68. package/src/preset/recipes/list.ts +196 -0
  69. package/src/preset/recipes/menu.ts +28 -0
  70. package/src/preset/recipes/separator.ts +89 -0
  71. package/src/preset/recipes/stack.ts +89 -0
  72. package/src/preset/recipes/striped.ts +34 -0
  73. package/src/preset/recipes/text.ts +41 -0
  74. package/src/preset/recipes/tooltip.ts +77 -0
  75. package/src/preset/semantic-variables.ts +283 -0
package/README.md ADDED
@@ -0,0 +1,699 @@
1
+ # @stonedogcode/style
2
+
3
+ A themeable [Panda CSS](https://panda-css.com) design system: a preset of design
4
+ tokens and recipes, plus the React components built on them.
5
+
6
+ Every colour in the system is a token that resolves to a bare CSS custom
7
+ property — `boxBgPrimary` is `var(--hopper-box-primary-bg)` and nothing more.
8
+ Your application defines those properties, from wherever you keep themes, and
9
+ the whole component set re-skins at runtime. No component here knows a colour.
10
+
11
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
12
+
13
+ ## Status
14
+
15
+ Early. The preset is complete (22 recipes, 43 colour tokens); the component set
16
+ is being extracted incrementally and currently covers the layout and typography
17
+ primitives. See [CLAUDE.md](./CLAUDE.md) for the architecture and the
18
+ contribution rules.
19
+
20
+ ## Install
21
+
22
+ **Not published to npm.** Consume it from git — either a plain dependency, or a
23
+ submodule if you want to develop against it:
24
+
25
+ ```bash
26
+ # Option A — git dependency, pinned to a commit
27
+ npm install "git+https://github.com/stonedog-code/stonedog-style.git#<sha>"
28
+
29
+ # Option B — submodule + file: dependency (use this in a monorepo)
30
+ git submodule add git@github.com:stonedog-code/stonedog-style.git packages/stonedog-style
31
+ # then in the consuming app's package.json:
32
+ # "@stonedogcode/style": "file:../../packages/stonedog-style"
33
+ ```
34
+
35
+ Pin to a commit rather than tracking a branch: this package ships source that
36
+ your build parses, so an unpinned bump changes your CSS without changing your
37
+ lockfile in any way you'd notice.
38
+
39
+ Peer dependencies: `react` ≥18, `react-dom` ≥18, `@pandacss/dev` ≥1.9.
40
+
41
+ ## Setup
42
+
43
+ Four steps. **All four are required** — miss step 3 or 4 and the app renders,
44
+ but invisibly or unstyled, with no error anywhere to tell you why.
45
+
46
+ **1 — add the preset to your `panda.config.ts`:**
47
+
48
+ ```ts
49
+ import { defineConfig } from "@pandacss/dev";
50
+ import { stonedogStylePreset } from "@stonedogcode/style/preset";
51
+
52
+ export default defineConfig({
53
+ // Listing `presets` REPLACES Panda's defaults rather than adding to them,
54
+ // so the two base presets must be named explicitly. Without them the recipes
55
+ // lose the tokens they build on, and Panda drops those styles silently.
56
+ presets: [
57
+ "@pandacss/preset-base",
58
+ "@pandacss/preset-panda",
59
+ stonedogStylePreset(),
60
+ ],
61
+ include: [
62
+ "./src/**/*.{ts,tsx}",
63
+ // Panda finds styles by parsing source. A package it never parses
64
+ // contributes no CSS, and its components render unstyled.
65
+ "./node_modules/@stonedogcode/style/src/**/*.tsx",
66
+ ],
67
+ outdir: "styled-system",
68
+ jsxFramework: "react",
69
+ });
70
+ ```
71
+
72
+ **2 — transpile the package.** It ships TypeScript source, not a bundle,
73
+ because Panda extracts styles statically at *your* build. In Next.js:
74
+
75
+ ```js
76
+ // next.config.js
77
+ module.exports = { transpilePackages: ["@stonedogcode/style"] };
78
+ ```
79
+
80
+ **3 — define the custom properties. This is the step that bites.** Every colour
81
+ token reads one, and **a token whose property is undefined renders as nothing** —
82
+ no fallback, no warning, no error. An app that skips this compiles, builds,
83
+ serves, and shows you a blank page.
84
+
85
+ There are **44** of them. Get the list at runtime rather than copying one:
86
+
87
+ ```ts
88
+ import { requiredCssCustomProperties } from "@stonedogcode/style/preset";
89
+
90
+ requiredCssCustomProperties(); // --hopper-* (default)
91
+ requiredCssCustomProperties("optima"); // --optima-*, if you set cssVarPrefix
92
+ ```
93
+
94
+ A complete starter theme — all 44, nothing elided. Dark, and every text/surface
95
+ pair clears WCAG AA (measured: worst 5.17:1, ten of thirteen pairs at AAA), so
96
+ it is a legitimate starting point rather than a placeholder. Replace the values;
97
+ keep every key.
98
+
99
+ ```css
100
+ :root {
101
+ /* Surfaces */
102
+ --hopper-box-main-bg: #0f172a;
103
+ --hopper-box-primary-bg: #1e293b;
104
+ --hopper-box-secondary-bg: #334155;
105
+ --hopper-box-accent-bg: #0b1220;
106
+ --hopper-box-info-bg: #1e3a5f;
107
+
108
+ /* Text on those surfaces */
109
+ --hopper-box-main-text: #f8fafc;
110
+ --hopper-box-primary-text: #f8fafc;
111
+ --hopper-box-secondary-text: #f1f5f9;
112
+ --hopper-box-accent-text: #e2e8f0;
113
+
114
+ /* Text that carries meaning on its own */
115
+ --hopper-text-pop-text: #38bdf8;
116
+ --hopper-text-error-text: #f87171;
117
+ --hopper-text-warning-text: #fbbf24;
118
+
119
+ /* Borders */
120
+ --hopper-box-primary-border: #475569;
121
+ --hopper-box-secondary-border: #64748b;
122
+ --hopper-box-accent-border: #334155;
123
+
124
+ /* Shadows */
125
+ --hopper-shadow-primary-bg: rgb(0 0 0 / 0.4);
126
+ --hopper-shadow-secondary-bg: rgb(0 0 0 / 0.3);
127
+ --hopper-shadow-accent-bg: rgb(0 0 0 / 0.5);
128
+
129
+ /* Buttons */
130
+ --hopper-button-primary-bg: #2563eb;
131
+ --hopper-button-secondary-bg: #475569;
132
+ --hopper-button-accent-bg: #1e293b;
133
+ --hopper-button-primary-hover-bg: #1d4ed8;
134
+ --hopper-button-secondary-hover-bg: #334155;
135
+ --hopper-button-accent-hover-bg: #334155;
136
+ --hopper-button-primary-text: #ffffff;
137
+ --hopper-button-secondary-text: #f8fafc;
138
+ --hopper-button-accent-text: #f8fafc;
139
+ --hopper-button-primary-hover-text: #ffffff;
140
+ --hopper-button-secondary-hover-text: #ffffff;
141
+ --hopper-button-accent-hover-text: #ffffff;
142
+ --hopper-button-plain-bg: transparent;
143
+ --hopper-button-plain-text: #f8fafc;
144
+
145
+ /* Icons */
146
+ --hopper-icon-primary-bg: #94a3b8;
147
+ --hopper-icon-secondary-bg: #64748b;
148
+ --hopper-icon-accent-bg: #cbd5e1;
149
+ --hopper-icon-primary-hover-bg: #cbd5e1;
150
+ --hopper-icon-secondary-hover-bg: #94a3b8;
151
+ --hopper-icon-accent-hover-bg: #e2e8f0;
152
+
153
+ /* Arrows / carets */
154
+ --hopper-arrow-primary-bg: #94a3b8;
155
+ --hopper-arrow-secondary-bg: #64748b;
156
+ --hopper-arrow-accent-bg: #cbd5e1;
157
+ --hopper-arrow-primary-border: #475569;
158
+ --hopper-arrow-secondary-border: #64748b;
159
+ --hopper-arrow-accent-border: #334155;
160
+ }
161
+ ```
162
+
163
+ Guard it with a test rather than trusting a checklist — the failure is invisible,
164
+ so nothing else will tell you:
165
+
166
+ ```ts
167
+ it("defines every property the design system reads", () => {
168
+ const css = readFileSync("src/theme.css", "utf8");
169
+ for (const prop of requiredCssCustomProperties()) {
170
+ expect(css).toContain(`${prop}:`);
171
+ }
172
+ });
173
+ ```
174
+
175
+ One optional extra, not in that list because it has a working fallback:
176
+ `--hopper-widget-base-height` (default `240px`) caps dropdown menus.
177
+
178
+ **4 — mount the provider** (optional; omitting it gives readable defaults):
179
+
180
+ ```tsx
181
+ import { StonedogStyleProvider } from "@stonedogcode/style";
182
+
183
+ <StonedogStyleProvider fontSizeProfile="md" variant="solid">
184
+ <App />
185
+ </StonedogStyleProvider>;
186
+ ```
187
+
188
+ ### Check it actually worked
189
+
190
+ Three greps against your generated stylesheet, in order. Each isolates one of
191
+ the three ways this goes wrong silently:
192
+
193
+ ```bash
194
+ npx panda cssgen --outfile styled-system/styles.css
195
+
196
+ # 1. Did the preset load? Expect ~44 matches, not 0.
197
+ grep -c 'var(--hopper-' styled-system/styles.css
198
+
199
+ # 2. Did Panda parse the package's source? Expect ~240 classes, not ~0.
200
+ # A low number means your `include` glob is wrong (step 1).
201
+ grep -oE '\.[a-zA-Z][a-zA-Z0-9_-]+' styled-system/styles.css | sort -u | wc -l
202
+
203
+ # 3. Did you keep the base presets? Expect all six breakpoints.
204
+ grep 'BreakpointToken =' styled-system/tokens/tokens.d.ts
205
+ # -> "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"
206
+ # Only "3xl" means you dropped @pandacss/preset-base and preset-panda.
207
+ ```
208
+
209
+ If all three pass and the UI is still blank, you are missing step 3.
210
+
211
+ ## Use
212
+
213
+ ```tsx
214
+ import { StyledBox, StyledHeading, StyledText, StyledVStack } from "@stonedogcode/style";
215
+
216
+ export function Panel() {
217
+ return (
218
+ <StyledBox p="4" header={<StyledHeading>Overview</StyledHeading>}>
219
+ <StyledVStack gap="3">
220
+ <StyledText>Colours come from the host's theme.</StyledText>
221
+ <StyledText tooltip="Shown on hover and on keyboard focus">
222
+ Hover me
223
+ </StyledText>
224
+ </StyledVStack>
225
+ </StyledBox>
226
+ );
227
+ }
228
+ ```
229
+
230
+ ## Theming
231
+
232
+ **Three settings** drive the system app-wide, all supplied by your app through
233
+ the provider:
234
+
235
+ - `fontSizeProfile` — `xs | sm | md | lg | xl`. The scale is rem-based, so it
236
+ compounds with the browser's own font-size setting rather than overriding it.
237
+ `StyledHeading` renders one tier above whatever body text is set to, so the
238
+ hierarchy survives every profile.
239
+ - `variant` — `solid | outline | aurora | glass | matte`. Any call site may
240
+ override it; `useResolvedVariant` applies the precedence (caller → app-wide →
241
+ `solid`) and coerces anything the recipes have no case for.
242
+ - `iconSize` — the default box for every `StyledIcon` that is not given an
243
+ explicit `size`. Defaults to `2x` (32px), which is large: this library came
244
+ out of an application built for an often-elderly audience. A conventional web
245
+ app wants `md` (20px).
246
+
247
+ ### Retuning the scale for your audience
248
+
249
+ The defaults lean large on purpose, and both halves are host-tunable without
250
+ forking anything:
251
+
252
+ ```tsx
253
+ <StonedogStyleProvider fontSizeProfile="md" iconSize="md" variant="solid">
254
+ ```
255
+
256
+ ```css
257
+ /* Every fontSizeMap entry is var(--font-sizes-KEY, <large fallback>),
258
+ so defining the properties replaces the scale wholesale. */
259
+ :root {
260
+ --font-sizes-sm: 0.875rem;
261
+ --font-sizes-md: 1rem;
262
+ --font-sizes-lg: 1.125rem;
263
+ }
264
+ ```
265
+
266
+ **Set the icon size once, at the provider.** Naming a `size` at each call site
267
+ works, but it opts that icon out of ever being retuned — which is how an
268
+ application ends up with three icon scales and no single place to fix them.
269
+ Because an icon set built with `createIcon` names no size of its own, setting
270
+ `iconSize` retunes the entire set at once.
271
+
272
+ **Your own namespace.** If `--hopper-*` does not suit, rename the whole
273
+ namespace at build time:
274
+
275
+ ```ts
276
+ stonedogStylePreset({ cssVarPrefix: "acme" }); // → var(--acme-box-primary-bg)
277
+ ```
278
+
279
+ The rename is total — every token re-points, and no `--hopper-*` reference
280
+ survives anywhere in the generated CSS. Choose it **before** you write a theme,
281
+ because it changes all 44 property names you have to define.
282
+
283
+ ## Adopting it in a new app — a worked example
284
+
285
+ Verified end to end against a clean project. Substitute your own prefix and
286
+ paths; nothing else here is optional.
287
+
288
+ ```bash
289
+ # 1. Take the dependency (see Install — it is not on npm)
290
+ npm install "git+https://github.com/stonedog-code/stonedog-style.git#<sha>"
291
+ npm install -D @pandacss/dev @types/react @types/react-dom
292
+ ```
293
+
294
+ `@types/react-dom` is not optional: the tooltip portals through `react-dom`,
295
+ and without the types your build fails on our source, not yours.
296
+
297
+ ```ts
298
+ // 2. panda.config.ts — all four points below matter
299
+ import { defineConfig } from "@pandacss/dev";
300
+ import { stonedogStylePreset } from "@stonedogcode/style/preset";
301
+
302
+ export default defineConfig({
303
+ preflight: false,
304
+ presets: [
305
+ "@pandacss/preset-base", // (a) REQUIRED — presets replaces, not merges
306
+ "@pandacss/preset-panda", // (b) REQUIRED — gray.*, radii, spacing
307
+ stonedogStylePreset({ cssVarPrefix: "acme" }),
308
+ ],
309
+ include: [
310
+ "./src/**/*.{ts,tsx}",
311
+ "./node_modules/@stonedogcode/style/src/**/*.tsx", // (c) REQUIRED
312
+ ],
313
+ exclude: ["./node_modules/@stonedogcode/style/src/**/__tests__/**/*"], // (d)
314
+ outdir: "styled-system",
315
+ jsxFramework: "react",
316
+ });
317
+ ```
318
+
319
+ > **Upgrading from `stonedog-style`?** The package moved to the
320
+ > `@stonedogcode` scope at `0.8.1`, and npm installs a scoped package one
321
+ > directory deeper — `node_modules/@stonedogcode/style/`, not
322
+ > `node_modules/stonedog-style/`. **So the `include` glob above changes, and
323
+ > getting it wrong is silent.** A glob that matches nothing produces no build
324
+ > error: components still render, with class names that have no CSS behind
325
+ > them. Only a component using an inline `styled(…, { base: … })` shows it,
326
+ > because everything else takes its CSS from the preset recipes, which Panda
327
+ > emits from config *without reading source*.
328
+ >
329
+ > Two things do **not** move. A `packages/stonedog-style/**` glob names a
330
+ > *submodule checkout directory*, which is unaffected by the package's name —
331
+ > changing it is its own silent breakage. And the `transpilePackages` entry in
332
+ > `next.config` **does** move, because that one names the package.
333
+ >
334
+ > Assert it rather than eyeballing it — this is the only failure here with no
335
+ > other symptom:
336
+ >
337
+ > ```ts
338
+ > // panda.test.ts
339
+ > import { globSync } from "tinyglobby";
340
+ > it("every stonedog glob resolves to real files", () => {
341
+ > for (const g of config.include.filter((p) => p.includes("stonedogcode"))) {
342
+ > expect(globSync(g).length).toBeGreaterThan(0);
343
+ > }
344
+ > });
345
+ > ```
346
+
347
+ ```jsonc
348
+ // 3. tsconfig.json — so the generated `styled-system/*` imports resolve
349
+ {
350
+ "compilerOptions": {
351
+ // NOT `baseUrl`. TypeScript 6 removed it, and a project on a current
352
+ // toolchain fails immediately with TS5102. This form does the same job
353
+ // and works on both.
354
+ "paths": { "*": ["./*"] },
355
+ "jsx": "react-jsx",
356
+ "moduleResolution": "bundler"
357
+ },
358
+ "include": ["src/**/*", "styled-system/**/*.ts"]
359
+ }
360
+ ```
361
+
362
+ ```tsx
363
+ // 4. Your root — theme first, then the provider
364
+ import "./theme.css"; // the 44 properties, from step 3 above
365
+ import { StonedogStyleProvider } from "@stonedogcode/style";
366
+
367
+ export function Root({ children }) {
368
+ return (
369
+ <StonedogStyleProvider fontSizeProfile="md" variant="solid">
370
+ {children}
371
+ </StonedogStyleProvider>
372
+ );
373
+ }
374
+ ```
375
+
376
+ ```bash
377
+ # 5. Generate, then run the three checks under "Check it actually worked"
378
+ npx panda codegen && npx panda cssgen --outfile styled-system/styles.css
379
+ ```
380
+
381
+ **What each mistake looks like**, since none of them raise an error:
382
+
383
+ | Symptom | Cause |
384
+ |---|---|
385
+ | Page renders, everything invisible or unstyled colours | Step 3 — properties undefined |
386
+ | Components render but have no styling at all | `include` missing the package (c) |
387
+ | Some styles apply, spacing and radii look wrong | Dropped a base preset (a/b) |
388
+ | `md`/`lg` responsive props rejected by the type-checker | Dropped a base preset (a/b) |
389
+ | `Cannot find module 'styled-system/jsx'` | No `paths` mapping, or codegen not run |
390
+ | `TS5102: Option 'baseUrl' has been removed` | TypeScript 6+; use `paths` (step 3) |
391
+ | `Could not find a declaration file for 'react-dom'` | Missing `@types/react-dom` (step 1) |
392
+ | Works in dev, breaks in a Next.js build | Missing `transpilePackages` |
393
+
394
+ Every row is a failure this walkthrough actually hit on a clean project, not a
395
+ list of things that might go wrong.
396
+
397
+ ## Logging
398
+
399
+ Silent by default — a component that renders a few hundred times a second must
400
+ not decide your console should fill up. Opt in at startup:
401
+
402
+ ```ts
403
+ import { setStyleLogger } from "@stonedogcode/style";
404
+ setStyleLogger(myLogger); // trace / debug / info / warn / error
405
+ ```
406
+
407
+ ## Icons — bring your own
408
+
409
+ **This package ships no icons, and that is the point.** `StyledIcon` is a
410
+ sizing-and-colouring wrapper that renders *whatever node you hand it*, so you
411
+ choose the icon set and nothing about it leaks into the library. Lucide,
412
+ Heroicons, Font Awesome, Material Symbols, your designer's SVGs — all equally
413
+ supported, and you can mix them.
414
+
415
+ ```tsx
416
+ import { StyledIcon } from "@stonedogcode/style";
417
+ import { Home } from "lucide-react";
418
+
419
+ <StyledIcon icon={<Home />} size="lg" />;
420
+ ```
421
+
422
+ ### Building an icon set
423
+
424
+ An icon set is a few hundred near-identical wrappers, and hand-writing them is
425
+ how a set drifts — one forgets to forward `size`, another hardcodes a colour.
426
+ `createIcon` makes each one a line and forces them to agree:
427
+
428
+ ```tsx
429
+ // icons.tsx — your own module, in your own repo
430
+ import { createIcon, createIconFromComponent } from "@stonedogcode/style";
431
+ import { Home, Trash2 } from "lucide-react";
432
+
433
+ export const StyledHome = createIcon("StyledHome", <Home />);
434
+ export const StyledTrash = createIconFromComponent("StyledTrash", Trash2);
435
+ ```
436
+
437
+ Use `createIconFromComponent` when the set exports one component per glyph
438
+ (Lucide, Heroicons, react-icons). It renders them at `width`/`height` 100% so
439
+ they fill the box `size` establishes — most sets default to 24px and would
440
+ otherwise ignore `size` entirely. Use `createIcon` when you have a node already.
441
+
442
+ ### Sizing
443
+
444
+ `size` accepts `xs`, `sm`, `1x`, `md`, `lg`, `2x`, `xl`, `3x` … `10x` and sets a
445
+ square box in CSS px (`md` → 20, `lg` → 24, `2x` → 32). It always wins over a
446
+ height or width in a spread `style` prop, so sizing stays predictable.
447
+
448
+ Omit it and the app-wide `iconSize` from the provider applies — `2x` unless your
449
+ app says otherwise. Prefer omitting it: see "Retuning the scale for your
450
+ audience" above.
451
+
452
+ ### Colouring
453
+
454
+ Two mechanisms, because icon libraries disagree about how they take a colour:
455
+
456
+ | Your icon set draws with… | What to do |
457
+ |---|---|
458
+ | `currentColor` — Lucide, Heroicons, Feather, Material Symbols, most SVGs | Nothing. `color` is set on the wrapper and inherits. |
459
+ | its own CSS variables — e.g. Font Awesome duotone | Map the published `--icon-*` properties, once. |
460
+
461
+ `StyledIcon` publishes `--icon-primary-color`, `--icon-secondary-color` and
462
+ `--icon-secondary-opacity` under **neutral names** so no icon library is baked
463
+ into this package. A set that wants different names needs one CSS rule:
464
+
465
+ ```css
466
+ /* Font Awesome adapter — one rule, in your app */
467
+ .icon svg {
468
+ --fa-primary-color: var(--icon-primary-color);
469
+ --fa-secondary-color: var(--icon-secondary-color);
470
+ --fa-secondary-opacity: var(--icon-secondary-opacity, 0.4);
471
+ }
472
+ ```
473
+
474
+ Colours default to the theme tokens (`textMain`, `iconBgPrimary`), so an icon
475
+ with no explicit colour follows the host's theme and colour mode automatically.
476
+ Pass `color` / `secondaryColor` to override per call site.
477
+
478
+ ### Accessibility
479
+
480
+ `title` is the whole interface, and the default is the one you want more often:
481
+
482
+ ```tsx
483
+ <StyledIcon icon={<Trash2 />} /> {/* decorative: aria-hidden */}
484
+ <StyledIcon icon={<Trash2 />} title="Delete" /> {/* meaningful: role="img" + name */}
485
+ ```
486
+
487
+ Give `title` **only** when the icon carries meaning no adjacent text already
488
+ conveys — an icon-only button, for instance. An icon sitting next to its own
489
+ label must stay untitled, or screen readers announce the name twice.
490
+
491
+ ### Why it works this way
492
+
493
+ The components were extracted from an app built on a per-seat commercial icon
494
+ set whose artwork cannot be redistributed under this licence. Rather than pick a
495
+ replacement and impose it on everyone, the artwork was cut out entirely. Your
496
+ licensed set can live in a private package while the components that lay it out
497
+ stay open — which is exactly the arrangement the original app now uses.
498
+
499
+ ## Navigation — `StyledSidebar`
500
+
501
+ A rail of tools, built for readers who navigate by reading words rather than by
502
+ decoding glyphs. Every item is **an icon *and* the tool's name** — there is no
503
+ icon-only rendering, not even collapsed. Full reasoning in
504
+ [PRD-0001](docs/prd/PRD-0001-styled-sidebar.md).
505
+
506
+ ```tsx
507
+ import { StyledSidebar, type SidebarItem } from "@stonedogcode/style";
508
+
509
+ const tools: SidebarItem[] = [
510
+ { id: "calendar", icon: <StyledCalendar />, label: "Calendar",
511
+ description: "Events & appointments", help: "Shows what is coming up." },
512
+ { id: "notes", icon: <StyledNotes />, label: "Notes" },
513
+ ];
514
+
515
+ <StyledSidebar
516
+ items={tools} // already ordered, already filtered
517
+ selectedId={selected}
518
+ onSelect={setSelected}
519
+ overflow="scroll" // or "paging"
520
+ emptyState="No tools match that search."
521
+ heading="TOOLS"
522
+ aria-label="Care Tools"
523
+ />;
524
+ ```
525
+
526
+ | Prop | Meaning |
527
+ |---|---|
528
+ | `items` | `SidebarItem[]` — `{ id, icon?, label, description?, help? }`. **Rendered exactly as given.** |
529
+ | `selectedId` / `onSelect` | Controlled selection. `onSelect(id)` reports a choice; navigation is yours. |
530
+ | `overflow` | `"scroll"` (default, uses `StyledScrollbar`) or `"paging"` (previous/next + "Page 2 of 4"). |
531
+ | `itemsPerPage` | Paging only; default 8. |
532
+ | `collapsed` / `onCollapsedChange` | Controlled collapse. Omit the handler and no collapse control renders. |
533
+ | `emptyState` | Rendered inside a live region when `items` is empty. |
534
+ | `heading` | e.g. `"TOOLS"`. |
535
+ | `aria-label` | Names the `navigation` landmark. Defaults to `"Tools"`. |
536
+
537
+ ### Ordering, filtering and the search box are **yours**, not the component's
538
+
539
+ This is the load-bearing part of the API, so it is stated plainly: **`items`
540
+ arrive already ordered and already filtered. `StyledSidebar` does not sort, does
541
+ not filter, and owns no search field.**
542
+
543
+ - **Ordering** is a user preference the host stores and applies.
544
+ - **Filtering is policy** — name only or description too, fuzzy or exact,
545
+ accent-insensitive or not. Different products want different answers, and
546
+ baking one in would impose it on every future consumer.
547
+ - **A search input built here could not dictate.** Speech-to-text lives in the
548
+ host's own text input, behind its own engine selection and feature flag. This
549
+ package cannot import that and must not depend on any host. Leaving the field
550
+ outside is precisely what makes dictated search work.
551
+
552
+ Three behaviours exist to make that seam seamless, and a host gets them free:
553
+
554
+ - **Selection survives filtering.** A `selectedId` no longer present in `items`
555
+ stays selected — the reader is searching, not navigating away.
556
+ - **Paging resets when `items` changes**, so a narrowed list never strands
557
+ anyone on an empty page 3.
558
+ - **An empty `items` renders `emptyState` in a live region**, so a screen-reader
559
+ user learns the filter matched nothing instead of meeting a blank panel.
560
+
561
+ ### What it guarantees
562
+
563
+ - **Tap targets:** 60px minimum on a tool row, 48px on the pager, collapse and
564
+ help controls — stated as `min-height`, so no density or font-scale change
565
+ erodes them.
566
+ - **Help opens on click, never hover** (via `StyledTooltip`'s `trigger="click"`,
567
+ which renders its own visible, focusable help control). Escape closes it and
568
+ returns focus. **Nothing anywhere in this component changes state on hover.**
569
+ - **Selection is never colour alone** — border, background *and* label weight,
570
+ plus `aria-current` for assistive technology.
571
+ - **Long names wrap** rather than spilling out of the rail.
572
+ - **No drag interaction at all** (WCAG 2.2 SC 2.5.7). A host that builds
573
+ reordering must provide a non-drag path.
574
+
575
+ Scroll mode needs a height to scroll inside: `StyledScrollbar` is
576
+ `flex: 1; min-height: 0; overflow: auto`, so give the sidebar's container a
577
+ height (`display: flex; flex-direction: column; height: …`). Unconstrained, the
578
+ rail simply grows — which is correct, and is not a bug.
579
+
580
+ ## Adopting a component as it is migrated
581
+
582
+ Components move out of HopperGuard into this package one at a time (NEH-167).
583
+ Each lands as its own release, so consumers adopt on their own schedule rather
584
+ than waiting for a big-bang switch.
585
+
586
+ ### Find out what is available
587
+
588
+ ```bash
589
+ git -C packages/stonedog-style log --oneline main # what has landed
590
+ ```
591
+
592
+ Every migration commit is `feat: migrate StyledX`. The commit body is the real
593
+ changelog: it says what the component does, **what was deliberately left
594
+ behind**, and any prop that was dropped. Read it before adopting — a migration
595
+ is rarely a pure move, because dead props and accessibility gaps get fixed on
596
+ the way through.
597
+
598
+ ### HopperGuard
599
+
600
+ The app already consumes this package, so adopting a component is a pointer bump
601
+ plus a decision about the local file.
602
+
603
+ ```bash
604
+ git -C packages/stonedog-style checkout main && git -C packages/stonedog-style pull
605
+ ```
606
+
607
+ Then, for `apps/web/src/app/components/Styled/StyledX.tsx`:
608
+
609
+ **If the migrated component is a drop-in**, replace the file with a re-export.
610
+ Call sites stay untouched:
611
+
612
+ ```tsx
613
+ export { StyledX as default, StyledX } from "@stonedogcode/style";
614
+ export type { StyledXProps } from "@stonedogcode/style";
615
+ ```
616
+
617
+ **If the app needs behaviour the shared one deliberately does not have**, keep a
618
+ real wrapper that delegates. `StyledSpinner` is the worked example: the shared
619
+ one has no `spinLogo`, because a brand mark is not a primitive, so the app keeps
620
+ a component that renders its logo and falls through to the shared spinner
621
+ otherwise. That is not a shim — it is an app-level extension, and it should not
622
+ pretend to be one.
623
+
624
+ Then two PRs, in this order — the submodule pointer must be on `main` before the
625
+ app can resolve it:
626
+
627
+ 1. **hopperguard** — bump the `packages/stonedog-style` gitlink.
628
+ 2. **hopper-web** — swap the local file.
629
+ 3. **hopperguard** — bump the `apps/web` gitlink.
630
+
631
+ ### optima-filings / optima-cloud-saas
632
+
633
+ Nothing to unpick — these have no local copy to replace. Take the dependency
634
+ (see Install), then import:
635
+
636
+ ```tsx
637
+ import { StyledSpinner } from "@stonedogcode/style";
638
+ ```
639
+
640
+ `optima-filings` is public and AGPLv3 and ships a public Docker image, so it
641
+ uses a **permissive icon set** (Lucide) through the icon seam rather than the
642
+ private Font Awesome package. Everything else is shared. Both Optima repos run
643
+ their own `--optima-*` namespace via `cssVarPrefix` (NEH-170).
644
+
645
+ ### Verify — the three checks that actually catch things
646
+
647
+ Learned the hard way; each one corresponds to a real bug that reached `main`.
648
+
649
+ 1. **Type-check on a real `npm install`.** Not a symlinked `node_modules` from
650
+ another checkout — a stale copy there resolves to the wrong package and hides
651
+ dependency-graph breakage entirely. The unit tier cannot substitute: a missing
652
+ export is a *type* error, and jest does not type-check.
653
+ 2. **Look at it at 375px.** Layout regressions surface on the narrowest screen
654
+ first, and jsdom has no layout engine, so no unit test will tell you.
655
+ 3. **Read the migration commit for dropped props.** A prop removed upstream is
656
+ a type error at the call site — good. A prop that was *always* silently
657
+ ignored (`thickness`, `speed`, `color`, `emptyColor`, `logoSize` on the
658
+ spinner) is not, and its removal is a no-op you can adopt safely.
659
+
660
+ ### If the component changed shape
661
+
662
+ Migrations fix things on the way through, so behaviour is occasionally
663
+ intentionally different. When it is, the commit says so explicitly. Two patterns
664
+ so far:
665
+
666
+ - **A prop was dropped because it never worked.** Adopt freely; nothing rendered
667
+ differently.
668
+ - **An accessibility gap was closed.** `StyledSpinner` gained `role="status"`, so
669
+ screen readers now announce it. Nothing visual changes, but a test asserting
670
+ the old silence will fail, and it should.
671
+
672
+ ## Development
673
+
674
+ ```bash
675
+ npm install # also runs panda codegen
676
+ npm run gate # codegen → typecheck → lint → tests. The merge bar.
677
+ npm test
678
+ ```
679
+
680
+ `styled-system/` is generated and gitignored; regenerate with
681
+ `npm run panda:build`.
682
+
683
+ Tests run against the **real** generated `styled-system` rather than a mock, so
684
+ recipe output is assertable — see CLAUDE.md for why that took some doing.
685
+
686
+ ### Design documents
687
+
688
+ Components with enough behaviour to argue about get a PRD under `docs/prd/`,
689
+ written before the component. They record what a component must do and — more
690
+ usefully — what it deliberately does not, so the next person does not re-open a
691
+ settled question.
692
+
693
+ | PRD | Component | Status |
694
+ |---|---|---|
695
+ | [PRD-0001](docs/prd/PRD-0001-styled-sidebar.md) | `StyledSidebar` | Shipped |
696
+
697
+ ## License
698
+
699
+ [Apache-2.0](./LICENSE). See [NOTICE](./NOTICE) for attribution.