@dreamtree-org/twreact-ui 2.0.9 → 2.1.1

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 (55) hide show
  1. package/DESIGN.md +3 -0
  2. package/README.md +9 -4
  3. package/ai-skills/dreamtree-ui.md +12 -8
  4. package/dist/ai-schema.json +314 -70
  5. package/dist/components/blocks/AppShell.d.ts.map +1 -1
  6. package/dist/components/blocks/ProductCard.d.ts +4 -0
  7. package/dist/components/blocks/ProductCard.d.ts.map +1 -0
  8. package/dist/components/blocks/ProductCardParts.d.ts +19 -0
  9. package/dist/components/blocks/ProductCardParts.d.ts.map +1 -0
  10. package/dist/components/blocks/StatCard.d.ts.map +1 -1
  11. package/dist/components/core/Accordion.d.ts.map +1 -1
  12. package/dist/components/core/Button.d.ts.map +1 -1
  13. package/dist/components/core/OptionCard.d.ts +14 -0
  14. package/dist/components/core/OptionCard.d.ts.map +1 -0
  15. package/dist/components/core/OptionCardGroup.d.ts +2 -0
  16. package/dist/components/core/OptionCardGroup.d.ts.map +1 -0
  17. package/dist/components/core/QuantityStepper.d.ts +8 -0
  18. package/dist/components/core/QuantityStepper.d.ts.map +1 -0
  19. package/dist/components/core/menu/MenuPopover.d.ts.map +1 -1
  20. package/dist/components/utility/ImageGallery.d.ts +4 -0
  21. package/dist/components/utility/ImageGallery.d.ts.map +1 -0
  22. package/dist/components/utility/imageGalleryParts.d.ts +55 -0
  23. package/dist/components/utility/imageGalleryParts.d.ts.map +1 -0
  24. package/dist/esm/components/blocks/AppShell.js +17 -6
  25. package/dist/esm/components/blocks/ProductCard.js +122 -0
  26. package/dist/esm/components/blocks/ProductCardParts.js +128 -0
  27. package/dist/esm/components/blocks/StatCard.js +20 -5
  28. package/dist/esm/components/core/Accordion.js +3 -1
  29. package/dist/esm/components/core/Button.js +17 -2
  30. package/dist/esm/components/core/OptionCard.js +340 -0
  31. package/dist/esm/components/core/QuantityStepper.js +194 -0
  32. package/dist/esm/components/core/menu/MenuPopover.js +4 -2
  33. package/dist/esm/components/navigation/Navbar.js +1 -1
  34. package/dist/esm/components/utility/ImageGallery.js +150 -0
  35. package/dist/esm/components/utility/imageGalleryParts.js +380 -0
  36. package/dist/esm/index.js +4 -0
  37. package/dist/index.css +1 -1
  38. package/dist/index.d.ts +6 -1
  39. package/dist/index.esm.css +1 -1
  40. package/dist/index.esm.js +1850 -505
  41. package/dist/index.js +1854 -504
  42. package/dist/tokens/tailwind3-preset.cjs +4 -1
  43. package/dist/tokens/theme.css +3 -0
  44. package/dist/tokens/tokens.json +7 -0
  45. package/dist/tokens/vars.css +2 -0
  46. package/dist/types/blocks.d.ts +61 -1
  47. package/dist/types/blocks.d.ts.map +1 -1
  48. package/dist/types/index.d.ts +102 -1
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/doc/README.md +5 -1
  51. package/mcp/catalog.snapshot.json +480 -9
  52. package/package.json +3 -2
  53. package/tokens/brand.schema.json +22 -0
  54. package/tokens/lib.cjs +26 -0
  55. package/tokens/tokens.json +2 -1
@@ -214,7 +214,13 @@
214
214
  "prop": "variant",
215
215
  "type": "`string`",
216
216
  "default": "\"primary\"",
217
- "description": "`primary`, `secondary`, `outline`, `ghost`, `destructive`, `success`, `warning`"
217
+ "description": "`primary`, `secondary`, `outline`, `ghost`, `destructive`, `success`, `warning`, `link`. `link` is a text link: no background, border or padding, underlined on hover, the usual focus ring, and 44px tall on coarse pointers"
218
+ },
219
+ {
220
+ "prop": "tone",
221
+ "type": "`\"primary\"` | `\"secondary\"` | `\"success\"` | `\"warning\"` | `\"error\"` | `\"info\"`",
222
+ "default": "\"primary\"",
223
+ "description": "Text colour when `variant=\"link\"` (e.g. `error` inside an error-tinted card). Ignored by the other variants"
218
224
  },
219
225
  {
220
226
  "prop": "size",
@@ -3261,6 +3267,203 @@
3261
3267
  ],
3262
3268
  "examples": "### Plain search\n\n```jsx\n<SearchField placeholder=\"Search invoices\" onSearch={(q) => router.push(`/invoices?q=${q}`)} className=\"max-w-sm\" />\n```\n\n### Always show Cancel (e.g. inside a search screen)\n\n```jsx\n<SearchField showCancel autoFocus onCancel={() => navigate(-1)} />\n```\n\n[← Component overview](README.md)"
3263
3269
  },
3270
+ {
3271
+ "name": "QuantityStepper",
3272
+ "group": "core",
3273
+ "familyExports": [],
3274
+ "importFrom": "@dreamtree-org/twreact-ui",
3275
+ "peerDependencies": [],
3276
+ "hasDoc": true,
3277
+ "description": "A **− / value / +** control for cart lines and buy boxes, where `Input type=\"number\"` is awkward on phones. The value is a `role=\"spinbutton\"` with `aria-valuenow` / `aria-valuemin` / `aria-valuemax`; the decrement and increment buttons step it and are disabled at the bounds. Controlled or uncontrolled.",
3278
+ "props": [
3279
+ {
3280
+ "prop": "value",
3281
+ "type": "`number`",
3282
+ "default": "",
3283
+ "description": "Controlled value. Values outside `min` / `max` are clamped for display."
3284
+ },
3285
+ {
3286
+ "prop": "defaultValue",
3287
+ "type": "`number`",
3288
+ "default": "min",
3289
+ "description": "Uncontrolled initial value. Falls back to `min`, or `0` when `min` is not finite."
3290
+ },
3291
+ {
3292
+ "prop": "onChange",
3293
+ "type": "`(value) => void`",
3294
+ "default": "",
3295
+ "description": "Receives the next number (not a DOM event), already clamped to `min` / `max` and rounded to the precision of `step`. Not called when the value would not change."
3296
+ },
3297
+ {
3298
+ "prop": "min",
3299
+ "type": "`number`",
3300
+ "default": "0",
3301
+ "description": "Lowest value. The decrement button is disabled at `min`."
3302
+ },
3303
+ {
3304
+ "prop": "max",
3305
+ "type": "`number`",
3306
+ "default": "Infinity",
3307
+ "description": "Highest value, e.g. the stock count. The increment button is disabled at `max`. Without a finite `max`, `aria-valuemax` is omitted and `End` does nothing."
3308
+ },
3309
+ {
3310
+ "prop": "step",
3311
+ "type": "`number`",
3312
+ "default": "1",
3313
+ "description": "Amount each button press or arrow key moves the value. Decimal steps (`0.5`) do not drift. Non-positive values fall back to `1`."
3314
+ },
3315
+ {
3316
+ "prop": "size",
3317
+ "type": "`string`",
3318
+ "default": "\"md\"",
3319
+ "description": "`sm`, `md` or `lg` (32 / 40 / 48px tall). The buttons grow to 44 × 44px on coarse pointers (touch screens)."
3320
+ },
3321
+ {
3322
+ "prop": "disabled",
3323
+ "type": "`boolean`",
3324
+ "default": "false",
3325
+ "description": "Dims the control, disables both buttons, removes the spinbutton from the tab order and sets `aria-disabled`."
3326
+ },
3327
+ {
3328
+ "prop": "loading",
3329
+ "type": "`boolean`",
3330
+ "default": "false",
3331
+ "description": "For a pending server update: shows a spinner in place of the value, disables the buttons, ignores keys and sets `aria-busy`. The spinbutton keeps focus. The spinner stops under `prefers-reduced-motion`."
3332
+ },
3333
+ {
3334
+ "prop": "label",
3335
+ "type": "`ReactNode`",
3336
+ "default": "",
3337
+ "description": "Visible label above the control; it names the spinbutton through `aria-labelledby`."
3338
+ },
3339
+ {
3340
+ "prop": "hideLabel",
3341
+ "type": "`boolean`",
3342
+ "default": "false",
3343
+ "description": "Keeps `label` for screen readers only (`sr-only`), for tight cart rows."
3344
+ },
3345
+ {
3346
+ "prop": "formatValue",
3347
+ "type": "`(value) => ReactNode`",
3348
+ "default": "",
3349
+ "description": "Renders the displayed value, e.g. `` v => `${v} kg` ``. When it returns a string or number it is also used as `aria-valuetext`."
3350
+ },
3351
+ {
3352
+ "prop": "decrementLabel",
3353
+ "type": "`string`",
3354
+ "default": "\"Decrease quantity\"",
3355
+ "description": "Accessible name of the − button."
3356
+ },
3357
+ {
3358
+ "prop": "incrementLabel",
3359
+ "type": "`string`",
3360
+ "default": "\"Increase quantity\"",
3361
+ "description": "Accessible name of the + button."
3362
+ },
3363
+ {
3364
+ "prop": "name",
3365
+ "type": "`string`",
3366
+ "default": "",
3367
+ "description": "Submits the value with a form through a hidden input."
3368
+ },
3369
+ {
3370
+ "prop": "aria-label",
3371
+ "type": "`string`",
3372
+ "default": "",
3373
+ "description": "Accessible name of the spinbutton when there is no `label`."
3374
+ },
3375
+ {
3376
+ "prop": "className",
3377
+ "type": "`string`",
3378
+ "default": "",
3379
+ "description": "Merged onto the root with `cn`, so your classes win on conflict."
3380
+ }
3381
+ ],
3382
+ "examples": "```jsx\nconst [qty, setQty] = useState(1);\n\n<QuantityStepper label=\"Quantity\" value={qty} min={1} max={stock} onChange={setQty} />\n\n<QuantityStepper\n label=\"Quantity, Ceramic mug\"\n hideLabel\n size=\"sm\"\n value={line.qty}\n min={1}\n max={line.stock}\n loading={line.updating}\n onChange={(next) => updateLine(line.id, next)}\n/>\n\n<QuantityStepper label=\"Weight\" defaultValue={0.5} min={0.5} max={5} step={0.5} formatValue={(v) => `${v} kg`} />\n```\n\n[← Component overview](README.md)"
3383
+ },
3384
+ {
3385
+ "name": "OptionCard",
3386
+ "group": "core",
3387
+ "familyExports": [],
3388
+ "importFrom": "@dreamtree-org/twreact-ui",
3389
+ "peerDependencies": [],
3390
+ "hasDoc": true,
3391
+ "description": "Selectable choice tile for **rich choices**: page templates (with a small wireframe), \"add a block\" pickers, starter kits. Each tile has a title, a description, an optional media slot, an optional badge and a selected state (primary border and ring **plus** a check indicator, so it never relies on colour alone). Wrap tiles in `OptionCardGroup` for a responsive grid with `role=\"radiogroup\"` semantics, or `multiple` for checkbox-group semantics. On its own an `OptionCard` is a toggle button (`aria-pressed`). For short text-only choices use `SegmentedControl` or `Radio` instead.",
3392
+ "props": [
3393
+ {
3394
+ "prop": "value",
3395
+ "type": "`string`",
3396
+ "default": "",
3397
+ "description": "The value this tile stands for (a string or number). Required inside an `OptionCardGroup`, where it is compared with the group's `value`; passed to `onSelect` everywhere."
3398
+ },
3399
+ {
3400
+ "prop": "title",
3401
+ "type": "`ReactNode`",
3402
+ "default": "",
3403
+ "description": "Tile heading. Also the tile's accessible name (`aria-labelledby`)."
3404
+ },
3405
+ {
3406
+ "prop": "description",
3407
+ "type": "`ReactNode`",
3408
+ "default": "",
3409
+ "description": "Secondary text under the title (`text-muted`). Also the tile's accessible description (`aria-describedby`)."
3410
+ },
3411
+ {
3412
+ "prop": "media",
3413
+ "type": "`ReactNode`",
3414
+ "default": "",
3415
+ "description": "Optional visual above the text, e.g. a small wireframe SVG or an image. Clipped to rounded corners."
3416
+ },
3417
+ {
3418
+ "prop": "badge",
3419
+ "type": "`ReactNode`",
3420
+ "default": "",
3421
+ "description": "Optional small pill next to the title, e.g. `\"New\"` or `\"Pro\"`."
3422
+ },
3423
+ {
3424
+ "prop": "selected",
3425
+ "type": "`boolean`",
3426
+ "default": "false",
3427
+ "description": "Standalone only: whether the tile is pressed (controlled). Ignored inside a group, which derives it from its `value`."
3428
+ },
3429
+ {
3430
+ "prop": "onSelect",
3431
+ "type": "`(value, event) => void`",
3432
+ "default": "",
3433
+ "description": "Called when the tile is activated (click, `Enter` or `Space`), standalone or inside a group. Not called while disabled."
3434
+ },
3435
+ {
3436
+ "prop": "disabled",
3437
+ "type": "`boolean`",
3438
+ "default": "false",
3439
+ "description": "Dims the tile, sets `aria-disabled`, removes it from the tab order and keyboard navigation, and ignores activation."
3440
+ },
3441
+ {
3442
+ "prop": "children",
3443
+ "type": "`ReactNode`",
3444
+ "default": "",
3445
+ "description": "Extra content rendered below the description."
3446
+ },
3447
+ {
3448
+ "prop": "className",
3449
+ "type": "`string`",
3450
+ "default": "",
3451
+ "description": "Merged onto the tile with `cn`, so your classes win on conflict."
3452
+ }
3453
+ ],
3454
+ "examples": "```jsx\nconst [template, setTemplate] = useState('classic');\n\n<OptionCardGroup value={template} onChange={setTemplate} columns={3} aria-label=\"Template\">\n <OptionCard value=\"classic\" title=\"Classic\" description=\"Filters beside the grid\" media={<Sketch />} />\n <OptionCard value=\"stacked\" title=\"Stacked\" description=\"Filters above the grid\" badge=\"New\" />\n <OptionCard value=\"minimal\" title=\"Minimal\" description=\"No filters\" disabled />\n</OptionCardGroup>\n\n<OptionCardGroup multiple label=\"Blocks to add\" defaultValue={['hero']} onChange={setBlocks} columns={2}>\n <OptionCard value=\"hero\" title=\"Hero\" description=\"Large heading with a call to action\" />\n <OptionCard value=\"faq\" title=\"FAQ\" description=\"Collapsible questions\" />\n</OptionCardGroup>\n\n<OptionCard value=\"starter\" title=\"Starter kit\" description=\"Pages and blocks to begin with\" selected={on} onSelect={() => setOn(!on)} />\n```\n\n[← Component overview](README.md)"
3455
+ },
3456
+ {
3457
+ "name": "OptionCardGroup",
3458
+ "group": "core",
3459
+ "familyExports": [],
3460
+ "importFrom": "@dreamtree-org/twreact-ui",
3461
+ "peerDependencies": [],
3462
+ "hasDoc": false,
3463
+ "description": "",
3464
+ "props": [],
3465
+ "examples": ""
3466
+ },
3264
3467
  {
3265
3468
  "name": "Sidebar",
3266
3469
  "group": "navigation",
@@ -4981,6 +5184,90 @@
4981
5184
  ],
4982
5185
  "examples": "```jsx\n<PullToRefresh onRefresh={() => refetchOrders()} className=\"h-[calc(100dvh-4rem)]\">\n <ul>{orders.map((o) => <OrderRow key={o.id} order={o} />)}</ul>\n</PullToRefresh>\n```\n\n```jsx\n// Your own scroll container\nconst listRef = useRef(null);\n\n<PullToRefresh scrollRef={listRef} onRefresh={refetch} refreshingLabel=\"Loading new orders…\" className=\"h-96\">\n <ul ref={listRef} className=\"h-full overflow-y-auto\">{rows}</ul>\n</PullToRefresh>\n```\n\n[← Component overview](README.md)"
4983
5186
  },
5187
+ {
5188
+ "name": "ImageGallery",
5189
+ "group": "utility",
5190
+ "familyExports": [],
5191
+ "importFrom": "@dreamtree-org/twreact-ui",
5192
+ "peerDependencies": [],
5193
+ "hasDoc": true,
5194
+ "description": "Product-photo gallery driven by an `images` array: a main viewer with thumbnails beside or below it, or a full-width mosaic. Supports a controlled or uncontrolled index, swipe on touch, arrow keys, previous/next buttons, click-to-zoom, a full-screen lightbox built on `Dialog`, a counter badge and a placeholder for missing or broken images.",
5195
+ "props": [
5196
+ {
5197
+ "prop": "images",
5198
+ "type": "`{ src: string; alt: string; caption?: ReactNode }[]`",
5199
+ "default": "[]",
5200
+ "description": "Images in display order. `alt` names the main image and its thumbnail; `caption` renders under the viewer."
5201
+ },
5202
+ {
5203
+ "prop": "layout",
5204
+ "type": "`'thumbs-below' | 'thumbs-side' | 'mosaic'`",
5205
+ "default": "'thumbs-below'",
5206
+ "description": "`thumbs-side` puts the thumbnails left of the photo from the `sm` breakpoint and falls back to below on narrow screens. `mosaic` is a full-width tile grid (hero + up to 4 tiles, the last showing \"+N\")."
5207
+ },
5208
+ {
5209
+ "prop": "ratio",
5210
+ "type": "`'4:3' | '1:1' | '3:4' | '16:9'`",
5211
+ "default": "'4:3'",
5212
+ "description": "Aspect ratio of the viewer and mosaic tiles."
5213
+ },
5214
+ {
5215
+ "prop": "index",
5216
+ "type": "`number`",
5217
+ "default": "",
5218
+ "description": "Controlled current image (0-based). Out-of-range values are clamped."
5219
+ },
5220
+ {
5221
+ "prop": "defaultIndex",
5222
+ "type": "`number`",
5223
+ "default": "0",
5224
+ "description": "Uncontrolled initial image."
5225
+ },
5226
+ {
5227
+ "prop": "onIndexChange",
5228
+ "type": "`(index: number) => void`",
5229
+ "default": "",
5230
+ "description": "Called when the user picks another image (thumbnail, arrows, keys, swipe, mosaic tile, lightbox)."
5231
+ },
5232
+ {
5233
+ "prop": "zoom",
5234
+ "type": "`boolean`",
5235
+ "default": "false",
5236
+ "description": "Click / tap the photo to toggle a 2x view whose origin follows the pointer."
5237
+ },
5238
+ {
5239
+ "prop": "lightbox",
5240
+ "type": "`boolean`",
5241
+ "default": "false",
5242
+ "description": "Adds an \"Open full-screen view\" button (and makes mosaic tiles open it) that shows the viewer in a `Dialog`."
5243
+ },
5244
+ {
5245
+ "prop": "placeholder",
5246
+ "type": "`ReactNode`",
5247
+ "default": "image-off icon",
5248
+ "description": "Rendered when `images` is empty or an image fails to load."
5249
+ },
5250
+ {
5251
+ "prop": "showCounter",
5252
+ "type": "`boolean`",
5253
+ "default": "true",
5254
+ "description": "Show the `\"3/6\"` badge on the viewer."
5255
+ },
5256
+ {
5257
+ "prop": "className",
5258
+ "type": "`string`",
5259
+ "default": "",
5260
+ "description": "Root CSS classes (merged with `cn`)."
5261
+ },
5262
+ {
5263
+ "prop": "...rest",
5264
+ "type": "`HTMLAttributes<HTMLDivElement>`",
5265
+ "default": "",
5266
+ "description": "Forwarded to the root `<div>` (`id`, `data-*`, handlers, …). Pass `aria-label` to name the gallery (default `\"Image gallery\"`); it labels the viewer region (or the mosaic region)."
5267
+ }
5268
+ ],
5269
+ "examples": "```jsx\nconst photos = [\n { src: '/units/42/front.jpg', alt: 'Front of the unit', caption: 'Minor scuff on the bezel' },\n { src: '/units/42/back.jpg', alt: 'Back of the unit' },\n { src: '/units/42/ports.jpg', alt: 'Ports close-up' },\n];\n\nfunction UnitPhotos() {\n const [index, setIndex] = React.useState(0);\n return (\n <ImageGallery\n aria-label=\"Photos of this unit\"\n images={photos}\n layout=\"thumbs-side\"\n ratio=\"1:1\"\n index={index}\n onIndexChange={setIndex}\n zoom\n lightbox\n placeholder={<span>No photos yet</span>}\n />\n );\n}\n```\n\n[← Component overview](README.md)"
5270
+ },
4984
5271
  {
4985
5272
  "name": "AppShell",
4986
5273
  "group": "blocks",
@@ -5032,6 +5319,24 @@
5032
5319
  "default": "\"a\"",
5033
5320
  "description": "Link element for nav items, e.g. Next.js `Link`"
5034
5321
  },
5322
+ {
5323
+ "prop": "navOpen",
5324
+ "type": "`boolean`",
5325
+ "default": "",
5326
+ "description": "Controls the mobile navigation drawer, e.g. so a `FootNav` \"More\" tab can open it. Unset, the shell's menu button owns it"
5327
+ },
5328
+ {
5329
+ "prop": "onNavOpenChange",
5330
+ "type": "`(open: boolean) => void`",
5331
+ "default": "",
5332
+ "description": "Called when the drawer should open or close (menu button, close button, Escape, backdrop, choosing a nav item). Called in uncontrolled mode too"
5333
+ },
5334
+ {
5335
+ "prop": "topbarFrom",
5336
+ "type": "`\"always\"` | `\"below-lg\"`",
5337
+ "default": "\"always\"",
5338
+ "description": "`\"below-lg\"` hides the top bar from `lg` up, where the sidebar carries the brand and nav, so a desktop page has no empty strip"
5339
+ },
5035
5340
  {
5036
5341
  "prop": "className",
5037
5342
  "type": "`string`",
@@ -5051,7 +5356,7 @@
5051
5356
  "description": "Forwarded to the root `<div>`; the ref is forwarded to it"
5052
5357
  }
5053
5358
  ],
5054
- "examples": "```jsx\n<AppShell\n brand={<span className=\"text-lg font-bold\">PracticePro</span>}\n nav={[\n { id: 'overview', label: 'Overview', href: '/overview', icon: <Home className=\"h-5 w-5\" /> },\n { id: 'compliance', label: 'Compliance', href: '/compliance', active: true },\n { id: 'approvals', label: 'Approvals', href: '/approvals', count: 4 },\n ]}\n topbar={<Avatar name=\"Ananya Rao\" size=\"sm\" />}\n linkAs={Link}\n>\n <PageHeader title=\"Compliance calendar\" />\n</AppShell>\n```\n\n### Bottom nav on phones\n\n```jsx\n<AppShell\n brand={<Logo />}\n nav={nav}\n linkAs={Link}\n bottomNav={\n <FootNav\n linkAs={Link}\n items={[\n { id: 'home', label: 'Home', icon: <Home />, href: '/', active: true },\n { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, href: '/tasks', badge: 3 },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, href: '/inbox' },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```\n\nKeep the bottom nav to the 3–5 most used destinations; the full `nav` stays in the drawer (menu button) below `lg` and in the sidebar on desktop."
5359
+ "examples": "```jsx\n<AppShell\n brand={<span className=\"text-lg font-bold\">PracticePro</span>}\n nav={[\n { id: 'overview', label: 'Overview', href: '/overview', icon: <Home className=\"h-5 w-5\" /> },\n { id: 'compliance', label: 'Compliance', href: '/compliance', active: true },\n { id: 'approvals', label: 'Approvals', href: '/approvals', count: 4 },\n ]}\n topbar={<Avatar name=\"Ananya Rao\" size=\"sm\" />}\n linkAs={Link}\n>\n <PageHeader title=\"Compliance calendar\" />\n</AppShell>\n```\n\n### Bottom nav on phones\n\n```jsx\n<AppShell\n brand={<Logo />}\n nav={nav}\n linkAs={Link}\n bottomNav={\n <FootNav\n linkAs={Link}\n items={[\n { id: 'home', label: 'Home', icon: <Home />, href: '/', active: true },\n { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, href: '/tasks', badge: 3 },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, href: '/inbox' },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```\n\nKeep the bottom nav to the 3–5 most used destinations; the full `nav` stays in the drawer (menu button) below `lg` and in the sidebar on desktop.\n\n### Opening the drawer from a \"More\" tab\n\nPass `navOpen` / `onNavOpenChange` to control the drawer, and `topbarFrom=\"below-lg\"` when the desktop page has no top bar:\n\n```jsx\nconst [navOpen, setNavOpen] = useState(false);\n\n<AppShell\n brand={<Logo />}\n nav={nav}\n navOpen={navOpen}\n onNavOpenChange={setNavOpen}\n topbarFrom=\"below-lg\"\n bottomNav={\n <FootNav\n items={[\n { id: 'today', label: 'Today', icon: <Home />, href: '/', active: true },\n { id: 'more', label: 'More', icon: <Menu />, onClick: () => setNavOpen(true) },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```"
5055
5360
  },
5056
5361
  {
5057
5362
  "name": "PageHeader",
@@ -5158,6 +5463,18 @@
5158
5463
  "default": "false",
5159
5464
  "description": "Shows a skeleton instead of the value"
5160
5465
  },
5466
+ {
5467
+ "prop": "tinted",
5468
+ "type": "`boolean`",
5469
+ "default": "false",
5470
+ "description": "Draws the whole card in `tone`: `<tone>-50` fill, `<tone>-200` border, `<tone>-800` text (deep translucent tints in dark mode)"
5471
+ },
5472
+ {
5473
+ "prop": "children",
5474
+ "type": "`ReactNode`",
5475
+ "default": "",
5476
+ "description": "Footer under the value with the card's spacing: a [`ProgressBar`](ProgressBar.md), a line of text or a `Button variant=\"link\"`"
5477
+ },
5161
5478
  {
5162
5479
  "prop": "className",
5163
5480
  "type": "`string`",
@@ -5165,7 +5482,7 @@
5165
5482
  "description": "Extra CSS classes"
5166
5483
  }
5167
5484
  ],
5168
- "examples": "```jsx\n<StatCard label=\"Due this week\" value={18} tone=\"primary\" icon={<CalendarDays className=\"h-6 w-6\" />} />\n<StatCard label=\"Overdue\" value={3} tone=\"error\" change={{ value: 2, unit: '', period: 'vs last week', goodWhenUp: false }} />\n```\n\n[← Component overview](README.md)"
5485
+ "examples": "```jsx\n<StatCard label=\"Due this week\" value={18} tone=\"primary\" icon={<CalendarDays className=\"h-6 w-6\" />} />\n<StatCard label=\"Overdue\" value={3} tone=\"error\" change={{ value: 2, unit: '', period: 'vs last week', goodWhenUp: false }} />\n\n<StatCard label=\"Occupancy\" value=\"92%\">\n <ProgressBar value={44} max={48} size=\"sm\" ariaLabel=\"Beds filled\" />\n <p className=\"text-muted\">44 of 48 beds filled · 4 free</p>\n</StatCard>\n\n<StatCard label=\"Overdue rent\" value=\"₹18,400\" tone=\"error\" tinted>\n <Button variant=\"link\" tone=\"error\" rightIcon=\"→\">See who owes</Button>\n</StatCard>\n```\n\n[← Component overview](README.md)"
5169
5486
  },
5170
5487
  {
5171
5488
  "name": "StatusPill",
@@ -5215,6 +5532,156 @@
5215
5532
  ],
5216
5533
  "examples": "```jsx\nconst OBLIGATION_STATUS = {\n overdue: { label: 'Overdue', tone: 'danger' },\n docs_missing: { label: 'Missing documents', tone: 'warning' },\n ready: { label: 'Ready for review', tone: 'info' },\n filed: { label: 'Filed', tone: 'success' },\n};\n\n<StatusPill status=\"docs_missing\" map={OBLIGATION_STATUS} />\n<StatusPill tone=\"success\">Filed</StatusPill>\n```\n\n[← Component overview](README.md)"
5217
5534
  },
5535
+ {
5536
+ "name": "ProductCard",
5537
+ "group": "blocks",
5538
+ "familyExports": [],
5539
+ "importFrom": "@dreamtree-org/twreact-ui",
5540
+ "peerDependencies": [],
5541
+ "hasDoc": true,
5542
+ "description": "Page-level block: a storefront product tile for listing grids, featured rows and \"similar products\". Cover image (with an optional second image on hover), badges over the image, brand, title, spec line, price with a struck-through compare-at price and a computed discount, an actions slot and an optional compare toggle. Replaces the Card + Badge + Checkbox + Button compositions apps kept hand-rolling (issue #181).",
5543
+ "props": [
5544
+ {
5545
+ "prop": "title",
5546
+ "type": "`ReactNode`",
5547
+ "default": "",
5548
+ "description": "Product name; becomes the link when `href` / `to` / `as` is set. Also the default image `alt` when it is a string"
5549
+ },
5550
+ {
5551
+ "prop": "image",
5552
+ "type": "`string` | `{ src: string; alt?: string }`",
5553
+ "default": "",
5554
+ "description": "Cover image"
5555
+ },
5556
+ {
5557
+ "prop": "images",
5558
+ "type": "`Array<string | { src: string; alt?: string }>`",
5559
+ "default": "",
5560
+ "description": "More images; with `image` set they follow it, otherwise the first one is the cover. The second image is used by `hover=\"second-image\"`"
5561
+ },
5562
+ {
5563
+ "prop": "imageAlt",
5564
+ "type": "`string`",
5565
+ "default": "title",
5566
+ "description": "`alt` for images given as plain strings"
5567
+ },
5568
+ {
5569
+ "prop": "imageRatio",
5570
+ "type": "`4:3` | `1:1` | `3:4`",
5571
+ "default": "\"4:3\"",
5572
+ "description": "Aspect ratio of the image area"
5573
+ },
5574
+ {
5575
+ "prop": "hover",
5576
+ "type": "`none` | `zoom` | `second-image`",
5577
+ "default": "\"none\"",
5578
+ "description": "Image effect on hover; disabled under reduced motion. The second image is decorative (`alt=\"\"`)"
5579
+ },
5580
+ {
5581
+ "prop": "eyebrow",
5582
+ "type": "`ReactNode`",
5583
+ "default": "",
5584
+ "description": "Small line above the title, usually the brand"
5585
+ },
5586
+ {
5587
+ "prop": "meta",
5588
+ "type": "`ReactNode`",
5589
+ "default": "",
5590
+ "description": "Spec line under the title (size, colour, pack)"
5591
+ },
5592
+ {
5593
+ "prop": "badges",
5594
+ "type": "`ReactNode[]`",
5595
+ "default": "",
5596
+ "description": "Overlaid on the image's top-left corner, e.g. `<Badge>` elements"
5597
+ },
5598
+ {
5599
+ "prop": "price",
5600
+ "type": "`number` | `string`",
5601
+ "default": "",
5602
+ "description": "Selling price. Numbers go through `priceFormatter`; strings (\"From ₹999\") pass through"
5603
+ },
5604
+ {
5605
+ "prop": "compareAtPrice",
5606
+ "type": "`number` | `string`",
5607
+ "default": "",
5608
+ "description": "Original price, struck through, announced as \"Original price\" to screen readers"
5609
+ },
5610
+ {
5611
+ "prop": "discountLabel",
5612
+ "type": "`ReactNode`",
5613
+ "default": "",
5614
+ "description": "Shown after the prices. When omitted and both prices are numbers with `compareAtPrice` higher, it is computed, e.g. `\"26% off\"`"
5615
+ },
5616
+ {
5617
+ "prop": "priceFormatter",
5618
+ "type": "`(value: number) => ReactNode`",
5619
+ "default": "INR, en-IN, no decimals",
5620
+ "description": "Formats numeric prices (`2999` → `₹2,999`, `125000` → `₹1,25,000`)"
5621
+ },
5622
+ {
5623
+ "prop": "layout",
5624
+ "type": "`stack` | `row`",
5625
+ "default": "\"stack\"",
5626
+ "description": "`row`: image beside the text on phones, stacked from `sm` up"
5627
+ },
5628
+ {
5629
+ "prop": "href",
5630
+ "type": "`string`",
5631
+ "default": "",
5632
+ "description": "Link target; renders the title as an `<a>`"
5633
+ },
5634
+ {
5635
+ "prop": "to",
5636
+ "type": "`string`",
5637
+ "default": "",
5638
+ "description": "Router target passed to `as` (e.g. React Router `Link`); without `as` it is used as the `href`"
5639
+ },
5640
+ {
5641
+ "prop": "as",
5642
+ "type": "`ElementType`",
5643
+ "default": "\"a\"",
5644
+ "description": "Link component for the title, like `Button`'s `as`"
5645
+ },
5646
+ {
5647
+ "prop": "actions",
5648
+ "type": "`ReactNode`",
5649
+ "default": "",
5650
+ "description": "Buttons under the price (Add to cart, Wishlist), above the stretched link"
5651
+ },
5652
+ {
5653
+ "prop": "selectable",
5654
+ "type": "`boolean`",
5655
+ "default": "false",
5656
+ "description": "Shows a \"Compare\" checkbox on the image"
5657
+ },
5658
+ {
5659
+ "prop": "selected",
5660
+ "type": "`boolean`",
5661
+ "default": "",
5662
+ "description": "Controlled compare state; omit for uncontrolled"
5663
+ },
5664
+ {
5665
+ "prop": "onSelectedChange",
5666
+ "type": "`(selected: boolean, event) => void`",
5667
+ "default": "",
5668
+ "description": "Called when the compare checkbox is toggled"
5669
+ },
5670
+ {
5671
+ "prop": "placeholder",
5672
+ "type": "`ReactNode`",
5673
+ "default": "image-off icon",
5674
+ "description": "Shown when there is no image or the cover fails to load"
5675
+ },
5676
+ {
5677
+ "prop": "className",
5678
+ "type": "`string`",
5679
+ "default": "",
5680
+ "description": "Extra CSS classes on the card root; other props are forwarded to it"
5681
+ }
5682
+ ],
5683
+ "examples": "```jsx\n<ProductCard\n title=\"Trail Runner 2\"\n eyebrow=\"Acme\"\n meta=\"Size 9 · Ocean blue\"\n images={['/shoes/tr2-front.jpg', '/shoes/tr2-side.jpg']}\n hover=\"second-image\"\n badges={[<Badge key=\"new\" variant=\"primary\" size=\"sm\">New</Badge>]}\n price={2999}\n compareAtPrice={4049}\n as={Link}\n to=\"/p/trail-runner-2\"\n actions={<Button size=\"sm\">Add to cart</Button>}\n/>\n\n<ProductCard\n layout=\"row\"\n title=\"Road Pacer\"\n image={{ src: '/shoes/rp.jpg', alt: 'Road Pacer, side view' }}\n price={3499}\n href=\"/p/road-pacer\"\n selectable\n selected={compare.includes('rp')}\n onSelectedChange={(on) => toggleCompare('rp', on)}\n/>\n```\n\n[← Component overview](README.md)"
5684
+ },
5218
5685
  {
5219
5686
  "name": "EmptyState",
5220
5687
  "group": "blocks",
@@ -6708,17 +7175,17 @@
6708
7175
  "rich-text"
6709
7176
  ],
6710
7177
  "counts": {
6711
- "components": 69,
7178
+ "components": 74,
6712
7179
  "hooks": 4,
6713
7180
  "utils": 3,
6714
7181
  "store": 1
6715
7182
  }
6716
7183
  },
6717
7184
  "docs": {
6718
- "Accordion": "# Accordion\n\nExpandable/collapsible content panels. Only one panel open at a time by default, or multiple when `allowMultiple` is true. Supports keyboard navigation (Arrow keys, Home, End, Enter, Space). The bordered wrapper and each item use the semantic `bg-surface` / `divide-line` / `text-foreground` tokens, so the panels follow dark mode.\n\n## Import\n\n```jsx\nimport { Accordion } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `items` | `Array` | `[]` | Array of `{ id?, title, subtitle?, content, disabled?, leftIcon? }`. `leftIcon` can be ReactNode or `(index, item) => ReactNode`. |\n| `allowMultiple` | `boolean` | `false` | If true, multiple panels can be open at once. |\n| `defaultOpenIndexes` | `number[]` | `[]` | Initial open panel index(es). |\n| `onChange` | `(openIndexes: number[]) => void` | — | Called when open panels change. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n| `headerClassName` | `string` | `\"\"` | Header button CSS classes. |\n| `contentClassName` | `string` | `\"\"` | Panel content CSS classes. |\n| `chevronPosition` | `\"left\"` \\| `\"right\"` | `\"right\"` | Position of chevron icon. |\n| `chevronIcon` | `ReactNode` | default SVG | Custom chevron icon. |\n| `bordered` | `boolean` | `true` | Whether to show border/background. |\n| `rounded` | `\"none\"` \\| `\"sm\"` \\| `\"md\"` \\| `\"lg\"` \\| `\"xl\"` | `\"md\"` | Border radius of wrapper. |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Header and content padding/text size. |\n| `transitionDuration` | `number` | `220` | Max-height transition duration in ms. Ignored (no animation) when the user prefers reduced motion. |\n\n## Accessibility & behavior\n\n- Header/panel ids are prefixed with a per-instance `useId()`, so several accordions on one page never collide.\n- Collapsed panels carry the `inert` attribute, so Tab never lands on focusable content inside a closed panel.\n- Header focus ring uses `focus-visible:ring-primary-500` (keyboard focus only).\n- Once an open transition finishes, the panel's `max-height` is released to `none`, so content that grows after opening is never clipped.\n- The height transition is disabled under `prefers-reduced-motion: reduce`.\n\n## Example\n\n```jsx\n<Accordion\n items={[\n { id: '1', title: 'Section 1', content: <p>Content for section 1</p> },\n { id: '2', title: 'Section 2', subtitle: 'Optional', content: <p>Content 2</p> },\n { title: 'Disabled', content: <p>Content</p>, disabled: true },\n ]}\n allowMultiple={false}\n defaultOpenIndexes={[0]}\n onChange={(indexes) => console.log('Open:', indexes)}\n size=\"md\"\n chevronPosition=\"right\"\n/>\n```\n\n[← Component overview](README.md)\n",
7185
+ "Accordion": "# Accordion\n\nExpandable/collapsible content panels. Only one panel open at a time by default, or multiple when `allowMultiple` is true. Supports keyboard navigation (Arrow keys, Home, End, Enter, Space). The bordered wrapper and each item use the semantic `bg-surface` / `divide-line` / `text-foreground` tokens, so the panels follow dark mode.\n\n## Import\n\n```jsx\nimport { Accordion } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `items` | `Array` | `[]` | Array of `{ id?, title, subtitle?, content, disabled?, leftIcon? }`. `leftIcon` can be ReactNode or `(index, item) => ReactNode`. |\n| `allowMultiple` | `boolean` | `false` | If true, multiple panels can be open at once. |\n| `defaultOpenIndexes` | `number[]` | `[]` | Initial open panel index(es). |\n| `onChange` | `(openIndexes: number[]) => void` | — | Called when open panels change. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n| `headerClassName` | `string` | `\"\"` | Header button CSS classes. |\n| `contentClassName` | `string` | `\"\"` | Panel content CSS classes. |\n| `chevronPosition` | `\"left\"` \\| `\"right\"` | `\"right\"` | Position of chevron icon. |\n| `chevronIcon` | `ReactNode` | default SVG | Custom chevron icon. |\n| `bordered` | `boolean` | `true` | Whether to show border/background. |\n| `rounded` | `\"none\"` \\| `\"sm\"` \\| `\"md\"` \\| `\"lg\"` \\| `\"xl\"` | `\"md\"` | Border radius of wrapper. |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Header and content padding/text size. |\n| `transitionDuration` | `number` | `220` | Max-height transition duration in ms. Ignored (no animation) when the user prefers reduced motion. |\n\n## Accessibility & behavior\n\n- Header/panel ids are prefixed with a per-instance `useId()`, so several accordions on one page never collide.\n- Collapsed panels carry the `inert` attribute, so Tab never lands on focusable content inside a closed panel.\n- Header focus ring uses `focus-visible:ring-primary-500` (keyboard focus only).\n- Once an open transition finishes, the panel's `max-height` is released to `none`, so content that grows after opening is never clipped.\n- The height transition is disabled under `prefers-reduced-motion: reduce`.\n- Server rendering (Next.js) is warning-free: panels are sized in a layout effect on the client and a plain effect on the server.\n\n## Example\n\n```jsx\n<Accordion\n items={[\n { id: '1', title: 'Section 1', content: <p>Content for section 1</p> },\n { id: '2', title: 'Section 2', subtitle: 'Optional', content: <p>Content 2</p> },\n { title: 'Disabled', content: <p>Content</p>, disabled: true },\n ]}\n allowMultiple={false}\n defaultOpenIndexes={[0]}\n onChange={(indexes) => console.log('Open:', indexes)}\n size=\"md\"\n chevronPosition=\"right\"\n/>\n```\n\n[← Component overview](README.md)\n",
6719
7186
  "ActionSheet": "# ActionSheet\n\niOS-style action sheet (the `UIAlertController` action-sheet pattern) on the bottom `Sheet`: an optional title and message, a card of actions — destructive ones in the error tone — and a separate Cancel button. Use it for \"what do you want to do with this?\" choices on phones; it is the same implementation `ThreeDotPopover` and `Table`'s row menu use for their phone presentation.\n\n## Import\n\n```jsx\nimport { ActionSheet } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `open` | `boolean` | — | Controls visibility |\n| `isOpen` | `boolean` | — | Alias of `open` |\n| `onClose` | `(reason) => void` | — | Called when the sheet should close; `reason` is `'select'` (after an action's `onSelect`), `'cancel'`, `'escape'`, `'backdrop'` or `'drag'` |\n| `title` | `ReactNode` | — | Small heading above the actions; names the sheet (`aria-labelledby`) unless `aria-label` is set |\n| `message` | `ReactNode` | — | Muted text under the title; wired to `aria-describedby` |\n| `actions` | `Array` | `[]` | `[{ key?, label, icon?, tone?: 'default' or 'destructive', onSelect, disabled? }]`. `icon` is a node (`<Trash2 />`) or a component (`Trash2`) |\n| `cancelLabel` | `ReactNode` | `\"Cancel\"` | Text of the separate Cancel button (always shown) |\n| `dismissible` | `boolean` | `true` | `false` disables Escape, backdrop click and drag dismissal |\n| `className` | `string` | — | Classes merged onto the sheet panel |\n\nOther props (`aria-label`, `data-*`, …) are forwarded to the sheet panel (`role=\"dialog\"`), and the ref points at it.\n\n### Behavior\n\n- Selecting an enabled action calls its `onSelect`, then `onClose('select')`. Disabled actions do nothing.\n- Cancel, Escape, a backdrop tap and a downward drag all call `onClose` with their reason; none of them calls an action.\n- You own the open state: set `open` to `false` in `onClose`.\n- With no `title` and no `aria-label`, the sheet is named \"Actions\".\n\n## Accessibility\n\n- The panel is a modal `role=\"dialog\"`; focus moves to the first action on open, Tab stays inside, and Escape closes.\n- Focus returns to the element that was focused before the sheet opened (usually the button that opened it).\n- Rows are at least 48px tall with a focus-visible ring; destructive rows pair the error colour with their label text.\n- The slide-in and slide-out animations are off under `prefers-reduced-motion`.\n\n## Examples\n\n```jsx\nfunction PhotoMenu({ photo }) {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Button onClick={() => setOpen(true)}>Options</Button>\n <ActionSheet\n open={open}\n onClose={() => setOpen(false)}\n title=\"Photo options\"\n message=\"Choose what to do with this photo.\"\n actions={[\n { key: 'share', label: 'Share', icon: Share2, onSelect: () => share(photo) },\n { key: 'archive', label: 'Archive', onSelect: () => archive(photo), disabled: photo.archived },\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(photo) },\n ]}\n />\n </>\n );\n}\n```\n\n```jsx\n// Custom cancel text, no title (named by aria-label)\n<ActionSheet\n open={open}\n onClose={() => setOpen(false)}\n aria-label=\"Sort by\"\n cancelLabel=\"Never mind\"\n actions={[\n { label: 'Newest first', onSelect: () => setSort('new') },\n { label: 'Oldest first', onSelect: () => setSort('old') },\n ]}\n/>\n```\n\n[← Component overview](README.md)\n",
6720
7187
  "Alert": "# Alert\n\nInline alert box for success, error, warning, info or neutral messages. Optional title, an actions slot after the description, and a dismiss button. Every variant has a dark tone, and the alert fades in (off under `prefers-reduced-motion`).\n\n## Import\n\n```jsx\nimport { Alert } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `variant` | `string` | `\"info\"` | `success`, `error`, `warning`, `info`, `neutral` (role tokens: surface, line, muted). Unknown values fall back to `info` |\n| `title` | `string` | — | Optional title |\n| `children` | `ReactNode` | — | Alert body content (the description) |\n| `actions` | `ReactNode` | — | Rendered after the description in a wrapping row (buttons, links) |\n| `dismissible` | `boolean` | `false` | Show close button |\n| `onDismiss` | `function` | — | Called when dismissed |\n| `dismissLabel` | `string` | `\"Dismiss\"` | Accessible name of the dismiss button |\n| `className` | `string` | — | Extra CSS classes |\n\nOther props are forwarded to the root `div`; the ref points at it.\n\n## Accessibility\n\n- `error` and `warning` render with `role=\"alert\"` (announced immediately); `info`, `success` and `neutral` use `role=\"status\"` (polite), so routine messages don't interrupt a screen reader. Pass `role` to override.\n- The dismiss button is labelled \"Dismiss\" (`dismissLabel`), has a focus-visible ring and a 44px hit area on touch screens.\n- The enter animation (`animate-fade-in`) is off under `prefers-reduced-motion`.\n\n## Examples\n\n```jsx\n<Alert variant=\"info\" title=\"Note\">Your session will expire in 5 minutes.</Alert>\n<Alert variant=\"success\">Saved successfully.</Alert>\n<Alert variant=\"error\" title=\"Error\">Something went wrong.</Alert>\n<Alert variant=\"warning\" dismissible onDismiss={() => {}}>\n Please review your input.\n</Alert>\n<Alert variant=\"neutral\" title=\"Heads up\">Billing moves to the 1st of each month.</Alert>\n\n<Alert\n variant=\"error\"\n title=\"Upload failed\"\n actions={\n <>\n <Button size=\"sm\" onClick={retry}>Retry</Button>\n <Button size=\"sm\" variant=\"ghost\" onClick={openHelp}>Get help</Button>\n </>\n }\n>\n The file is larger than 10 MB.\n</Alert>\n```\n\n[← Component overview](README.md)\n",
6721
- "AppShell": "# AppShell\n\nPage-level block: the product app shell — a sidebar on large screens, a top bar with a navigation drawer below `lg`, and an optional bottom nav on phones. Replaces the shell 12 of 13 products hand-rolled (extracted from procurementpro `components/app-shell.tsx`). Colours come from the `shell` and role tokens (`bg-background`, `bg-surface`, `text-foreground`, `text-muted`, `border-line`), so a brand file restyles it and dark mode needs no extra classes.\n\nThe shell fills the dynamic viewport (`min-h-dvh`; the sidebar is `h-dvh`), so mobile browser toolbars don't cut it off. It pads the safe areas: the top bar has `pt-safe` and `px-safe`, the sidebar `pt-safe pb-safe pl-safe`, the content column `px-safe`. Add `viewport-fit=cover` to your viewport meta.\n\n## Import\n\n```jsx\nimport { AppShell } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `brand` | `ReactNode` | — | Product mark at the top of the sidebar |\n| `nav` | `AppShellNavItem[]` | — | `{ id, label, href?, icon?, active?, count?, group?, onClick? }`. Items with `href` render as links; `active` sets `aria-current=\"page\"` |\n| `sidebarFooter` | `ReactNode` | — | Pinned to the bottom of the sidebar (user menu, workspace switcher) |\n| `topbar` | `ReactNode` | — | Right side of the top bar (search, notifications, avatar) |\n| `pageTitle` | `string` | — | Title shown in the mobile top bar |\n| `bottomNav` | `ReactNode` | — | Bottom navigation for phones, usually a [`FootNav`](FootNav.md). Rendered below `md` only (hidden from `md` up, where the sidebar / drawer take over). When set, a `FootNav.Spacer` is added after `<main>` so content doesn't scroll under the bar |\n| `linkAs` | `ElementType` | `\"a\"` | Link element for nav items, e.g. Next.js `Link` |\n| `className` | `string` | — | Extra CSS classes for the root (merged with `cn`) |\n| `children` | `ReactNode` | — | Page content, rendered in `<main id=\"main\">` |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>`; the ref is forwarded to it |\n\n## Example\n\n```jsx\n<AppShell\n brand={<span className=\"text-lg font-bold\">PracticePro</span>}\n nav={[\n { id: 'overview', label: 'Overview', href: '/overview', icon: <Home className=\"h-5 w-5\" /> },\n { id: 'compliance', label: 'Compliance', href: '/compliance', active: true },\n { id: 'approvals', label: 'Approvals', href: '/approvals', count: 4 },\n ]}\n topbar={<Avatar name=\"Ananya Rao\" size=\"sm\" />}\n linkAs={Link}\n>\n <PageHeader title=\"Compliance calendar\" />\n</AppShell>\n```\n\n### Bottom nav on phones\n\n```jsx\n<AppShell\n brand={<Logo />}\n nav={nav}\n linkAs={Link}\n bottomNav={\n <FootNav\n linkAs={Link}\n items={[\n { id: 'home', label: 'Home', icon: <Home />, href: '/', active: true },\n { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, href: '/tasks', badge: 3 },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, href: '/inbox' },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```\n\nKeep the bottom nav to the 3–5 most used destinations; the full `nav` stays in the drawer (menu button) below `lg` and in the sidebar on desktop.\n\n## Mobile drawer accessibility\n\nBelow `lg`, the \"Open navigation\" button (`aria-haspopup=\"dialog\"`, `aria-expanded`) opens the sidebar in a left [`Sheet`](Sheet.md) (`role=\"dialog\"`, `aria-modal=\"true\"`, `aria-label=\"Navigation\"`), coloured with the shell tokens. While it is open, focus moves into the drawer, Tab / Shift+Tab stay inside it, **Escape** closes it and returns focus to the menu button, and body scroll is locked. The lock is ref-counted and shared with `Dialog`, `Drawer`, `Sheet` and `Sidebar`, so stacked overlays release the page only when the last one closes. Clicking the backdrop, the close button or a nav item, or dragging the drawer to the left, also closes it. It slides in and out (instant under `prefers-reduced-motion`), so it stays mounted for its short exit transition. The menu, close and nav controls grow to 44px on coarse pointers and show a `focus-visible` ring.\n\n[← Component overview](README.md)\n",
7188
+ "AppShell": "# AppShell\n\nPage-level block: the product app shell — a sidebar on large screens, a top bar with a navigation drawer below `lg`, and an optional bottom nav on phones. Replaces the shell 12 of 13 products hand-rolled (extracted from procurementpro `components/app-shell.tsx`). Colours come from the `shell` and role tokens (`bg-background`, `bg-surface`, `text-foreground`, `text-muted`, `border-line`), so a brand file restyles it and dark mode needs no extra classes.\n\nThe shell fills the dynamic viewport (`min-h-dvh`; the sidebar is `h-dvh`), so mobile browser toolbars don't cut it off. It pads the safe areas: the top bar has `pt-safe` and `px-safe`, the sidebar `pt-safe pb-safe pl-safe`, the content column `px-safe`. Add `viewport-fit=cover` to your viewport meta.\n\n## Import\n\n```jsx\nimport { AppShell } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `brand` | `ReactNode` | — | Product mark at the top of the sidebar |\n| `nav` | `AppShellNavItem[]` | — | `{ id, label, href?, icon?, active?, count?, group?, onClick? }`. Items with `href` render as links; `active` sets `aria-current=\"page\"` |\n| `sidebarFooter` | `ReactNode` | — | Pinned to the bottom of the sidebar (user menu, workspace switcher) |\n| `topbar` | `ReactNode` | — | Right side of the top bar (search, notifications, avatar) |\n| `pageTitle` | `string` | — | Title shown in the mobile top bar |\n| `bottomNav` | `ReactNode` | — | Bottom navigation for phones, usually a [`FootNav`](FootNav.md). Rendered below `md` only (hidden from `md` up, where the sidebar / drawer take over). When set, a `FootNav.Spacer` is added after `<main>` so content doesn't scroll under the bar |\n| `linkAs` | `ElementType` | `\"a\"` | Link element for nav items, e.g. Next.js `Link` |\n| `navOpen` | `boolean` | — | Controls the mobile navigation drawer, e.g. so a `FootNav` \"More\" tab can open it. Unset, the shell's menu button owns it |\n| `onNavOpenChange` | `(open: boolean) => void` | — | Called when the drawer should open or close (menu button, close button, Escape, backdrop, choosing a nav item). Called in uncontrolled mode too |\n| `topbarFrom` | `\"always\"` \\| `\"below-lg\"` | `\"always\"` | `\"below-lg\"` hides the top bar from `lg` up, where the sidebar carries the brand and nav, so a desktop page has no empty strip |\n| `className` | `string` | — | Extra CSS classes for the root (merged with `cn`) |\n| `children` | `ReactNode` | — | Page content, rendered in `<main id=\"main\">` |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>`; the ref is forwarded to it |\n\n## Example\n\n```jsx\n<AppShell\n brand={<span className=\"text-lg font-bold\">PracticePro</span>}\n nav={[\n { id: 'overview', label: 'Overview', href: '/overview', icon: <Home className=\"h-5 w-5\" /> },\n { id: 'compliance', label: 'Compliance', href: '/compliance', active: true },\n { id: 'approvals', label: 'Approvals', href: '/approvals', count: 4 },\n ]}\n topbar={<Avatar name=\"Ananya Rao\" size=\"sm\" />}\n linkAs={Link}\n>\n <PageHeader title=\"Compliance calendar\" />\n</AppShell>\n```\n\n### Bottom nav on phones\n\n```jsx\n<AppShell\n brand={<Logo />}\n nav={nav}\n linkAs={Link}\n bottomNav={\n <FootNav\n linkAs={Link}\n items={[\n { id: 'home', label: 'Home', icon: <Home />, href: '/', active: true },\n { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, href: '/tasks', badge: 3 },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, href: '/inbox' },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```\n\nKeep the bottom nav to the 3–5 most used destinations; the full `nav` stays in the drawer (menu button) below `lg` and in the sidebar on desktop.\n\n### Opening the drawer from a \"More\" tab\n\nPass `navOpen` / `onNavOpenChange` to control the drawer, and `topbarFrom=\"below-lg\"` when the desktop page has no top bar:\n\n```jsx\nconst [navOpen, setNavOpen] = useState(false);\n\n<AppShell\n brand={<Logo />}\n nav={nav}\n navOpen={navOpen}\n onNavOpenChange={setNavOpen}\n topbarFrom=\"below-lg\"\n bottomNav={\n <FootNav\n items={[\n { id: 'today', label: 'Today', icon: <Home />, href: '/', active: true },\n { id: 'more', label: 'More', icon: <Menu />, onClick: () => setNavOpen(true) },\n ]}\n />\n }\n>\n {page}\n</AppShell>\n```\n\n## Mobile drawer accessibility\n\nBelow `lg`, the \"Open navigation\" button (`aria-haspopup=\"dialog\"`, `aria-expanded`) opens the sidebar in a left [`Sheet`](Sheet.md) (`role=\"dialog\"`, `aria-modal=\"true\"`, `aria-label=\"Navigation\"`), coloured with the shell tokens. While it is open, focus moves into the drawer, Tab / Shift+Tab stay inside it, **Escape** closes it and returns focus to the menu button, and body scroll is locked. The lock is ref-counted and shared with `Dialog`, `Drawer`, `Sheet` and `Sidebar`, so stacked overlays release the page only when the last one closes. Clicking the backdrop, the close button or a nav item, or dragging the drawer to the left, also closes it. It slides in and out (instant under `prefers-reduced-motion`), so it stays mounted for its short exit transition. The menu, close and nav controls grow to 44px on coarse pointers and show a `focus-visible` ring.\n\n[← Component overview](README.md)\n",
6722
7189
  "AreaChart": "# AreaChart\n\nThemed, responsive area chart (optionally stacked) with token colours, an accessible data-table fallback and reduced-motion support. Wraps `recharts` (optional peer, `/charts` entry point).\n\n## Install\n\nCharts live on a separate entry point so apps that don't use them never install or bundle `recharts`:\n\n```bash\nnpm install recharts\n# React 18 apps: also pin react-is to your React major (recharts resolves it as a peer)\nnpm install react-is@18\n```\n\n## Import\n\n```jsx\nimport { AreaChart } from '@dreamtree-org/twreact-ui/charts';\n```\n\nNever import charts from the root `@dreamtree-org/twreact-ui` — they are not exported there.\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `object[]` | — | **Required.** Rows to plot. An empty array renders `emptyState`. |\n| `index` | `string` | — | **Required.** Key of the x-axis (category) value in each row, e.g. `\"month\"`. |\n| `categories` | `string[]` | — | **Required.** Keys of the numeric series, one area per key. |\n| `colors` | `ChartColor[]` | `primary, success, warning, error, info, secondary, gray` | Series colours by position (cycled). A token (`\"primary\"`, `\"success-600\"`) resolves to the `--dt-*` CSS variable and follows the theme and dark mode; any other string is used as a CSS colour. |\n| `xAxisFormatter` | `(value) => string` | — | Formats category (x-axis) tick labels and the hidden table's row headers. |\n| `showLegend` | `boolean` | `true` | Legend below the chart (real DOM text). |\n| `showXAxis` | `boolean` | `true` | Show the x-axis. |\n| `showYAxis` | `boolean` | `true` | Show the y-axis. |\n| `showGrid` | `boolean` | `true` | Dashed grid lines (`--dt-line`). |\n| `yAxisWidth` | `number` | `48` (`36` when the chart is narrower than 400 px) | Width reserved for value-axis labels, px. When set, it is used at every width. |\n| `curveType` | `\"linear\"`, `\"monotone\"`, `\"step\"` or `\"natural\"` | `\"monotone\"` | Area interpolation. |\n| `stacked` | `boolean` | `false` | Stack the series on top of each other. |\n| `showDots` | `boolean` | `false` | Draw a dot on every data point. |\n| `connectNulls` | `boolean` | `false` | Draw across `null` gaps. |\n| `valueFormatter` | `(value: number) => string` | `toLocaleString` | Formats values in the tooltip, value axis and hidden data table. |\n| `height` | `number` or `string` | `300` | Chart height (px number or any CSS length). The width is always 100% of the parent (responsive). |\n| `aspectRatio` | `number` | — | Width / height ratio, e.g. `16 / 9`. When provided it replaces `height`, so the chart scales with its container (handy on phones); the empty state uses it too. |\n| `title` | `string` | — | Accessible name: `aria-label` of the `role=\"img\"` region and caption of the hidden table. Falls back to `aria-label`, then `\"Area chart\"`. |\n| `description` | `string` | — | Longer text alternative (the takeaway), linked with `aria-describedby`. |\n| `emptyState` | `ReactNode` | `\"No data to display\"` | Shown (as `role=\"status\"`) instead of the chart when `data` is empty. |\n| `accessibleTable` | `boolean` | `true` | Repeat the numbers in a visually-hidden `<table>` for screen-reader users. |\n| `animate` | `boolean` | `true` | Animate on mount / data change. Always off when the user prefers reduced motion. |\n| `showTooltip` | `boolean` | `true` | Tooltip (themed, dark-mode aware): on hover with a mouse; on touch a tap pins it at the tapped point, and a second tap on the chart or a tap outside unpins it. |\n| `className` | `string` | — | Extra classes for the root `<div>` (merged with `cn`). |\n| `ref` | `Ref<HTMLDivElement>` | — | Forwarded to the root `<div>`. |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`id`, `data-*`, `aria-*`, handlers). |\n\n## Small screens\n\n- x-axis ticks keep a wider gap on narrow charts (`minTickGap` 32 px below 360 px wide, 16 px below 640 px, otherwise 8 px), so labels don't collide.\n- The value axis narrows to 36 px below 400 px wide unless you pass `yAxisWidth`.\n- Use `aspectRatio` instead of a fixed `height` to keep the chart proportional to its width.\n\n## Examples\n\n### Revenue vs forecast\n\n```jsx\nconst data = [\n { month: 'Jan', revenue: 4200, costs: 2600 },\n { month: 'Feb', revenue: 5100, costs: 2900 },\n { month: 'Mar', revenue: 4800, costs: 3100 },\n];\n\n<AreaChart data={data} index=\"month\" categories={['revenue', 'costs']} colors={['success', 'gray-400']} title=\"Revenue vs costs\" />\n```\n\n### Stacked\n\n```jsx\n<AreaChart data={data} index=\"month\" categories={['revenue', 'costs']} stacked title=\"Total spend\" />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Name | The SVG sits in a `role=\"img\"` region named by `title` (falls back to `aria-label`, then a generic name such as \"Area chart\") |\n| Description | `description` is rendered visually hidden and linked with `aria-describedby` |\n| Numbers | `accessibleTable` (default on) repeats the data in a visually-hidden `<table>` with a caption, column headers and row headers, formatted with `valueFormatter`; recharts' SVG itself has no accessible content |\n| Legend | Real DOM text, not SVG |\n| Motion | Animation is disabled when `prefers-reduced-motion: reduce` is set (and with `animate={false}`) |\n| Colour | Series colours come from the design tokens; pair the chart with the table / description so colour is never the only signal |\n| Dark mode | Axis text (`--dt-muted`), grid (`--dt-line`) and series tokens are re-read when the `dark` class or `data-theme` on `<html>` changes; tooltip and legend use `dark:` variants |\n| Empty | An empty `data` array renders `emptyState` in a `role=\"status\"` box with the same accessible name |\n\n## Server rendering\n\nSafe to render on the server (a Next.js `\"use client\"` page). The first client render matches the server's HTML: token colours stay `rgb(var(--dt-*))` and reduced motion reads as off until hydration finishes, then the live values apply. SVG ids come from `React.useId()` (`dt-chart-…`), not a module counter.\n\n[← Component overview](README.md)\n",
6723
7190
  "Avatar": "# Avatar\n\nUser avatar: image or initials fallback. Optional status indicator.\n\n## Import\n\n```jsx\nimport { Avatar } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `src` | `string` | — | Image URL |\n| `alt` | `string` | — | Image alt |\n| `name` | `string` | — | Used for initials when no src |\n| `size` | `string` | `\"md\"` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl` |\n| `shape` | `string` | `\"circle\"` | `circle`, `rounded`, `square` |\n| `status` | `string` | — | `online`, `offline`, `away`, `busy`. The dot is ringed in the `surface` colour, so it reads correctly in dark mode |\n| `className` | `string` | — | Extra CSS classes |\n\nIf `src` fails to load, the avatar falls back to the initials (or the user icon) through React state; a new `src` retries the image.\n\n## Example\n\n```jsx\n<Avatar name=\"Alice Smith\" />\n<Avatar src=\"/photo.jpg\" alt=\"User\" />\n<Avatar name=\"Bob\" status=\"online\" size=\"lg\" />\n```\n\nTo stack several avatars with a \"+N\" overflow, use [AvatarGroup](AvatarGroup.md).\n\n[← Component overview](README.md)\n",
6724
7191
  "AvatarGroup": "# AvatarGroup\n\nOverlapping stack of `Avatar`s (assignees, participants, members). Each avatar is ringed in the `surface` colour so it reads as cut out of the one behind it, in light and dark mode. With `max`, extra avatars collapse into a \"+N\" tile.\n\n## Import\n\n```jsx\nimport { Avatar, AvatarGroup } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | `Avatar` elements. Non-element children (`null`, `false`) are ignored |\n| `max` | `number` | — | Show at most this many avatars, then a \"+N\" overflow tile (`role=\"img\"`, `aria-label=\"N more\"`). Omit to show all |\n| `size` | `string` | `\"md\"` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl`. Passed to every child that doesn't set its own `size`; also sizes the overflow tile and the overlap |\n| `className` | `string` | — | Extra CSS classes on the root (`role=\"group\"`) |\n| ...rest | — | — | Passed to the root `<div>` (e.g. `aria-label=\"Assignees\"`). The ref is forwarded to it |\n\n## Example\n\n```jsx\n<AvatarGroup max={3} size=\"sm\" aria-label=\"Assignees\">\n <Avatar name=\"Asha Rao\" />\n <Avatar name=\"Bilal Khan\" src=\"/bilal.jpg\" />\n <Avatar name=\"Chen Li\" />\n <Avatar name=\"Divya Nair\" />\n <Avatar name=\"Esha Patel\" />\n</AvatarGroup>\n{/* Asha, Bilal, Chen, then a \"+2\" tile */}\n```\n\n[← Component overview](README.md)\n",
@@ -6726,7 +7193,7 @@
6726
7193
  "BarChart": "# BarChart\n\nThemed, responsive bar chart — grouped, stacked or horizontal — with token colours, an accessible data-table fallback and reduced-motion support. Wraps `recharts` (optional peer, `/charts` entry point).\n\n## Install\n\nCharts live on a separate entry point so apps that don't use them never install or bundle `recharts`:\n\n```bash\nnpm install recharts\n# React 18 apps: also pin react-is to your React major (recharts resolves it as a peer)\nnpm install react-is@18\n```\n\n## Import\n\n```jsx\nimport { BarChart } from '@dreamtree-org/twreact-ui/charts';\n```\n\nNever import charts from the root `@dreamtree-org/twreact-ui` — they are not exported there.\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `object[]` | — | **Required.** Rows to plot. An empty array renders `emptyState`. |\n| `index` | `string` | — | **Required.** Key of the x-axis (category) value in each row, e.g. `\"month\"`. |\n| `categories` | `string[]` | — | **Required.** Keys of the numeric series, one bar per key. |\n| `colors` | `ChartColor[]` | `primary, success, warning, error, info, secondary, gray` | Series colours by position (cycled). A token (`\"primary\"`, `\"success-600\"`) resolves to the `--dt-*` CSS variable and follows the theme and dark mode; any other string is used as a CSS colour. |\n| `xAxisFormatter` | `(value) => string` | — | Formats category (x-axis) tick labels and the hidden table's row headers. |\n| `showLegend` | `boolean` | `true` | Legend below the chart (real DOM text). |\n| `showXAxis` | `boolean` | `true` | Show the x-axis. |\n| `showYAxis` | `boolean` | `true` | Show the y-axis. |\n| `showGrid` | `boolean` | `true` | Dashed grid lines (`--dt-line`). |\n| `yAxisWidth` | `number` | `48` (`36` when the chart is narrower than 400 px) | Width reserved for value-axis labels, px. When set, it is used at every width. |\n| `stacked` | `boolean` | `false` | Stack the series in one bar per category. |\n| `horizontal` | `boolean` | `false` | Horizontal bars: categories on the y-axis (every label shown), values on the x-axis. |\n| `valueFormatter` | `(value: number) => string` | `toLocaleString` | Formats values in the tooltip, value axis and hidden data table. |\n| `height` | `number` or `string` | `300` | Chart height (px number or any CSS length). The width is always 100% of the parent (responsive). |\n| `aspectRatio` | `number` | — | Width / height ratio, e.g. `16 / 9`. When provided it replaces `height`, so the chart scales with its container (handy on phones); the empty state uses it too. |\n| `title` | `string` | — | Accessible name: `aria-label` of the `role=\"img\"` region and caption of the hidden table. Falls back to `aria-label`, then `\"Bar chart\"`. |\n| `description` | `string` | — | Longer text alternative (the takeaway), linked with `aria-describedby`. |\n| `emptyState` | `ReactNode` | `\"No data to display\"` | Shown (as `role=\"status\"`) instead of the chart when `data` is empty. |\n| `accessibleTable` | `boolean` | `true` | Repeat the numbers in a visually-hidden `<table>` for screen-reader users. |\n| `animate` | `boolean` | `true` | Animate on mount / data change. Always off when the user prefers reduced motion. |\n| `showTooltip` | `boolean` | `true` | Tooltip (themed, dark-mode aware): on hover with a mouse; on touch a tap pins it at the tapped point, and a second tap on the chart or a tap outside unpins it. |\n| `className` | `string` | — | Extra classes for the root `<div>` (merged with `cn`). |\n| `ref` | `Ref<HTMLDivElement>` | — | Forwarded to the root `<div>`. |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`id`, `data-*`, `aria-*`, handlers). |\n\n## Small screens\n\n- x-axis ticks keep a wider gap on narrow charts (`minTickGap` 32 px below 360 px wide, 16 px below 640 px, otherwise 8 px), so labels don't collide.\n- The value axis narrows to 36 px below 400 px wide unless you pass `yAxisWidth`.\n- Use `aspectRatio` instead of a fixed `height` to keep the chart proportional to its width.\n\n## Examples\n\n### Grouped\n\n```jsx\nconst data = [\n { month: 'Jan', revenue: 4200, costs: 2600 },\n { month: 'Feb', revenue: 5100, costs: 2900 },\n { month: 'Mar', revenue: 4800, costs: 3100 },\n];\n\n<BarChart data={data} index=\"month\" categories={['revenue', 'costs']} colors={['primary', 'warning']} title=\"Monthly revenue and costs\" />\n```\n\n### Stacked, horizontal\n\n```jsx\n<BarChart data={data} index=\"month\" categories={['revenue', 'costs']} stacked horizontal height={240} title=\"Revenue + costs\" />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Name | The SVG sits in a `role=\"img\"` region named by `title` (falls back to `aria-label`, then a generic name such as \"Bar chart\") |\n| Description | `description` is rendered visually hidden and linked with `aria-describedby` |\n| Numbers | `accessibleTable` (default on) repeats the data in a visually-hidden `<table>` with a caption, column headers and row headers, formatted with `valueFormatter`; recharts' SVG itself has no accessible content |\n| Legend | Real DOM text, not SVG |\n| Motion | Animation is disabled when `prefers-reduced-motion: reduce` is set (and with `animate={false}`) |\n| Colour | Series colours come from the design tokens; pair the chart with the table / description so colour is never the only signal |\n| Dark mode | Axis text (`--dt-muted`), grid (`--dt-line`) and series tokens are re-read when the `dark` class or `data-theme` on `<html>` changes; tooltip and legend use `dark:` variants |\n| Empty | An empty `data` array renders `emptyState` in a `role=\"status\"` box with the same accessible name |\n\n## Server rendering\n\nSafe to render on the server (a Next.js `\"use client\"` page). The first client render matches the server's HTML: token colours stay `rgb(var(--dt-*))` and reduced motion reads as off until hydration finishes, then the live values apply. SVG ids come from `React.useId()` (`dt-chart-…`), not a module counter.\n\n[← Component overview](README.md)\n",
6727
7194
  "BottomActionBar": "# BottomActionBar\n\nPage-level block: a bar pinned to the bottom of the screen that holds the page's primary action on forms and checkout pages (\"Save\", \"Place order\", \"Pay now\"). It sits on the `z-sticky` layer, pads the home indicator (`pb-safe`) and the side notches (`px-safe`), and uses role tokens (`bg-surface`, a top `border-line`, a soft upward shadow), so it is dark-mode aware. On phones it **rides above the on-screen keyboard**: it follows `window.visualViewport` (resize and scroll) and lifts itself by the height the keyboard covers. This is SSR-safe: on the server and without `visualViewport` it rests at the bottom.\n\nPut a `<BottomActionBar.Spacer />` at the end of the page content so the last rows aren't hidden behind the bar.\n\n## Import\n\n```jsx\nimport { BottomActionBar } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | The actions, usually one or two `Button`s. Put the primary action last |\n| `position` | `'fixed' \\| 'sticky'` | `\"fixed\"` | `fixed`: pinned to the bottom of the screen (`fixed inset-x-0 bottom-0`); pair it with `BottomActionBar.Spacer`. `sticky`: `sticky bottom-0` inside its scroll container; it keeps its place in the flow and needs no Spacer |\n| `align` | `'start' \\| 'center' \\| 'end' \\| 'between'` | `\"end\"` | Horizontal alignment of the actions. `between` puts e.g. a total on the left and the action on the right |\n| `fullWidth` | `boolean` | `false` | Below `sm` (phones), the actions share the row's width equally |\n| `stackOnMobile` | `boolean` | `false` | Below `sm`, the actions stack full-width with the last (primary) one on top |\n| `keyboardAware` | `boolean` | `true` | Rise above the on-screen keyboard via `window.visualViewport`. While lifted the bar drops its `pb-safe` pad (the keyboard covers the home indicator) and sets `data-keyboard=\"open\"` |\n| `className` | `string` | — | Extra classes for the bar (merged with `cn`, so yours win) |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the bar (`aria-label`, `role`, `data-*`, …). The ref is forwarded to it |\n\nThe bar also sets `data-position=\"fixed\" | \"sticky\"`. Its content row is centred with `max-w-7xl` and the same horizontal padding as `AppShell`'s `<main>`.\n\n## BottomActionBar.Spacer\n\n`<BottomActionBar.Spacer />` renders an `aria-hidden` block as tall as the fixed bar. The bar measures itself (with `ResizeObserver`) and publishes its height as the CSS variable `--dt-bottom-action-bar-height` on `<html>`; the Spacer reads it, so it matches stacked layouts and the safe area. Before the bar has measured, it falls back to `4.5rem` plus the bottom safe area. Accepts `className` (e.g. `sm:hidden` when the bar only shows on phones), `...rest` and a ref.\n\n## Examples\n\n### Form\n\n```jsx\n<form onSubmit={save}>\n <Input label=\"Customer name\" />\n <Input label=\"Amount\" inputMode=\"decimal\" />\n <BottomActionBar.Spacer />\n <BottomActionBar fullWidth>\n <Button variant=\"outline\" onClick={cancel}>Cancel</Button>\n <Button type=\"submit\">Save payment</Button>\n </BottomActionBar>\n</form>\n```\n\n### Checkout\n\n```jsx\n<BottomActionBar align=\"between\" aria-label=\"Checkout\">\n <div>\n <p className=\"text-xs text-muted\">Total</p>\n <p className=\"text-lg font-semibold\">₹4,820.00</p>\n </div>\n <Button>Pay now</Button>\n</BottomActionBar>\n```\n\n### Stacked on phones\n\n```jsx\n<BottomActionBar stackOnMobile>\n <Button variant=\"outline\">Save draft</Button>\n <Button>Send invoice</Button>\n</BottomActionBar>\n```\n\n### Inside a scrolling panel\n\n```jsx\n<div className=\"h-96 overflow-y-auto\">\n {steps}\n <BottomActionBar position=\"sticky\">\n <Button variant=\"outline\">Back</Button>\n <Button>Continue</Button>\n </BottomActionBar>\n</div>\n```\n\n`PageHeader` uses this bar for `actionsPlacement=\"bottom\"`.\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Semantics | A plain `<div>`; its actions keep their own roles and DOM order, so Tab order is the visual order (with `stackOnMobile` the primary action is shown on top but stays last in Tab order). Pass `role=\"region\"` + `aria-label` if the bar needs a landmark |\n| Keyboard | The bar lifts above the on-screen keyboard, so the action stays visible and tappable while a field is focused |\n| Touch | Use `Button`s, which are 44px tall on coarse pointers |\n| Motion | The lift animates `bottom` for `duration-fast`; off under `prefers-reduced-motion` |\n| Dark mode | `bg-surface`, `border-line` and `text-foreground` switch with the theme; the shadow deepens via `--dt-shadow-strength` |\n\n[← Component overview](README.md)\n",
6728
7195
  "Breadcrumbs": "# Breadcrumbs\n\nBreadcrumb navigation trail. Optional home link and custom separator.\n\n## Import\n\n```jsx\nimport { Breadcrumbs } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `items` | `Array` | `[]` | `[{ id?, label, href?, icon?, onClick? }]` — `id` is optional; it is the React key when present, otherwise an index-based key is used (no key warning) |\n| `separator` | `ReactNode` | `<ChevronRight />` | Separator between items |\n| `showHome` | `boolean` | `true` | Prepend Home link |\n| `homeIcon` | `ReactNode` | — | Icon for Home |\n| `onHomeClick` | `function` | — | Home click handler |\n| `maxItems` | `number` | — (no collapse) | When the trail (Home included) has more crumbs than this, show the first crumb, an \"…\" button, and the last `maxItems - 1` crumbs. Clicking \"…\" expands the full trail and moves focus to the first revealed crumb. Values below 2 are treated as 2. |\n| `className` | `string` | — | Extra CSS classes |\n\nLong labels truncate with an ellipsis (`max-w-[10rem]`, `sm:max-w-[16rem]`), and the full text stays available as a `title` tooltip when the label is a string. Links underline on hover, show a `focus-visible` ring, and have dark-mode colours. The \"…\" button grows to a 44 px touch target on coarse pointers.\n\n## Example\n\n```jsx\n<Breadcrumbs\n items={[\n { id: '1', label: 'Products', href: '/products' },\n { id: '2', label: 'Category', href: '/products/cat' },\n { id: '3', label: 'Current Page' },\n ]}\n showHome\n/>\n```\n\nCollapsed trail (Home / … / Board / Card):\n\n```jsx\n<Breadcrumbs maxItems={3} items={[\n { id: 'org', label: 'Org', href: '/o' },\n { id: 'team', label: 'Team', href: '/o/t' },\n { id: 'board', label: 'Board', href: '/o/t/b' },\n { id: 'card', label: 'Card' },\n]} />\n```\n\n[← Component overview](README.md)\n",
6729
- "Button": "# Button\n\nButton with variants, sizes, loading state, and optional left/right icons.\n\n## Import\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `variant` | `string` | `\"primary\"` | `primary`, `secondary`, `outline`, `ghost`, `destructive`, `success`, `warning` |\n| `size` | `string` | `\"md\"` | `xs` (h-7), `sm` (h-8), `md` (h-10), `lg` (h-12), `xl` (h-14), `2xl` (h-16) — every value of the shared `Size` type — plus the square icon-only sizes `icon-sm` (32px), `icon` (40px), `icon-lg` (48px); pair an icon size with `aria-label`. `xs`, `sm`, `icon-sm` and `icon` grow to 44px on coarse pointers (touch) |\n| `disabled` | `boolean` | — | Disable button |\n| `loading` | `boolean` | — | Disable and overlay a centred spinner. The label (and icons) stay in the layout at `opacity-0`, so the button keeps its width and its accessible name; sets `aria-busy=\"true\"` |\n| `leftIcon` | `ReactNode` | — | Icon before children |\n| `rightIcon` | `ReactNode` | — | Icon after children |\n| `fullWidth` | `boolean` | — | Full width |\n| `as` | `ElementType` | `\"button\"` | Render as another element or component with the Button's variants, sizes and focus ring, e.g. `\"a\"` or a router `Link` |\n| `href` | `string` | — | Link target. A bare `href` (no `as`) renders an `<a>` |\n| `className` | `string` | — | Extra CSS classes |\n| ...rest | — | — | Passed to the rendered element (native `<button>` by default; `to`, `target`, `rel` etc. reach a link) |\n\n### As a link\n\n```jsx\n<Button href=\"tel:+919800000000\" variant=\"outline\">Call</Button>\n<Button as=\"a\" href=\"https://wa.me/919800000000\" target=\"_blank\" rel=\"noopener noreferrer\">WhatsApp</Button>\n<Button as={Link} to=\"/move-in\">Move in</Button>\n```\n\nA link can't be `disabled`; with `disabled` or `loading` it gets `aria-disabled=\"true\"`, `tabIndex={-1}`, the disabled styling, and its click is prevented.\n\nEvery variant has `dark:` colours, and the focus ring offset matches a dark page. Buttons shrink slightly while pressed (`active:scale-[0.97]`, off under `prefers-reduced-motion`).\n\n## Examples\n\n### Basic\n\n```jsx\n<Button variant=\"primary\">Save</Button>\n<Button variant=\"outline\">Cancel</Button>\n```\n\n### Sizes and loading\n\n```jsx\n<Button size=\"xs\">Extra small</Button>\n<Button size=\"sm\">Small</Button>\n<Button size=\"lg\">Large</Button>\n<Button size=\"xl\">Extra large</Button>\n<Button loading>Saving...</Button>\n```\n\n### With icons\n\n```jsx\nimport { Download } from 'lucide-react';\n\n<Button leftIcon={<Download className=\"h-4 w-4\" />}>Download</Button>\n```\n\n### Icon-only\n\n```jsx\nimport { Plus } from 'lucide-react';\n\n<Button size=\"icon\" variant=\"outline\" aria-label=\"Add\"><Plus className=\"h-4 w-4\" /></Button>\n<Button size=\"icon-sm\" variant=\"ghost\" aria-label=\"Add\"><Plus className=\"h-4 w-4\" /></Button>\n```\n\n[← Component overview](README.md)\n",
7196
+ "Button": "# Button\n\nButton with variants, sizes, loading state, and optional left/right icons.\n\n## Import\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `variant` | `string` | `\"primary\"` | `primary`, `secondary`, `outline`, `ghost`, `destructive`, `success`, `warning`, `link`. `link` is a text link: no background, border or padding, underlined on hover, the usual focus ring, and 44px tall on coarse pointers |\n| `tone` | `\"primary\"` \\| `\"secondary\"` \\| `\"success\"` \\| `\"warning\"` \\| `\"error\"` \\| `\"info\"` | `\"primary\"` | Text colour when `variant=\"link\"` (e.g. `error` inside an error-tinted card). Ignored by the other variants |\n| `size` | `string` | `\"md\"` | `xs` (h-7), `sm` (h-8), `md` (h-10), `lg` (h-12), `xl` (h-14), `2xl` (h-16) — every value of the shared `Size` type — plus the square icon-only sizes `icon-sm` (32px), `icon` (40px), `icon-lg` (48px); pair an icon size with `aria-label`. `xs`, `sm`, `icon-sm` and `icon` grow to 44px on coarse pointers (touch) |\n| `disabled` | `boolean` | — | Disable button |\n| `loading` | `boolean` | — | Disable and overlay a centred spinner. The label (and icons) stay in the layout at `opacity-0`, so the button keeps its width and its accessible name; sets `aria-busy=\"true\"` |\n| `leftIcon` | `ReactNode` | — | Icon before children |\n| `rightIcon` | `ReactNode` | — | Icon after children |\n| `fullWidth` | `boolean` | — | Full width |\n| `as` | `ElementType` | `\"button\"` | Render as another element or component with the Button's variants, sizes and focus ring, e.g. `\"a\"` or a router `Link` |\n| `href` | `string` | — | Link target. A bare `href` (no `as`) renders an `<a>` |\n| `className` | `string` | — | Extra CSS classes |\n| ...rest | — | — | Passed to the rendered element (native `<button>` by default; `to`, `target`, `rel` etc. reach a link) |\n\n### As a link\n\n```jsx\n<Button href=\"tel:+919800000000\" variant=\"outline\">Call</Button>\n<Button as=\"a\" href=\"https://wa.me/919800000000\" target=\"_blank\" rel=\"noopener noreferrer\">WhatsApp</Button>\n<Button as={Link} to=\"/move-in\">Move in</Button>\n```\n\nA link can't be `disabled`; with `disabled` or `loading` it gets `aria-disabled=\"true\"`, `tabIndex={-1}`, the disabled styling, and its click is prevented.\n\n### Text link\n\n`variant=\"link\"` looks like an inline link but stays a button, for actions inside a card. Give it an `href` (or `as`) when it really navigates.\n\n```jsx\n<Button variant=\"link\" rightIcon=\"→\" onClick={showPayments}>All payments</Button>\n<Button variant=\"link\" tone=\"error\" rightIcon=\"→\" onClick={scrollToDues}>See who owes</Button>\n<Button variant=\"link\" as={Link} to=\"/settlements\">Open full settlement</Button>\n```\n\nEvery variant has `dark:` colours, and the focus ring offset matches a dark page. Buttons shrink slightly while pressed (`active:scale-[0.97]`, off under `prefers-reduced-motion`).\n\n## Examples\n\n### Basic\n\n```jsx\n<Button variant=\"primary\">Save</Button>\n<Button variant=\"outline\">Cancel</Button>\n```\n\n### Sizes and loading\n\n```jsx\n<Button size=\"xs\">Extra small</Button>\n<Button size=\"sm\">Small</Button>\n<Button size=\"lg\">Large</Button>\n<Button size=\"xl\">Extra large</Button>\n<Button loading>Saving...</Button>\n```\n\n### With icons\n\n```jsx\nimport { Download } from 'lucide-react';\n\n<Button leftIcon={<Download className=\"h-4 w-4\" />}>Download</Button>\n```\n\n### Icon-only\n\n```jsx\nimport { Plus } from 'lucide-react';\n\n<Button size=\"icon\" variant=\"outline\" aria-label=\"Add\"><Plus className=\"h-4 w-4\" /></Button>\n<Button size=\"icon-sm\" variant=\"ghost\" aria-label=\"Add\"><Plus className=\"h-4 w-4\" /></Button>\n```\n\n[← Component overview](README.md)\n",
6730
7197
  "Card": "# Card\n\nCard container with optional hover shadow or a fully interactive (pressable) style. Subcomponents: `Card.Header`, `Card.Title`, `Card.Description`, `Card.Content`, `Card.Footer` for structure. The card and its header/footer dividers use the semantic `bg-surface` / `border-line` tokens, so they follow dark mode (re-theme via `--dt-surface` / `--dt-line`).\n\n## Import\n\n```jsx\nimport { Card } from '@dreamtree-org/twreact-ui';\n```\n\n## Props (Card root)\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | Card content. |\n| `className` | `string` | — | Wrapper CSS classes. |\n| `hover` | `boolean` | `false` | If true, add hover:shadow-md transition. |\n| `interactive` | `boolean` | `false` | Pressable card: hover shadow, a slight press shrink (off under reduced motion), a `focus-visible` ring and `cursor-pointer`. With an `onClick` (and no `role` of your own) the card also gets `role=\"button\"` and `tabIndex=0`, and Enter / Space call `onClick`. Without `interactive` the card renders exactly as before. |\n| ...rest | — | — | Passed to root `<div>`. |\n\nCorners use the `rounded-card` radius token (12 px; previously `rounded-lg`, 8 px). Header, Content and Footer pad `px-4` on phones and `px-6` from `sm` up.\n\n## Subcomponents\n\n- **Card.Header**: Wrapper for header (border-bottom, padding). Accepts `children`, `className`.\n- **Card.Title**: `<h3>` for title. Accepts `children`, `className`.\n- **Card.Description**: `<p>` for description. Accepts `children`, `className`.\n- **Card.Content**: Main body (padding). Accepts `children`, `className`.\n- **Card.Footer**: Footer (border-top, padding). Accepts `children`, `className`.\n\n## Example\n\n```jsx\n<Card hover>\n <Card.Header>\n <Card.Title>Card title</Card.Title>\n <Card.Description>Optional description text.</Card.Description>\n </Card.Header>\n <Card.Content>\n Main content here.\n </Card.Content>\n <Card.Footer>\n <Button>Action</Button>\n </Card.Footer>\n</Card>\n```\n\n[← Component overview](README.md)\n",
6731
7198
  "Carousel": "# Carousel\n\nHorizontal carousel of slides built on native CSS scroll-snap. Renders `children` as slides (one per child). Native momentum swipe on touch, optional auto-play, always-visible previous/next buttons, and dot indicators. Auto-play pauses on hover, touch and keyboard focus, and is off under reduced motion.\n\n## Import\n\n```jsx\nimport { Carousel } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | Each child is one slide. |\n| `autoPlay` | `boolean` | `false` | Auto-advance slides. |\n| `interval` | `number` | `5000` | Auto-play interval in ms. |\n| `showDots` | `boolean` | `true` | Show dot indicators at bottom. |\n| `className` | `string` | — | Wrapper CSS classes. |\n| `itemClassName` | `string` | — | Each slide wrapper CSS classes. |\n| `...rest` | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`aria-*`, `data-*`, `id`, …); `onMouseEnter` / `onMouseLeave` / `onFocus` / `onBlur` / `onTouchStart` / `onTouchEnd` are composed with the autoplay pause. Pass `aria-label` to name the carousel (default `\"Carousel\"`). |\n\n## Behavior\n\n- Slides sit in a horizontal track with `scroll-snap-type: x mandatory` (`snap-x snap-mandatory`), so touch users get native momentum swipe; the scrollbar is hidden. The active slide is tracked with an `IntersectionObserver` (the slide at least 60% in view), so swiping updates the dots and the announcement.\n- Arrows, dots and the arrow keys (with the track focused) scroll the track with `scrollTo`; `behavior` is `'smooth'`, or `'auto'` under `prefers-reduced-motion`. Previous/Next wrap around.\n- Accessibility: the root is `role=\"region\"` with `aria-roledescription=\"carousel\"`; each slide is `role=\"group\"` with `aria-roledescription=\"slide\"` and an `\"n of m\"` label. A visually hidden polite live region announces \"Slide n of m\"; it is `aria-live=\"off\"` while auto-play is running.\n- Dots are 44 px tall tap targets with a small visual dot inside; the active dot has `aria-current=\"true\"`.\n- Previous/Next buttons (labelled \"Previous slide\" / \"Next slide\") are always visible — including on touch devices — and grow to a 44×44 px touch target on coarse pointers.\n- Styling uses the role tokens (`bg-surface`, `border-line`, `text-foreground`, `bg-muted`) and `bg-primary-600` for the active dot, so it follows light/dark theming; transitions honour `prefers-reduced-motion`.\n- Dots are clickable to jump to a slide.\n- Auto-play pauses while the pointer hovers, while a finger is on the carousel, and while focus is inside it; it resumes when that ends. Under `prefers-reduced-motion` auto-play does not run at all.\n\n## Example\n\n```jsx\n<Carousel autoPlay interval={5000} showDots>\n <div className=\"h-64 bg-gray-100 flex items-center justify-center\">Slide 1</div>\n <div className=\"h-64 bg-gray-200 flex items-center justify-center\">Slide 2</div>\n <div className=\"h-64 bg-gray-300 flex items-center justify-center\">Slide 3</div>\n</Carousel>\n```\n\n[← Component overview](README.md)\n",
6732
7199
  "Checkbox": "# Checkbox\n\nCheckbox with label, sizes, and optional indeterminate state.\n\n## Import\n\n```jsx\nimport { Checkbox } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `id` | `string` | — | Input id |\n| `name` | `string` | — | Form name |\n| `label` | `string` | — | Label text |\n| `checked` | `boolean` | — | Controlled checked |\n| `defaultChecked` | `boolean` | `false` | Uncontrolled default |\n| `onChange` | `function` | — | Change handler |\n| `disabled` | `boolean` | `false` | Disable |\n| `required` | `boolean` | `false` | Required |\n| `size` | `string` | `\"md\"` | `sm`, `md`, `lg` |\n| `labelPosition` | `string` | `\"right\"` | `left`, `right` |\n| `value` | `any` | — | Value when checked |\n| `indeterminate` | `boolean` | `false` | Mixed state: fills the box with a dash and sets `aria-checked=\"mixed\"` |\n| `color` | `string` | `\"primary\"` | Token preset (`primary`, `secondary`, `success`, `error`, `warning`; aliases `green`, `danger`, `red`) or a custom CSS colour string. The raw-palette presets `blue`, `indigo`, `purple` and the `borderColor` prop were **removed in 2.0.0** ([migration](MIGRATION-2.0.md#checkbox-and-switch-raw-colour-presets)); the box border is always the `border-gray-300` token. |\n| `iconClass` | `string` | — | Extra classes for the check icon |\n| `checkedClass` | `string` | — | Extra classes applied when checked |\n| `wrapperProps` | `object` | — | Extra props spread onto the outer wrapper element |\n| `haptics` | `boolean` | `false` | Opt-in: a `selection` vibration (10ms) on every change. No-op without `navigator.vibrate` (iOS Safari) and under reduced motion. See [useHaptics](useHaptics.md) |\n\n## Behavior\n\n- The check and the indeterminate dash scale/fade in (`duration-fast`, off under `prefers-reduced-motion`).\n- Keyboard focus draws a `focus-visible` ring on the box (no ring after a mouse click).\n- On coarse pointers the invisible input is a 44×44 hit area centred on the box.\n- Surfaces use tokens (`bg-surface`, `border-gray-300` / `dark:border-gray-600`) and follow dark mode.\n\n## Example\n\n```jsx\n<Checkbox label=\"Accept terms\" checked={checked} onChange={e => setChecked(e.target.checked)} />\n<Checkbox label=\"Remember me\" defaultChecked />\n```\n\n[← Component overview](README.md)\n",
@@ -6743,6 +7210,7 @@
6743
7210
  "FileUpload": "# FileUpload\n\nFile upload zone: drag-and-drop or click to open file dialog. Supports single/multiple, `accept`, `maxSize` validation, and list of selected files with remove. Calls `onFileSelect` with valid files and `onFileRemove` with index.\n\n## Import\n\n```jsx\nimport { FileUpload } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `accept` | `string` | — | Input accept (e.g. `\"image/*\"`, `\".pdf\"`). |\n| `multiple` | `boolean` | `false` | Allow multiple files. |\n| `maxSize` | `number` | — | Max file size in bytes; larger files trigger error and are not added. |\n| `onFileSelect` | `(files: File[]) => void` | — | Called with valid selected/dropped files. |\n| `onFileRemove` | `(fileIndex: number) => void` | — | Called when user removes a file from the list. |\n| `files` | `File[]` | `[]` | List of files to display (e.g. controlled from parent). |\n| `disabled` | `boolean` | `false` | Disable the zone, pick buttons and remove buttons. |\n| `capture` | `boolean` \\| `\"user\"` \\| `\"environment\"` | — | When set, touch screens also get a **Take photo** button, wired to a second hidden input that carries `capture` (and `accept`, defaulting to `\"image/*\"`). The **Choose file** input never has `capture`, so picking from the library still works. |\n| `className` | `string` | — | Wrapper CSS classes. |\n| ...rest | — | — | Passed to wrapper div. |\n\n## Behavior\n\n- Validates each file: if `maxSize` is set and file.size > maxSize, shows error and does not add that file.\n- File type icons: image, text/document, or generic file.\n- **Mouse / trackpad** (`pointer: fine`): the dashed zone is a `role=\"button\"` (Tab to it, Enter / Space opens the file dialog) and accepts drag and drop.\n- **Touch screens** (`pointer: coarse`): drag and drop isn't available, so the zone shows a 44 px **Choose file** (or **Choose files** with `multiple`) button, plus **Take photo** when `capture` is set. Dropping still works if the device supports it.\n- The selected-file list is a `<ul aria-label=\"Selected files\">` using the role tokens (`bg-surface`, `border-line`, `text-foreground`, `text-muted`), so it follows dark mode. Each remove button is labelled `Remove <filename>` and grows to a 44×44 px touch target on coarse pointers.\n- Validation errors render with `role=\"alert\"`.\n- Displays file size in human-readable form (Bytes, KB, MB, GB).\n\n## Example\n\n```jsx\nconst [files, setFiles] = useState([]);\n<FileUpload\n accept=\"image/*\"\n multiple\n maxSize={5 * 1024 * 1024}\n files={files}\n onFileSelect={(newFiles) => setFiles(prev => [...prev, ...newFiles])}\n onFileRemove={(index) => setFiles(prev => prev.filter((_, i) => i !== index))}\n/>\n```\n\n[← Component overview](README.md)\n",
6744
7211
  "FootNav": "# FootNav\n\nFixed bottom navigation for mobile (hidden on `md` and up via `md:hidden`): a native-feeling tab bar. Renders a row of items with icon and label; optional badge per item. Items are buttons (`onClick`) or links (`href` / `to`, rendered as `<a>` or your router `Link` via `linkAs`). The bar sits on the `z-sticky` layer, pads the bottom safe area (`pb-safe`, for the home indicator), and is a translucent `bg-surface/80` with backdrop blur where supported (solid `bg-surface` otherwise); borders use `border-line`, so it is dark-mode aware. Items share the width equally, long labels truncate (five tabs fit a 375px phone), and shrink slightly while pressed (off under reduced motion). Put a `<FootNav.Spacer />` at the end of the page content so nothing scrolls under the bar.\n\n## Import\n\n```jsx\nimport { FootNav } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `items` | `Array` | `[]` | Each item: `{ id, label, icon, onClick?, active?, badge?, href?, to?, variant?, 'aria-*'?, 'data-*'? }`. `variant: 'action'` renders a raised circular primary button (FAB); `href` / `to` render the item as a link; `aria-*` / `data-*` keys are passed to the item's button or link. |\n| `linkAs` | `ElementType` | `\"a\"` | Component for items with `href` / `to`, e.g. a router `Link`. It receives `href` and `to` (`item.to ?? item.href`). Items without `href`/`to` stay `<button type=\"button\">`. |\n| `hideOnScroll` | `boolean` | `false` | Slide the bar away while scrolling down and back on scroll up (passive listener, SSR-safe; instant under `prefers-reduced-motion`). Never hides within 64px of the top; keyboard focus entering the bar shows it. Sets `data-hidden` on the `<nav>`. |\n| `badgeMax` | `number` | `99` | Numeric badges above this read `${badgeMax}+` (e.g. `99+`). |\n| `aria-label` | `string` | `\"Primary\"` | Accessible name of the `<nav>` landmark. |\n| `className` | `string` | — | Nav wrapper CSS classes. |\n| ...rest | — | — | Passed to `<nav>`. The ref is forwarded to it. |\n\n## FootNav.Spacer\n\n`<FootNav.Spacer />` renders an `aria-hidden` block the height of the bar plus the bottom safe area (hidden from `md` up, like the bar). Place it after the page content so the last rows aren't covered. Accepts `className`, `...rest` and a ref.\n\n## Item shape\n\n- `id`: string or number (key).\n- `label`: string.\n- `icon`: ReactNode (e.g. Lucide icon).\n- `onClick(item)`: optional; called when item is clicked.\n- `active`: boolean; the current tab. Rendered with a tinted primary background, `text-primary-700` and semibold weight (inactive tabs use the neutral `text-muted` token), and gets `aria-current=\"page\"`.\n- `badge`: optional number or node; shown as a small badge anchored to that item's own icon. `0` renders as a badge; `null`/`undefined`/`false`/`''` render nothing. Numbers above `badgeMax` (default 99) read `99+`; the badge keeps a minimum width and tabular digits.\n- `href` / `to`: render the item as a link (`<a>`, or `linkAs`). The `active` item gets `aria-current=\"page\"`, same as a button item. `onClick(item)` still fires.\n- `variant`: `'action'` renders a raised, circular primary button (FAB): a 56px `bg-primary-600` circle lifted above the bar and ringed in the surface colour, with the label below. Put it in the middle of `items` to centre it, e.g. a \"+\" that opens a quick-actions sheet. Omit for a normal tab.\n- `aria-*` / `data-*`: passed through to the item's button or link, e.g. `'aria-haspopup': 'dialog'` and `'aria-expanded': open` on an item that opens a sheet, or `'aria-label'` to override the visible label as the accessible name. Other keys stay data and never reach the DOM.\n- Each item renders as a `<button type=\"button\">`, or a link when it has `href` / `to`.\n\n### Primary action (FAB)\n\n```jsx\n<FootNav\n items={[\n { id: 'today', label: 'Today', icon: <Home />, active: true, onClick: go },\n { id: 'beds', label: 'Beds', icon: <Bed />, onClick: go },\n {\n id: 'add',\n label: 'Add',\n icon: <Plus />,\n variant: 'action',\n 'aria-haspopup': 'dialog',\n 'aria-expanded': sheetOpen,\n onClick: () => setSheetOpen(true),\n },\n { id: 'payments', label: 'Payments', icon: <Wallet />, onClick: go },\n { id: 'more', label: 'More', icon: <Menu />, onClick: go },\n ]}\n/>\n```\n\n### Links, hide on scroll and the spacer\n\n```jsx\nimport { Link } from 'react-router-dom';\n\n<main>\n {content}\n <FootNav.Spacer />\n</main>\n<FootNav\n linkAs={Link}\n hideOnScroll\n items={[\n { id: 'home', label: 'Home', icon: <Home />, to: '/', active: path === '/' },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, to: '/inbox', badge: unread }, // 120 → 99+\n { id: 'me', label: 'Me', icon: <User />, to: '/me', active: path === '/me' },\n ]}\n/>\n```\n\n## Example\n\n```jsx\n<FootNav\n items={[\n { id: 'home', label: 'Home', icon: <Home />, onClick: () => navigate('/'), active: path === '/' },\n { id: 'search', label: 'Search', icon: <Search />, onClick: () => openSearch() },\n { id: 'notifications', label: 'Alerts', icon: <Bell />, badge: 3, onClick: () => openNotifications() },\n { id: 'profile', label: 'Profile', icon: <User />, onClick: () => navigate('/profile') },\n ]}\n/>\n```\n\n[← Component overview](README.md)\n",
6745
7212
  "Form": "# Form\n\nForm built on **react-hook-form** with a `fields` config array. Renders Input, Select, DatePicker, Checkbox, Switch, textarea, etc., and handles submit/reset. Use `validationSchema` (e.g. from `@hookform/resolvers` with yup) for validation. The built-in textarea and the file-upload button use the semantic `bg-surface` / `border-line` / `text-foreground` tokens, so they follow dark mode.\n\n## Import\n\n```jsx\nimport { Form } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `fields` | `Array` | `[]` | Field configs (see Field shape below). |\n| `onSubmit` | `(data: object) => void \\| Promise<void>` | — | Submit handler; receives form data. If it returns a promise, the form awaits it: the submit button shows its loading state and stays disabled until the promise settles. |\n| `defaultValues` | `object` | `{}` | Default values for each field name. |\n| `validationSchema` | `object` | — | Resolver from e.g. `yupResolver(schema)`. |\n| `className` | `string` | — | Form wrapper CSS classes. |\n| `submitText` | `string` | `\"Submit\"` | Submit button text. |\n| `resetText` | `string` | `\"Reset\"` | Reset button text. |\n| `submitButtonClass` | `string` | — | Submit button CSS classes. |\n| `resetButtonClass` | `string` | — | Reset button CSS classes. |\n| `showReset` | `boolean` | `true` | Show reset button. |\n| `submitDisabled` | `boolean` | `false` | Disable submit button. |\n| ...rest | — | — | Passed to react-hook-form `useForm` (e.g. `mode`). |\n\n## Field shape\n\nEach item in `fields` can have:\n\n- `type`: `\"text\"` \\| `\"email\"` \\| `\"password\"` \\| `\"number\"` \\| `\"tel\"` \\| `\"url\"` \\| `\"select\"` \\| `\"date\"` \\| `\"textarea\"` \\| `\"checkbox\"` \\| `\"radio\"` \\| `\"file\"` \\| `\"switch\"` \\| `\"color\"` \\| `\"custom\"`.\n- `name`: string (required).\n- `label`: string.\n- `required`: boolean.\n- `placeholder`: string.\n- `helperText`: ReactNode — hint shown under the field and linked via `aria-describedby`, for **every** built-in type. The validation error replaces it while shown.\n- `swatches`, `showAlpha`: for `type: \"color\"` — forwarded to `ColorPicker`.\n- `options`: for `select` and `radio`, an array of `{ value, label }` choices (used only by those types and never spread as props). A plain **object** `options` on any type is spread onto the rendered component as extra props, e.g. `options: { maxLength: 4 }`.\n- `rows`: for textarea.\n- Radio options share the field `name` as a native group, and each option's label is linked to its input (`htmlFor`/`id`), so clicking the label selects it. The field `label` names the `radiogroup`.\n- `clearable`: for the text-like types (`text`, `email`, `password`, `number`, `tel`, `url`). The clear button resets the form value to `\"\"` and then calls the field's own `onClear`, if set. Since 2.0.0 `Input` no longer fires a synthetic `onChange` on clear, so `Form` wires this for you ([migration](MIGRATION-2.0.md#input-clear-no-longer-fires-onchange)).\n- Any other prop passed through to the rendered component (e.g. `disabled`).\n\n## Field groups\n\nAn item with `type: \"group\"` renders a titled section whose `fields` are laid\nout in a grid:\n\n- `title`, `description`: optional heading and intro text.\n- `gridCols`: `1`–`4` (default `1`). Layouts are responsive and always one\n column below the `sm` breakpoint: `2` → 2 columns from `sm`; `3` / `4` → 2\n columns from `sm`, 3 / 4 columns from `lg`. Values outside 1–4 are clamped.\n\n```jsx\n<Form\n fields={[\n {\n type: 'group',\n name: 'contact',\n title: 'Contact',\n gridCols: 2,\n fields: [\n { type: 'text', name: 'first', label: 'First name' },\n { type: 'text', name: 'last', label: 'Last name' },\n ],\n },\n ]}\n/>\n```\n\n## Color fields\n\n`type: \"color\"` mounts the library's [`ColorPicker`](ColorPicker.md) through a\nreact-hook-form `Controller`. The stored value is the string ColorPicker emits:\n`rgb(r, g, b)` (or `rgba(r, g, b, a)` when alpha < 1). Any hex / `rgb()` /\n`rgba()` string works as a default value and is submitted unchanged until the\nuser picks a colour.\n\n```jsx\n<Form\n defaultValues={{ brand: '#2563eb' }}\n fields={[\n {\n type: 'color',\n name: 'brand',\n label: 'Brand colour',\n swatches: ['#2563eb', '#16a34a', '#dc2626'],\n helperText: 'Used for buttons and links.',\n },\n ]}\n onSubmit={(data) => save(data)} // { brand: 'rgb(37, 99, 235)' } after a pick\n/>\n```\n\n## Helper text\n\n```jsx\n<Form\n fields={[\n { type: 'email', name: 'email', label: 'Email', helperText: 'Receipts only.' },\n { type: 'textarea', name: 'bio', label: 'Bio', helperText: 'Max 200 characters.' },\n ]}\n/>\n```\n\n`Input` / `Select` / `DatePicker` fields forward `helperText` to the component;\n`textarea`, `checkbox`, `radio`, `file`, `switch` and `color` fields render it\nin Form's own markup with the same rule: the validation error replaces the\nhelper text while present.\n\n## Example\n\n```jsx\nimport { Form } from '@dreamtree-org/twreact-ui';\nimport { yupResolver } from '@hookform/resolvers/yup';\nimport * as yup from 'yup';\n\nconst schema = yup.object({\n email: yup.string().email().required(),\n password: yup.string().min(6).required(),\n});\n\n<Form\n fields={[\n { type: 'email', name: 'email', label: 'Email', required: true, placeholder: 'you@example.com' },\n { type: 'password', name: 'password', label: 'Password', required: true },\n { type: 'select', name: 'role', label: 'Role', options: [{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }] },\n ]}\n defaultValues={{ email: '', password: '', role: 'user' }}\n validationSchema={yupResolver(schema)}\n onSubmit={(data) => console.log(data)}\n submitText=\"Sign in\"\n showReset\n/>\n```\n\n[← Component overview](README.md)\n",
7213
+ "ImageGallery": "# ImageGallery\n\nProduct-photo gallery driven by an `images` array: a main viewer with thumbnails beside or below it, or a full-width mosaic. Supports a controlled or uncontrolled index, swipe on touch, arrow keys, previous/next buttons, click-to-zoom, a full-screen lightbox built on `Dialog`, a counter badge and a placeholder for missing or broken images.\n\n## Import\n\n```jsx\nimport { ImageGallery } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `images` | `{ src: string; alt: string; caption?: ReactNode }[]` | `[]` | Images in display order. `alt` names the main image and its thumbnail; `caption` renders under the viewer. |\n| `layout` | `'thumbs-below' \\| 'thumbs-side' \\| 'mosaic'` | `'thumbs-below'` | `thumbs-side` puts the thumbnails left of the photo from the `sm` breakpoint and falls back to below on narrow screens. `mosaic` is a full-width tile grid (hero + up to 4 tiles, the last showing \"+N\"). |\n| `ratio` | `'4:3' \\| '1:1' \\| '3:4' \\| '16:9'` | `'4:3'` | Aspect ratio of the viewer and mosaic tiles. |\n| `index` | `number` | — | Controlled current image (0-based). Out-of-range values are clamped. |\n| `defaultIndex` | `number` | `0` | Uncontrolled initial image. |\n| `onIndexChange` | `(index: number) => void` | — | Called when the user picks another image (thumbnail, arrows, keys, swipe, mosaic tile, lightbox). |\n| `zoom` | `boolean` | `false` | Click / tap the photo to toggle a 2x view whose origin follows the pointer. |\n| `lightbox` | `boolean` | `false` | Adds an \"Open full-screen view\" button (and makes mosaic tiles open it) that shows the viewer in a `Dialog`. |\n| `placeholder` | `ReactNode` | image-off icon | Rendered when `images` is empty or an image fails to load. |\n| `showCounter` | `boolean` | `true` | Show the `\"3/6\"` badge on the viewer. |\n| `className` | `string` | — | Root CSS classes (merged with `cn`). |\n| `...rest` | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`id`, `data-*`, handlers, …). Pass `aria-label` to name the gallery (default `\"Image gallery\"`); it labels the viewer region (or the mosaic region). |\n\n## Behavior\n\n- **Index**: controlled with `index` + `onIndexChange` (recommended when the page also reacts to the photo, e.g. a variant picker), or uncontrolled with `defaultIndex`. Previous/next, arrow keys and swipe wrap around at both ends.\n- **Viewer**: `role=\"region\"` with `aria-roledescription=\"carousel\"` and the gallery label, focusable (`tabIndex=0`); ArrowLeft / ArrowRight change the image, Escape leaves zoom. The current photo sits in a `role=\"group\"` / `aria-roledescription=\"slide\"` labelled `\"n of m\"`, and a visually hidden polite live region announces `\"Image n of m: alt\"`.\n- **Swipe**: pointer events on the photo; a horizontal drag of at least 40 px (and more horizontal than vertical) goes to the next / previous image. `touch-action: pan-y` keeps vertical page scrolling. Swipe is off while zoomed.\n- **Thumbnails**: buttons named `\"Show image n of m: alt\"` with `aria-current=\"true\"` on the active one. Roving focus — only the active thumbnail is in the Tab order; Arrow keys (Left/Right/Up/Down, wrapping), Home and End move focus, Enter / Space selects. Hidden when there is a single image.\n- **Mosaic**: the same roving-focus tile buttons; clicking a tile selects that image and, with `lightbox`, opens it full-screen. The counter shows in the lightbox viewer.\n- **Zoom**: the photo becomes a toggle button (`\"Zoom in\"` / `\"Zoom out\"`, `aria-pressed`). Resets when the image changes.\n- **Lightbox**: reuses the library `Dialog` (`size=\"full\"`, `presentation=\"popover\"`), so focus is trapped, Escape and the close button dismiss it, the page scroll is locked, and focus returns to the expand button on close. Navigating inside the lightbox drives the same index.\n- **Placeholder**: shown in place of the viewer when there are no images (`role=\"img\"`, label `\"No images\"`), and in place of any image (main or thumbnail) whose `src` fails to load.\n- **Motion**: the image fade and zoom transition use `motion-safe:` / `motion-reduce:` variants, so they are off under `prefers-reduced-motion`.\n- **Theming**: role tokens (`bg-surface`, `border-line`, `text-foreground`, `text-muted`) and `border-primary-600` for the active thumbnail, so it follows light/dark mode. Arrow and expand buttons grow to 44×44 px on coarse pointers.\n\n## Example\n\n```jsx\nconst photos = [\n { src: '/units/42/front.jpg', alt: 'Front of the unit', caption: 'Minor scuff on the bezel' },\n { src: '/units/42/back.jpg', alt: 'Back of the unit' },\n { src: '/units/42/ports.jpg', alt: 'Ports close-up' },\n];\n\nfunction UnitPhotos() {\n const [index, setIndex] = React.useState(0);\n return (\n <ImageGallery\n aria-label=\"Photos of this unit\"\n images={photos}\n layout=\"thumbs-side\"\n ratio=\"1:1\"\n index={index}\n onIndexChange={setIndex}\n zoom\n lightbox\n placeholder={<span>No photos yet</span>}\n />\n );\n}\n```\n\n[← Component overview](README.md)\n",
6746
7214
  "Input": "# Input\n\nText input with optional label, error/success states, clear button, character count, and password visibility toggle.\n\n## Import\n\n```jsx\nimport { Input } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `id` | `string` | — | Input id (auto-generated if not set) |\n| `className` | `string` | — | Extra CSS classes |\n| `type` | `string` | `\"text\"` | `text`, `search`, `password`, etc. |\n| `label` | `string` | — | Label text |\n| `placeholder` | `string` | — | Placeholder |\n| `error` | `string` | — | Error message (shows error state). Replaces `helperText` while shown. |\n| `helperText` | `ReactNode` | — | Hint below the input, linked via `aria-describedby`. Hidden while `error` or `success` is shown. |\n| `success` | `boolean` | — | Success state |\n| `disabled` | `boolean` | `false` | Disable input |\n| `required` | `boolean` | `false` | Required field |\n| `clearable` | `boolean` | `false` | Show clear button when has value |\n| `leftIcon` | `ReactNode` | — | Decorative icon before the value (`type=\"search\"` shows its own search icon instead). Replaces `icon` in 2.0.0 ([migration](MIGRATION-2.0.md#input-icon--iconposition--lefticon--righticon)) |\n| `rightIcon` | `ReactNode` | — | Decorative icon after the value, hidden while the password toggle or clear button shows. Replaces `icon` + `iconPosition=\"right\"` in 2.0.0. Both icons can be set together |\n| `onClear` | `function` | — | Called when the clear button is pressed. **Controlled** fields must clear their own `value` here: since 2.0.0 the clear no longer sends a synthetic `onChange({ target: { value: '' } })` ([migration](MIGRATION-2.0.md#input-clear-no-longer-fires-onchange)). Uncontrolled fields clear themselves. Focus returns to the input either way. |\n| `showCount` | `boolean` | `false` | Show character count |\n| `maxLength` | `number` | — | Max length (used with showCount) |\n| `readOnly` | `boolean` | `false` | Read-only |\n| `value` | `string` | — | Controlled value |\n| `defaultValue` | `string` | — | Uncontrolled default |\n| `onChange` | `function` | — | Change handler |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Field size. `sm` is 32px, `md` is 40px and grows to the 44px touch target on coarse pointers, `lg` is 44px everywhere. A number is passed through as the native `<input size>` attribute. |\n| `floatingLabel` | `boolean` | `false` | Render `label` inside the field; it floats above the value once the field is focused or filled. The field is taller (48px at `md`). |\n| `otp` | `boolean` | `false` | One-time code field: sets `autoComplete=\"one-time-code\"` and `inputMode=\"numeric\"` (unless you pass them). |\n| `ref` | `Ref<HTMLInputElement>` | — | Forwarded to the native `<input>`; object refs and callback refs both work. |\n| ...rest | — | — | Passed to native `<input>` |\n\n### Mobile keyboard hints\n\nWhen you don't pass the attribute yourself, Input picks the phone keyboard and\nautofill for native types:\n\n| `type` | Adds |\n|--------|------|\n| `tel` | `inputMode=\"tel\"` `autoComplete=\"tel\"` |\n| `email` | `inputMode=\"email\"` `autoComplete=\"email\"` |\n| `url` | `inputMode=\"url\"` `autoComplete=\"url\"` |\n| `search` | `inputMode=\"search\"` `enterKeyHint=\"search\"` |\n| `otp` prop | `inputMode=\"numeric\"` `autoComplete=\"one-time-code\"` |\n\nAny of these you pass explicitly wins (e.g. `autoComplete=\"off\"`).\n\nThe password toggle and clear buttons are real buttons with `aria-label`s and a\n`focus-visible` ring; they grow to 44×44 on coarse pointers. Colours use role\ntokens (`bg-surface`, `text-foreground`) with `dark:` counterparts, so the field\nfollows `ThemeProvider`.\n\nKeyboard focus shows a `focus-visible` primary ring in the neutral state too (not only for `error` / `success`).\n\n## Examples\n\n### Basic\n\n```jsx\n<Input label=\"Email\" placeholder=\"you@example.com\" />\n```\n\n### With helper text\n\n```jsx\n<Input label=\"Email\" type=\"email\" helperText=\"Use your work email.\" />\n```\n\n### Helper text and errors\n\n`helperText` renders a muted line below the control and is linked to it via\n`aria-describedby`, so screen readers announce it with the field. **While\n`error` is set, the error message replaces the helper text** (only one line is\nshown, and `aria-describedby` points at the error). This rule is the same on\nevery field-like component: `Input`, `Select`, `DatePicker`, `DateRangePicker`\nand `Form` fields.\n\n### With error\n\n```jsx\n<Input label=\"Email\" error=\"Invalid email address\" />\n```\n\n### Password with toggle\n\n```jsx\n<Input type=\"password\" label=\"Password\" placeholder=\"Enter password\" />\n```\n\n### Clearable and character count\n\n```jsx\n<Input\n label=\"Bio\"\n clearable\n showCount\n maxLength={200}\n placeholder=\"Tell us about yourself\"\n/>\n\n// Controlled: clear the value yourself in onClear (2.0)\n<Input\n label=\"Search\"\n clearable\n value={q}\n onChange={(e) => setQ(e.target.value)}\n onClear={() => setQ(\"\")}\n/>\n```\n\n### Icons\n\n```jsx\n<Input label=\"Amount\" leftIcon={<IndianRupee className=\"h-4 w-4\" />} />\n<Input label=\"Email\" type=\"email\" rightIcon={<Mail className=\"h-4 w-4\" />} />\n```\n\n### Sizes and floating label\n\n```jsx\n<Input size=\"lg\" label=\"Phone\" type=\"tel\" />\n<Input floatingLabel label=\"Email\" type=\"email\" />\n```\n\n### One-time code\n\n```jsx\n<Input otp label=\"Verification code\" maxLength={6} />\n```\n\n[← Component overview](README.md)\n",
6747
7215
  "LineChart": "# LineChart\n\nThemed, responsive line chart with a data-first API (`data` + `index` + `categories`), token colours, an accessible data-table fallback and reduced-motion support. Wraps `recharts` (optional peer, `/charts` entry point).\n\n## Install\n\nCharts live on a separate entry point so apps that don't use them never install or bundle `recharts`:\n\n```bash\nnpm install recharts\n# React 18 apps: also pin react-is to your React major (recharts resolves it as a peer)\nnpm install react-is@18\n```\n\n## Import\n\n```jsx\nimport { LineChart } from '@dreamtree-org/twreact-ui/charts';\n```\n\nNever import charts from the root `@dreamtree-org/twreact-ui` — they are not exported there.\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `object[]` | — | **Required.** Rows to plot. An empty array renders `emptyState`. |\n| `index` | `string` | — | **Required.** Key of the x-axis (category) value in each row, e.g. `\"month\"`. |\n| `categories` | `string[]` | — | **Required.** Keys of the numeric series, one line per key. |\n| `colors` | `ChartColor[]` | `primary, success, warning, error, info, secondary, gray` | Series colours by position (cycled). A token (`\"primary\"`, `\"success-600\"`) resolves to the `--dt-*` CSS variable and follows the theme and dark mode; any other string is used as a CSS colour. |\n| `xAxisFormatter` | `(value) => string` | — | Formats category (x-axis) tick labels and the hidden table's row headers. |\n| `showLegend` | `boolean` | `true` | Legend below the chart (real DOM text). |\n| `showXAxis` | `boolean` | `true` | Show the x-axis. |\n| `showYAxis` | `boolean` | `true` | Show the y-axis. |\n| `showGrid` | `boolean` | `true` | Dashed grid lines (`--dt-line`). |\n| `yAxisWidth` | `number` | `48` (`36` when the chart is narrower than 400 px) | Width reserved for value-axis labels, px. When set, it is used at every width. |\n| `curveType` | `\"linear\"`, `\"monotone\"`, `\"step\"` or `\"natural\"` | `\"monotone\"` | Line interpolation. |\n| `showDots` | `boolean` | `false` | Draw a dot on every data point. |\n| `connectNulls` | `boolean` | `false` | Draw across `null` gaps instead of breaking the line. |\n| `valueFormatter` | `(value: number) => string` | `toLocaleString` | Formats values in the tooltip, value axis and hidden data table. |\n| `height` | `number` or `string` | `300` | Chart height (px number or any CSS length). The width is always 100% of the parent (responsive). |\n| `aspectRatio` | `number` | — | Width / height ratio, e.g. `16 / 9`. When provided it replaces `height`, so the chart scales with its container (handy on phones); the empty state uses it too. |\n| `title` | `string` | — | Accessible name: `aria-label` of the `role=\"img\"` region and caption of the hidden table. Falls back to `aria-label`, then `\"Line chart\"`. |\n| `description` | `string` | — | Longer text alternative (the takeaway), linked with `aria-describedby`. |\n| `emptyState` | `ReactNode` | `\"No data to display\"` | Shown (as `role=\"status\"`) instead of the chart when `data` is empty. |\n| `accessibleTable` | `boolean` | `true` | Repeat the numbers in a visually-hidden `<table>` for screen-reader users. |\n| `animate` | `boolean` | `true` | Animate on mount / data change. Always off when the user prefers reduced motion. |\n| `showTooltip` | `boolean` | `true` | Tooltip (themed, dark-mode aware): on hover with a mouse; on touch a tap pins it at the tapped point, and a second tap on the chart or a tap outside unpins it. |\n| `className` | `string` | — | Extra classes for the root `<div>` (merged with `cn`). |\n| `ref` | `Ref<HTMLDivElement>` | — | Forwarded to the root `<div>`. |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`id`, `data-*`, `aria-*`, handlers). |\n\n## Small screens\n\n- x-axis ticks keep a wider gap on narrow charts (`minTickGap` 32 px below 360 px wide, 16 px below 640 px, otherwise 8 px), so labels don't collide.\n- The value axis narrows to 36 px below 400 px wide unless you pass `yAxisWidth`.\n- Use `aspectRatio` instead of a fixed `height` to keep the chart proportional to its width.\n\n## Examples\n\n### Basic\n\n```jsx\nconst data = [\n { month: 'Jan', revenue: 4200, costs: 2600 },\n { month: 'Feb', revenue: 5100, costs: 2900 },\n { month: 'Mar', revenue: 4800, costs: 3100 },\n];\n\n<LineChart\n data={data}\n index=\"month\"\n categories={['revenue', 'costs']}\n valueFormatter={(v) => `$${v.toLocaleString()}`}\n title=\"Revenue and costs, Q1\"\n description=\"Revenue grew 14% while costs grew 19%.\"\n/>\n```\n\n### Token colours, no grid\n\n```jsx\n<LineChart data={data} index=\"month\" categories={['revenue']} colors={['success']} showGrid={false} curveType=\"linear\" showDots />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Name | The SVG sits in a `role=\"img\"` region named by `title` (falls back to `aria-label`, then a generic name such as \"Line chart\") |\n| Description | `description` is rendered visually hidden and linked with `aria-describedby` |\n| Numbers | `accessibleTable` (default on) repeats the data in a visually-hidden `<table>` with a caption, column headers and row headers, formatted with `valueFormatter`; recharts' SVG itself has no accessible content |\n| Legend | Real DOM text, not SVG |\n| Motion | Animation is disabled when `prefers-reduced-motion: reduce` is set (and with `animate={false}`) |\n| Colour | Series colours come from the design tokens; pair the chart with the table / description so colour is never the only signal |\n| Dark mode | Axis text (`--dt-muted`), grid (`--dt-line`) and series tokens are re-read when the `dark` class or `data-theme` on `<html>` changes; tooltip and legend use `dark:` variants |\n| Empty | An empty `data` array renders `emptyState` in a `role=\"status\"` box with the same accessible name |\n\n## Server rendering\n\nSafe to render on the server (a Next.js `\"use client\"` page). The first client render matches the server's HTML: token colours stay `rgb(var(--dt-*))` and reduced motion reads as off until hydration finishes, then the live values apply. SVG ids come from `React.useId()` (`dt-chart-…`), not a module counter.\n\n[← Component overview](README.md)\n",
6748
7216
  "Loader": "# Loader\n\nLoading indicator in three variants: spinner (default), dots, or bar. Configurable size, speed, colors, and optional label text.\n\n## Import\n\n```jsx\nimport { Loader } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` \\| `number` | `\"md\"` | Size: sm=14px, md=20px, lg=36px, or numeric px. |\n| `speed` | `number` | `0.9` | Animation duration in seconds. Applied through the `--dt-loader-speed` CSS custom property on the wrapper. |\n| `thickness` | `number` | `2.5` | Stroke thickness (spinner). |\n| `color` | `string` | `\"currentColor\"` | Main color (spinner/dots/bar). |\n| `secondaryColor` | `string` | — | Track/background color. When omitted, a dark-safe track is used: 8% black in light mode (as before) and 15% white in dark mode. |\n| `variant` | `\"spinner\"` \\| `\"dots\"` \\| `\"bar\"` | `\"spinner\"` | Visual style. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n| `inline` | `boolean` | `false` | If true, wrapper is inline-flex. |\n| `ariaLabel` | `string` | `\"Loading\"` | Accessibility label. |\n| `text` | `string` | — | Optional label shown next to loader. |\n| `reverse` | `boolean` | `false` | Reverse spinner rotation direction. |\n| `style` | `object` | — | Inline style for wrapper. |\n\n**Animation:** the keyframes ship in the library's Tailwind build (`animate-loader-spin`, `animate-loader-bounce`, `animate-loader-bar`), so no `<style>` tag is injected per instance. Under `prefers-reduced-motion` the spinner slows to one turn every 3 s (it still shows the app is busy), and the dots and bar stop. The spinner is a CSS rotation of the SVG, not SMIL `<animateTransform>`. The indeterminate bar now travels the full width of its track.\n\n## Examples\n\n```jsx\n<Loader />\n<Loader variant=\"dots\" size=\"sm\" />\n<Loader variant=\"bar\" text=\"Loading...\" />\n<Loader size={32} color=\"#2563eb\" />\n```\n\n[← Component overview](README.md)\n",
@@ -6751,12 +7219,15 @@
6751
7219
  "MIGRATION-2.0": "# Migrating to 2.0\n\n2.0.0 is the one breaking release planned in the [UI/UX and mobile roadmap](../docs/roadmap/uiux-mobile-2026.md) (epic E6). It removes the APIs that 1.x deprecated and renames the icon props that broke the shared `leftIcon` / `rightIcon` vocabulary. Nothing else changes: there are no new peer dependencies, the Tailwind setup is the same, and no default behaviour was flipped.\n\n## Upgrade in three steps\n\n1. Install the new version:\n\n ```bash\n npm install @dreamtree-org/twreact-ui@^2.0.0\n ```\n\n2. Run the codemod over your source folder. It rewrites JSX on components imported from `@dreamtree-org/twreact-ui` and prints the spots it can't fix by itself:\n\n ```bash\n npx jscodeshift -t node_modules/@dreamtree-org/twreact-ui/codemods/v2.cjs src/\n ```\n\n The codemod parses `.js`, `.jsx`, `.ts` and `.tsx` files (its default parser is `tsx`). Add `--extensions=js,jsx,ts,tsx` if jscodeshift skips your files, `--dry --print` to preview the result, or `--parser=babel` for Flow code.\n\n3. Fix each line the codemod reports (lines that start with `REP`), then type-check. The TypeScript types drop every removed prop, so `tsc` flags any the codemod couldn't see, such as props passed through a spread.\n\n## What the codemod does\n\n| Change | Automated | Manual |\n|---|---|---|\n| [Input `icon` / `iconPosition`](#input-icon--iconposition--lefticon--righticon) | `icon` becomes `leftIcon`, or `rightIcon` with `iconPosition=\"right\"`; a literal `iconPosition` is removed | A non-literal `iconPosition={pos}` is reported |\n| [`Tabs.Tab` `iconLeft` / `iconRight`](#tabstab-iconleft--iconright) | Renamed to `leftIcon` / `rightIcon` | None |\n| [Input clear](#input-clear-no-longer-fires-onchange) | None | A `clearable` Input with `value` but no `onClear` is reported. Add `onClear` |\n| [ProgressBar HEX colours](#progressbar-hex-colour-props) | `color`, `bgColor` and `valueColor` are removed | Reported. Pick a `variant` if the bar wasn't primary |\n| [Checkbox `borderColor`](#checkbox-bordercolor) | Removed | None |\n| [Rate `activeColor` / `inactiveColor`](#rate-activecolor-and-inactivecolor) | Removed | None |\n| [Checkbox / Switch raw presets](#checkbox-and-switch-raw-colour-presets) | None | `color=\"blue\"`, `\"indigo\"` or `\"purple\"` is reported. Pick a token |\n\nThe codemod only sees literal JSX attributes. It doesn't follow props passed through a spread (`<Input {...props} />`), components re-exported from your own wrapper files, or `React.createElement` calls. The TypeScript types and the [removed props table](#removed-props-at-a-glance) cover those cases.\n\n## Renamed props\n\n### Input `icon` / `iconPosition` → `leftIcon` / `rightIcon`\n\nInput used one `icon` slot plus `iconPosition`. It now uses the same two slots as `Button`, so you can also set both at once.\n\n```jsx\n// 1.x\n<Input icon={<Search />} />\n<Input icon={<Mail />} iconPosition=\"right\" />\n\n// 2.0\n<Input leftIcon={<Search />} />\n<Input rightIcon={<Mail />} />\n<Input leftIcon={<IndianRupee />} rightIcon={<Info />} /> // new: both together\n```\n\nThe behaviour is the same as before. `rightIcon` hides while the password toggle or the clear button shows, and `type=\"search\"` still draws its own search icon on the left.\n\n**Codemod:** automated when `iconPosition` is missing, `\"left\"` or `\"right\"`. If `iconPosition` is an expression, the codemod reports it and you choose the slot yourself:\n\n```jsx\n// 1.x\n<Input icon={icon} iconPosition={end ? \"right\" : \"left\"} />\n// 2.0\n<Input leftIcon={end ? undefined : icon} rightIcon={end ? icon : undefined} />\n```\n\n### `Tabs.Tab` `iconLeft` / `iconRight`\n\n```jsx\n// 1.x\n<Tabs.Tab iconLeft={<Home />}>Home</Tabs.Tab>\n// 2.0\n<Tabs.Tab leftIcon={<Home />}>Home</Tabs.Tab>\n```\n\n**Codemod:** fully automated, including `UI.Tabs.Tab` when you use a namespace import.\n\n## Behaviour change\n\n### Input clear no longer fires `onChange`\n\nIn 1.x the clear button of a controlled `Input` called `onChange({ target: { value: \"\" } })`. That fake event had no `type`, no `currentTarget` and no `preventDefault`, so handlers that read anything beyond `target.value` broke. In 2.0 the clear button only calls `onClear`, and a controlled field clears its own `value` there.\n\n```jsx\n// 1.x: worked because clear sent a synthetic onChange\n<Input clearable value={q} onChange={(e) => setQ(e.target.value)} />\n\n// 2.0\n<Input\n clearable\n value={q}\n onChange={(e) => setQ(e.target.value)}\n onClear={() => setQ(\"\")}\n/>\n```\n\nUncontrolled fields (`defaultValue`, or no value) still clear themselves, and focus still returns to the input. `Form` text fields keep working: `Form` now passes `onClear` itself and resets the react-hook-form value, then calls the field's own `onClear` if you set one. `SearchField` has its own clear and isn't affected.\n\n**Codemod:** reports every `clearable` Input that has a `value` but no `onClear`. Add the handler yourself, because only you know which state to reset.\n\n## Removed props\n\n### ProgressBar HEX colour props\n\n`color`, `bgColor` and `valueColor` took HEX strings and painted the bar inline, which ignored the brand tokens and dark mode. They are gone. Use the new `variant` prop, a token tone for both the fill and the track.\n\n```jsx\n// 1.x\n<ProgressBar value={80} color=\"#16a34a\" bgColor=\"#dcfce7\" />\n// 2.0\n<ProgressBar value={80} variant=\"success\" />\n```\n\n| `variant` | Fill | Track |\n|---|---|---|\n| `primary` (default) | `bg-primary-600` | `bg-primary-100` / `dark:bg-primary-900/40` |\n| `secondary` | `bg-secondary-600` | `bg-secondary-100` / `dark:bg-secondary-900/40` |\n| `success` | `bg-success-600` | `bg-success-100` / `dark:bg-success-900/40` |\n| `warning` | `bg-warning-600` | `bg-warning-100` / `dark:bg-warning-900/40` |\n| `destructive` | `bg-error-600` | `bg-error-100` / `dark:bg-error-900/40` |\n\nThe value text inside the fill is always `text-white`. For a colour that isn't one of these tones, set it on the brand (`tokens/brands/<name>.json`) rather than on one bar.\n\n**Codemod:** removes the three props and reports each ProgressBar it changed, so you can add a `variant`.\n\n### Checkbox `borderColor`\n\nThe box border is always the `border-gray-300` / `dark:border-gray-600` token.\n\n```jsx\n// 1.x\n<Checkbox label=\"Accept\" borderColor=\"#9ca3af\" />\n// 2.0\n<Checkbox label=\"Accept\" />\n```\n\n**Codemod:** removes the prop.\n\n### Rate `activeColor` and `inactiveColor`\n\nStars always use `text-warning-400` (active) and `text-gray-200` (inactive), so a brand file recolours them.\n\n```jsx\n// 1.x\n<Rate value={4} activeColor=\"#ef4444\" inactiveColor=\"#fee2e2\" />\n// 2.0\n<Rate value={4} />\n```\n\n**Codemod:** removes both props.\n\n### Checkbox and Switch raw colour presets\n\nThe `blue`, `indigo` and `purple` presets used the raw Tailwind palette (`bg-blue-600`, …) instead of tokens. They are removed. The token presets remain: `primary`, `secondary`, `success`, `error` and `warning`, plus the aliases `green` (success) and `danger` / `red` (error).\n\n```jsx\n// 1.x\n<Checkbox color=\"indigo\" label=\"Remember me\" />\n<Switch color=\"blue\" />\n// 2.0\n<Checkbox color=\"primary\" label=\"Remember me\" />\n<Switch color=\"primary\" />\n```\n\n**Heads-up:** `color` still accepts any CSS colour string as an inline override, and `blue`, `indigo` and `purple` are CSS colour names. So the old values don't throw an error: they now paint the CSS colour inline (pure `#0000ff` and so on) with no dark-mode tone. Replace them.\n\n**Codemod:** reports every literal `color=\"blue\" | \"indigo\" | \"purple\"` on Checkbox and Switch. It doesn't pick a token for you, because the right tone depends on your brand.\n\n### Internal: `computePosition`\n\nThe internal `computePosition` helper in `src/utils/position.js` was never exported from the package. It was unused after every overlay moved to `useFloatingPosition` (floating-ui). It is deleted. Consumers are only affected if they deep-imported library source files, which isn't supported.\n\n## Removed props at a glance\n\n| Component | 1.x prop | 2.0 |\n|---|---|---|\n| `Input` | `icon` | `leftIcon` / `rightIcon` |\n| `Input` | `iconPosition` | Choose `leftIcon` or `rightIcon` |\n| `Input` | clear → synthetic `onChange` | `onClear` (controlled: clear `value` yourself) |\n| `Tabs.Tab` | `iconLeft` / `iconRight` | `leftIcon` / `rightIcon` |\n| `ProgressBar` | `color`, `bgColor`, `valueColor` (HEX) | `variant` |\n| `Checkbox` | `borderColor` | Removed (token border) |\n| `Checkbox` | `color=\"blue\" \\| \"indigo\" \\| \"purple\"` | A token preset |\n| `Switch` | `color=\"blue\" \\| \"indigo\" \\| \"purple\"` | A token preset |\n| `Rate` | `activeColor`, `inactiveColor` | Removed (token colours) |\n\nA removed prop that is still passed falls through `...rest` to the DOM element, where React logs an \"unknown prop\" warning in development. Treat those warnings as a list of the props left to clean up.\n\n## What did not change\n\n- **No default flips.** No 1.x ticket or changelog entry promised a 2.0 default change, so every default stays as it was. Examples are `presentation=\"auto\"`, `Sidebar swipeable`, `Slider showBubble` and the opt-in `haptics`.\n- **Sizes** already used the shared `xs | sm | md | lg | xl` scale. No size was renamed.\n- **Exports:** no component, hook or util was added, renamed or removed.\n\n[← Component overview](README.md)\n",
6752
7220
  "MobileNativeFeel": "# Mobile native feel and PWA guide\n\nHow to make an app built with `@dreamtree-org/twreact-ui` feel like a native app on phones and when installed as a PWA. The components already handle safe areas, the dynamic viewport, sheets, touch targets and reduced motion. Your app still has to add the page-level parts: the viewport meta, `theme-color`, the web manifest and a few layout rules. This guide covers both, then ends with a checklist for testing on real devices.\n\n## 1. Viewport meta\n\nPut this in your HTML `<head>` (in Next.js, export a `viewport` object from the root layout with the same values):\n\n```html\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n```\n\n- `viewport-fit=cover` lets the page draw under the notch and the home indicator, and makes `env(safe-area-inset-*)` return real values. Without it, `pt-safe`, `pb-safe` and the padding inside `AppShell`, `FootNav`, `Sheet` and `BottomActionBar` all resolve to `0`.\n- Don't add `maximum-scale=1` or `user-scalable=no` to stop iOS zooming into focused inputs. That blocks pinch-zoom, which people with low vision need. The library's stylesheet prevents the focus zoom another way (see [Global mobile baseline](#9-global-mobile-baseline)).\n\n## 2. Theme colour per theme\n\n`theme-color` tints the browser toolbar on Android Chrome and the status bar in an installed PWA. Give each colour scheme its own meta tag:\n\n```html\n<meta name=\"theme-color\" content=\"#ffffff\" media=\"(prefers-color-scheme: light)\">\n<meta name=\"theme-color\" content=\"#111827\" media=\"(prefers-color-scheme: dark)\">\n```\n\n`#ffffff` / `#111827` are the default `surface` token (light / dark), which is the colour of the `AppShell` top bar (`bg-surface`). If your brand file changes `surface`, or your top bar uses a different colour, use that colour here.\n\n### Keep it in sync with `ThemeProvider`\n\n`ThemeProvider` stores an explicit `'light'` or `'dark'` choice and writes `data-theme`, the `.dark` class and `color-scheme` on `<html>`. It does not follow the OS setting. So a user can pick dark mode on a phone set to light, and the media-query tags above would then show the wrong toolbar colour. Rewrite the tags whenever the theme changes. Read the colour from the `--dt-surface` token variable (stored as space-separated RGB channels), so the tag also follows a brand file:\n\n```jsx\nimport { useEffect } from 'react';\nimport { ThemeProvider, useTheme } from '@dreamtree-org/twreact-ui';\n\nfunction ThemeColorSync() {\n const { theme } = useTheme();\n useEffect(() => {\n const rgb = getComputedStyle(document.documentElement).getPropertyValue('--dt-surface').trim();\n if (!rgb) return;\n document\n .querySelectorAll('meta[name=\"theme-color\"]')\n .forEach((meta) => meta.setAttribute('content', `rgb(${rgb})`));\n }, [theme]);\n return null;\n}\n\nexport function Root({ children }) {\n return (\n <ThemeProvider defaultTheme=\"light\">\n <ThemeColorSync />\n {children}\n </ThemeProvider>\n );\n}\n```\n\n`useTheme()` throws outside a `ThemeProvider`, so render `ThemeColorSync` inside it. Keep the static tags in the HTML anyway: they give the right colour before JavaScript runs.\n\n## 3. Web manifest\n\nA manifest makes the app installable (\"Add to Home Screen\" / \"Install app\") and opens it without browser UI:\n\n```json\n{\n \"name\": \"PracticePro\",\n \"short_name\": \"PracticePro\",\n \"start_url\": \"/\",\n \"scope\": \"/\",\n \"display\": \"standalone\",\n \"background_color\": \"#f8fafc\",\n \"theme_color\": \"#ffffff\",\n \"icons\": [\n { \"src\": \"/icons/icon-192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n { \"src\": \"/icons/icon-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\" },\n { \"src\": \"/icons/icon-maskable-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\", \"purpose\": \"maskable\" }\n ]\n}\n```\n\n```html\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<link rel=\"apple-touch-icon\" href=\"/icons/apple-touch-icon.png\">\n```\n\n- `display: \"standalone\"` removes the address bar and browser toolbars.\n- `background_color` is the splash screen colour; `#f8fafc` is the default `background` token (the page background), so the splash matches the first frame.\n- The manifest's `theme_color` has no light/dark variants. The `<meta name=\"theme-color\">` tags win once the page loads.\n- iOS uses `apple-touch-icon` for the home-screen icon. To draw the page under the iOS status bar in standalone mode, add `<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">`. Your top bar then needs `pt-safe`, which `AppShell` already has.\n- Offline support (a service worker) is up to you; the library ships none.\n\n## 4. Styling for standalone mode\n\nInstalled apps have no browser back button or address bar. Use `@media (display-mode: standalone)` for anything that should change:\n\n```css\n/* Show an in-app back button only when there is no browser UI. */\n.app-back-button { display: none; }\n\n@media (display-mode: standalone) {\n .app-back-button { display: inline-flex; }\n /* Native apps don't let you select UI chrome by accident. */\n .app-chrome { user-select: none; -webkit-user-select: none; }\n}\n```\n\nIn Tailwind 3.4 you can write it inline as an arbitrary variant: `hidden [@media(display-mode:standalone)]:inline-flex`. The library has no `standalone:` variant of its own; add one to your config if you use it a lot. In JavaScript, check `window.matchMedia('(display-mode: standalone)').matches` (and `navigator.standalone` on older iOS).\n\n## 5. Use `dvh`, not `100vh`\n\nOn phones, `100vh` is the height with the browser toolbars hidden, so a `h-screen` layout is taller than the visible area and its bottom (often the main action) ends up under the toolbar. Use the dynamic viewport units, which Tailwind 3.4 ships:\n\n| Instead of | Use |\n|------------|-----|\n| `h-screen`, `height: 100vh` | `h-dvh` (`height: 100dvh`) |\n| `min-h-screen` | `min-h-dvh` |\n| `max-h-[80vh]` | `max-h-[80dvh]` |\n\nThe library already uses them: `AppShell` fills `min-h-dvh` (its sidebar is `h-dvh`), side `Sheet`s are `h-dvh`, and `Dialog`, `Drawer`, `Tour` and the toast stack cap their height in `dvh`. If you only need a fixed full-height page and the content must never resize while the toolbar moves, `h-svh` (the smallest viewport) is the steadier choice.\n\n## 6. The app shell recipe: `AppShell` + `FootNav` + `Sheet`\n\n[`AppShell`](AppShell.md) gives you a sidebar on desktop, a top bar with a navigation drawer below `lg`, and a phone tab bar via `bottomNav`. Pass a [`FootNav`](FootNav.md) there: it renders below `md` only, and `AppShell` adds the `FootNav.Spacer` after `<main>` for you. Open quick tasks in a bottom [`Sheet`](Sheet.md) from a FAB item:\n\n```jsx\nimport { useState } from 'react';\nimport { Link } from 'react-router-dom';\nimport { AppShell, FootNav, Sheet, Button, Input } from '@dreamtree-org/twreact-ui';\nimport { Home, CheckSquare, Plus, Inbox } from 'lucide-react';\n\nexport function Layout({ path, children }) {\n const [adding, setAdding] = useState(false);\n\n return (\n <AppShell\n brand={<span className=\"text-lg font-bold\">PracticePro</span>}\n nav={[\n { id: 'home', label: 'Home', href: '/', active: path === '/' },\n { id: 'tasks', label: 'Tasks', href: '/tasks', active: path === '/tasks' },\n { id: 'inbox', label: 'Inbox', href: '/inbox', active: path === '/inbox' },\n ]}\n linkAs={Link}\n pageTitle=\"Tasks\"\n data-sheet-background\n bottomNav={\n <FootNav\n linkAs={Link}\n hideOnScroll\n items={[\n { id: 'home', label: 'Home', icon: <Home />, to: '/', active: path === '/' },\n { id: 'tasks', label: 'Tasks', icon: <CheckSquare />, to: '/tasks', active: path === '/tasks', badge: 3 },\n {\n id: 'add',\n label: 'Add',\n icon: <Plus />,\n variant: 'action',\n 'aria-haspopup': 'dialog',\n 'aria-expanded': adding,\n onClick: () => setAdding(true),\n },\n { id: 'inbox', label: 'Inbox', icon: <Inbox />, to: '/inbox', active: path === '/inbox' },\n ]}\n />\n }\n >\n {children}\n\n <Sheet open={adding} onClose={() => setAdding(false)} title=\"New task\" snapPoints={[0.5, 1]} scaleBackground>\n <Sheet.Body>\n <Input label=\"Title\" />\n </Sheet.Body>\n <Sheet.Footer>\n <Button onClick={() => setAdding(false)}>Save</Button>\n </Sheet.Footer>\n </Sheet>\n </AppShell>\n );\n}\n```\n\n- Keep `bottomNav` to the 3–5 most used destinations; the full `nav` stays in the drawer and the sidebar.\n- `data-sheet-background` goes to the `AppShell` root (it forwards `...rest`), so `scaleBackground` can scale the page behind the sheet, as iOS does. It does nothing under reduced motion.\n- Only render a `FootNav.Spacer` yourself when you use `FootNav` without `AppShell`.\n- For a form's primary action, use [`BottomActionBar`](BottomActionBar.md) (with `<BottomActionBar.Spacer />`) rather than a hand-built `fixed bottom-0` bar: it pads the safe area and rides above the on-screen keyboard.\n- Other native patterns ship as components: [`PullToRefresh`](PullToRefresh.md), [`SwipeableListItem`](SwipeableListItem.md), [`SearchField`](SearchField.md), [`SegmentedControl`](SegmentedControl.md) and [`ActionSheet`](ActionSheet.md).\n\n## 7. Overlays: `presentation` overrides\n\nOn small touch screens (`(pointer: coarse) and (max-width: 639px)`), these components switch to a phone presentation by default (`presentation=\"auto\"`):\n\n| Component | Phone presentation |\n|-----------|--------------------|\n| [`Select`](Select.md), [`DatePicker`](DatePicker.md), [`DateRangePicker`](DateRangePicker.md), [`Popover`](Popover.md), [`SearchField`](SearchField.md) results | Bottom `Sheet` |\n| [`Dialog`](Dialog.md), [`ConfirmDialog`](ConfirmDialog.md) | Bottom `Sheet` instead of the centred modal |\n| [`ThreeDotPopover`](ThreeDotPopover.md), the [`Table`](Table.md) row menu | Action sheet |\n| [`Tour`](Tour.md) | Step card docked at the bottom |\n\nEverywhere else, and on the server and while hydrating, they render the desktop popover or modal. Each takes `presentation=\"auto\" | \"popover\" | \"sheet\"`. Override it only for a reason:\n\n- **Force `popover`** when the content needs the context around it: a small `Popover` next to the control it adjusts, a sort menu that should not cover the list, a `Tour` step that must point at its target, or a `Dialog` that a tablet-sized coarse-pointer screen should keep centred.\n- **Force `sheet`** when the content is long or full of touch controls on every screen size: a filter panel, a long `Select` list, a multi-field quick form.\n- Leave `auto` alone otherwise. The breakpoint matches the one the rest of the library uses, so all overlays switch together.\n\n`Tooltip` has no sheet presentation (it opens on long-press on touch screens); use `Popover` for interactive content.\n\n## 8. Safe areas\n\nWith `viewport-fit=cover` set, the stylesheet's utilities pad for the notch, rounded corners and home indicator:\n\n| Class | Pads |\n|-------|------|\n| `pt-safe`, `pb-safe`, `pl-safe`, `pr-safe` | One edge (`env(safe-area-inset-*)`) |\n| `px-safe` | Left and right |\n| `py-safe` | Top and bottom |\n| `inset-safe` | Sets `top` / `right` / `bottom` / `left` to the insets (for positioned elements) |\n\nAlready padded, so don't add them again: the `AppShell` top bar (`pt-safe px-safe`), sidebar (`pt-safe pb-safe pl-safe`) and content column (`px-safe`); `FootNav` (`pb-safe`); `BottomActionBar` (`pb-safe px-safe`); `Sheet` (bottom: `pb-safe px-safe`, top: `pt-safe px-safe`, sides: `py-safe` plus the outer edge).\n\nAdd them yourself to any edge bar you build: a custom sticky header gets `pt-safe`, a full-bleed bottom element `pb-safe`, and in landscape any full-width row `px-safe`.\n\n## 9. Global mobile baseline\n\nImporting the library's CSS gives every page this baseline (from `src/styles/index.css`):\n\n- **No tap highlight.** `-webkit-tap-highlight-color: transparent` on `:root`; components show their own pressed and `focus-visible` states instead. `text-size-adjust: 100%` stops iOS from enlarging text in landscape.\n- **No zoom on input focus.** On coarse pointers, text `input`s, `select`s and `textarea`s are at least 16px (`font-size: max(16px, 1em) !important`, so it beats `text-sm`). iOS Safari zooms into fields with smaller text. Checkboxes, radios, ranges, colour and file inputs are not affected. Add `dt-keep-font-size` to a field to opt it out.\n- **No rubber-band overscroll.** `body` has `overscroll-behavior-y: none`, so the page doesn't bounce or chain scroll, and the browser's own pull-to-refresh doesn't fight [`PullToRefresh`](PullToRefresh.md). To get the browser's pull-to-refresh back, add `dt-overscroll-auto` to `<html>` or `<body>`.\n- **`color-scheme`** follows the theme (`light` by default, `dark` under `.dark` / `[data-theme=\"dark\"]`), so native scrollbars and form controls match.\n- **Reduced motion.** Under `prefers-reduced-motion: reduce`, animations and transitions are cut to near zero, and components such as `Sheet`, `FootNav` and `Dialog` skip their slides.\n- Touch targets in the components grow to 44px on coarse pointers.\n\n## 10. Haptics are opt-in\n\nNothing vibrates unless you ask. [`useHaptics`](useHaptics.md) wraps `navigator.vibrate` with named patterns (`selection`, `success`, `warning`, `error`), and `Switch`, `Checkbox`, `ConfirmDialog` and `Sheet` take a `haptics` prop that defaults to `false`:\n\n```jsx\n{/* Each alongside its usual props (open, onClose, onConfirm, …) */}\n<Switch haptics rightLabel=\"Notifications\" />\n<ConfirmDialog haptics destructive />\n<Sheet haptics snapPoints={[0.5, 1]} />\n```\n\n- iOS Safari has no vibration API, so haptics never fire on iPhones. Treat them as a bonus on Android, never as the only feedback.\n- Calls are no-ops on the server, while hydrating, under reduced motion and before the user's first interaction.\n- To let users turn haptics off, pass your setting as `useHaptics({ enabled })` or as the components' `haptics` prop.\n\n## 11. Real-device testing checklist\n\nEmulators and desktop responsive mode miss most of these. Test on at least one iPhone (iOS Safari) and one Android phone (Chrome), in the browser and installed to the home screen.\n\n**Safe area**\n- [ ] On a notched iPhone, nothing sits under the notch or the Dynamic Island in portrait, and nothing hides under the rounded corners in landscape.\n- [ ] The `FootNav` labels and `BottomActionBar` buttons sit above the home indicator.\n- [ ] A bottom `Sheet`'s last row isn't covered by the home indicator.\n\n**No zoom on input focus**\n- [ ] Tapping an `Input`, `Select`, `Textarea` or `SearchField` does not zoom the page on iOS Safari.\n- [ ] Pinch-zoom still works (the viewport meta doesn't block it).\n- [ ] With the keyboard open, the `BottomActionBar` and a bottom `Sheet` stay above it.\n\n**Sheet swipe**\n- [ ] Dragging a bottom `Sheet` (handle, header or body scrolled to the top) follows the finger and dismisses past the threshold; a short drag snaps back.\n- [ ] With `snapPoints`, the sheet settles on each snap point.\n- [ ] Scrolling inside `Sheet.Body` scrolls the content, not the page behind it.\n- [ ] The left navigation drawer of `AppShell` closes when dragged left.\n\n**No horizontal scroll at 375px**\n- [ ] At 375px wide (iPhone SE / mini), no page scrolls sideways. Wide tables, long words and fixed-width elements are the usual cause.\n- [ ] Five `FootNav` tabs fit, and long labels truncate.\n\n**Standalone mode**\n- [ ] The app installs from Android Chrome (\"Install app\") and iOS Safari (Share → Add to Home Screen) with the right icon and name.\n- [ ] It opens without browser UI, and the status bar / toolbar colour matches the theme in light and dark (including after switching with `ThemeProvider`).\n- [ ] The splash screen colour matches the first frame.\n- [ ] Every screen has a way back without a browser back button, and links within `scope` stay in the app.\n\n**General**\n- [ ] Pressing a button gives visual feedback with no grey tap flash.\n- [ ] Overlays open as sheets on the phone and as popovers / modals on a desktop browser.\n- [ ] With Reduce Motion on (iOS Settings → Accessibility → Motion; Android Remove animations), sheets and the tab bar appear without sliding.\n\n[← Component overview](README.md)\n",
6753
7221
  "Navbar": "# Navbar\n\nTop navigation bar with logo, nav items (links or buttons), optional search, notifications dropdown, and login/logout. Uses an **emitter** for notification actions (view, markAsRead, markAsUnread, clearAll, delete). Responsive: left icon and hamburger on mobile; desktop nav and search. You can replace the right area with a custom component via `rightMenuContent`. The bar, search box and dropdown panels use the semantic `bg-surface` / `border-line` / `text-foreground` tokens, so they follow dark mode.\n\n## Import\n\n```jsx\nimport { Navbar } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `emitter` | `object` | **required** | Event emitter for notifications (e.g. `notification:view`, `notification:markAsRead`, etc.). |\n| `logo` | `ReactNode` | — | Logo (e.g. image or link). Truncates on narrow screens. |\n| `center` | `ReactNode` | — | Centre slot (e.g. a search field or primary action) between the logo and the right-hand icons. Rendered in a `min-w-0 flex-1` column so it shrinks first on narrow screens; give its content a fluid width such as `w-full max-w-md`. |\n| `items` | `Array` | `[]` | Nav items: `{ id, label, href?, active?, onClick?(item) }`. If `href` is set, renders `<a>`; else `<button>` with `onClick`. |\n| `user` | `object` | — | `{ name, email, avatar?, menuItems? }`. If set, shows the user menu and a logout button; if omitted, a login button. Without `avatar`, the menu trigger shows `name` initials (via `Avatar`). |\n| `notifications` | `Array` | `[]` | List of notifications for the dropdown. Each: `{ id, title, message?, read? }`. |\n| `searchable` | `boolean` | `false` | Show the search input from `sm` up. Below `sm` a **Search** icon button opens a top `Sheet` holding a [`SearchField`](SearchField.md) instead (same term, same `onSearch`). |\n| `onSearch` | `(term: string) => void` | — | Called with the current term on search form submit (desktop) or Enter in the phone search sheet (the sheet then closes). |\n| `sticky` | `boolean` | `false` | Sticks the bar to the top (`sticky top-0 z-sticky`), pads it with the top safe area (`pt-safe`) and makes it translucent (`bg-surface/80 backdrop-blur` where `backdrop-filter` is supported; opaque `bg-surface` otherwise). |\n| `largeTitle` | `ReactNode` | — | iOS large title: an `<h1>` under the bar that collapses into a compact centred title in the bar once the window scrolls past 44px (and expands again under 8px). Passive scroll listener, SSR-safe (expanded on the server), instant under `prefers-reduced-motion`. |\n| `leftIcon` | `ReactNode` \\| `false` | `<ChevronLeft />` | Icon shown on mobile (e.g. back). Set `false` to hide. |\n| `onLeftIconClick` | `(event) => void` | — | Called when left icon (mobile) is clicked. |\n| `leftIconLabel` | `string` | `\"Go back\"` | Accessible name of the left button. Set it when the button is not \"back\", e.g. `\"Open navigation\"` when it opens a `Sidebar` drawer. |\n| `leftIconProps` | `ButtonHTMLAttributes` | — | Extra props spread onto the left `<button>` — e.g. `{ \"aria-expanded\": open, \"aria-controls\": \"app-sidebar\" }` when it toggles a drawer. `className` merges last; `onClick` runs after `onLeftIconClick`. |\n| `rightMenuContent` | `(props) => ReactNode` | — | Custom right menu. Receives `{ notifications }` and any Navbar `...props` (e.g. `emitter`, `onFetch`). If not provided, default renders notifications dropdown. |\n| `onLoginClick` | `() => void` | — | Called when the login icon button (accessible name \"Sign in\") is clicked. |\n| `onLogoutClick` | `() => void` | — | Called when the logout icon button (accessible name \"Sign out\") or the user-menu **Sign out** item is clicked. |\n| `onFetch` | `({ setNotifications }) => void` | — | Refresh handler for the default notifications dropdown. Called on mount and when `notifications` changes. Consumed by the right menu — it is **not** forwarded onto `<nav>`. |\n| `className` | `string` | — | Wrapper CSS classes. |\n| ...rest | — | — | Only DOM-valid attributes (e.g. `id`, `aria-*`, `data-*`, event handlers) are forwarded to the root `<nav>`. The component's own control props (`isMobileOpen`, `setIsMobileOpen`, `onFetch`, …) are consumed and never leak to the DOM. |\n\n`Navbar` forwards `ref` to its root `<nav>` element.\n\n## Touch targets and labels\n\nIcon buttons (back, search, notifications, sign in/out, menu, user menu) are 36px on desktop and grow to the 44×44px touch target on coarse pointers, with a `focus-visible` ring. Accessible names: **Go back** (the left button; rename it with `leftIconLabel`), **Search**, **Notifications** (or **Notifications, 3 unread**), **Sign in**, **Sign out**, **Open menu** / **Close menu** (with `aria-expanded`) and **User menu**.\n\nThe unread badge grows with its count (`min-w-4` + padding) and caps at **99+**. It is `aria-hidden`; the count is in the bell's name.\n\n## Sticky bar and large title\n\n```jsx\n<Navbar emitter={emitter} logo={<Logo />} sticky largeTitle=\"Invoices\" searchable onSearch={search} />\n```\n\nThe large title collapses with the window scroll, so use it on pages that scroll the document (not an inner scroll container). Add `viewport-fit=cover` to your viewport meta for the safe-area inset.\n\n## Small screens (≤ 390px)\n\nThe header fits a 390px phone: columns use `gap` + `min-w-0` so the logo\ntruncates and the `center` slot shrinks instead of overflowing. Below the `sm`\nbreakpoint the standalone logout icon is hidden and a **Sign out** item appears\nat the bottom of the user menu instead (it calls `onLogoutClick`). With\n`searchable`, the inline search box is replaced by a **Search** button that\nopens the search sheet. From `sm` up the layout is unchanged.\n\n## Centre slot and initials avatar\n\n```jsx\n<Navbar\n emitter={emitter}\n logo={<Logo />}\n center={<Input type=\"search\" placeholder=\"Search tasks...\" className=\"w-full max-w-md\" />}\n user={{ name: 'Ada Lovelace', email: 'ada@example.com' }} // no avatar → \"AL\"\n/>\n```\n\n## Item shape (items)\n\n- `id`: string or number (key).\n- `label`: string.\n- `href`: optional; if set, item is an `<a href={href}>`.\n- `active`: optional boolean; applies primary styling.\n- `onClick(item)`: optional; used when no `href` (button click).\n\n## Notification shape (notifications)\n\n- `id`: string or number.\n- `title`: string.\n- `message`: optional string.\n- `read`: optional boolean; unread count uses `!n.read`.\n\n## Default notifications dropdown\n\nIf `rightMenuContent` is not provided, the right menu shows a bell icon and dropdown with:\n\n- Unread count badge.\n- \"Clear all\" that clears the list and calls emitter `notification:clearAll`.\n- Per notification: title, message, \"Mark as Read\" / \"Mark as Unread\", \"Delete\" (emitter events).\n- Clicking a notification calls `emitter.emit('notification:view', [n])`.\n\nTo refresh the list from the server, pass `onFetch({ setNotifications })`; the default dropdown uses it on mount and when `notifications` prop changes.\n\n## Example\n\n```jsx\nimport { Navbar } from '@dreamtree-org/twreact-ui';\nimport MyEmitter from './emitter';\n\n<Navbar\n emitter={MyEmitter}\n logo={<a href=\"/\"><img src=\"/logo.svg\" alt=\"Home\" /></a>}\n items={[\n { id: '1', label: 'Home', href: '/', active: pathname === '/' },\n { id: '2', label: 'About', href: '/about' },\n { id: '3', label: 'Settings', onClick: () => openSettings() },\n ]}\n user={user}\n notifications={notifications}\n searchable\n onSearch={(term) => doSearch(term)}\n onLoginClick={() => navigate('/login')}\n onLogoutClick={logout}\n onFetch={async ({ setNotifications }) => {\n const res = await fetch('/api/notifications');\n const data = await res.json();\n setNotifications(data.items);\n }}\n/>\n```\n\n### Left button as the Sidebar menu button\n\n```jsx\nimport { Menu } from 'lucide-react';\n\n<Navbar\n emitter={MyEmitter}\n leftIcon={<Menu />}\n leftIconLabel=\"Open navigation\"\n leftIconProps={{ 'aria-expanded': drawerOpen, 'aria-controls': 'app-sidebar' }}\n onLeftIconClick={() => setDrawerOpen(true)}\n/>\n<Sidebar id=\"app-sidebar\" items={items} isMobileOpen={drawerOpen} setIsMobileOpen={setDrawerOpen} />\n```\n\n[← Component overview](README.md)\n",
7222
+ "OptionCard": "# OptionCard\n\nSelectable choice tile for **rich choices**: page templates (with a small wireframe), \"add a block\" pickers, starter kits. Each tile has a title, a description, an optional media slot, an optional badge and a selected state (primary border and ring **plus** a check indicator, so it never relies on colour alone). Wrap tiles in `OptionCardGroup` for a responsive grid with `role=\"radiogroup\"` semantics, or `multiple` for checkbox-group semantics. On its own an `OptionCard` is a toggle button (`aria-pressed`). For short text-only choices use `SegmentedControl` or `Radio` instead.\n\n## Import\n\n```jsx\nimport { OptionCard, OptionCardGroup } from '@dreamtree-org/twreact-ui';\n// OptionCard.Group is the same component as OptionCardGroup.\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `value` | `string` | — | The value this tile stands for (a string or number). Required inside an `OptionCardGroup`, where it is compared with the group's `value`; passed to `onSelect` everywhere. |\n| `title` | `ReactNode` | — | Tile heading. Also the tile's accessible name (`aria-labelledby`). |\n| `description` | `ReactNode` | — | Secondary text under the title (`text-muted`). Also the tile's accessible description (`aria-describedby`). |\n| `media` | `ReactNode` | — | Optional visual above the text, e.g. a small wireframe SVG or an image. Clipped to rounded corners. |\n| `badge` | `ReactNode` | — | Optional small pill next to the title, e.g. `\"New\"` or `\"Pro\"`. |\n| `selected` | `boolean` | `false` | Standalone only: whether the tile is pressed (controlled). Ignored inside a group, which derives it from its `value`. |\n| `onSelect` | `(value, event) => void` | — | Called when the tile is activated (click, `Enter` or `Space`), standalone or inside a group. Not called while disabled. |\n| `disabled` | `boolean` | `false` | Dims the tile, sets `aria-disabled`, removes it from the tab order and keyboard navigation, and ignores activation. |\n| `children` | `ReactNode` | — | Extra content rendered below the description. |\n| `className` | `string` | — | Merged onto the tile with `cn`, so your classes win on conflict. |\n\nOther props (`id`, `data-*`, event handlers, …) go to the tile's root `<div>` (`role=\"radio\"`, `\"checkbox\"` or `\"button\"`), which also receives the forwarded `ref`. Your `onClick` / `onKeyDown` run first; call `event.preventDefault()` to stop the tile from selecting.\n\n## OptionCardGroup props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `value` | `string \\| string[]` | — | Controlled selection: a single value, or an array of values with `multiple`. |\n| `defaultValue` | `string \\| string[]` | — | Uncontrolled initial selection. Without `value` or `defaultValue` nothing is selected and the first enabled tile is the tab stop. |\n| `onChange` | `(value) => void` | — | Receives the new value (single) or the new array (`multiple`), not a DOM event. In single mode it is not called when the selected tile is activated again. |\n| `multiple` | `boolean` | `false` | Multi-select: the root becomes `role=\"group\"`, tiles become `role=\"checkbox\"`, and `value` is an array. |\n| `columns` | `number` | `3` | `1`–`4`. Always one column on phones; two from `sm`, then `columns` from `lg`. |\n| `label` | `ReactNode` | — | Visible label above the grid, wired up as the group's `aria-labelledby`. |\n| `aria-label` | `string` | — | Accessible name when there is no visible `label`. Pass `label`, `aria-label` or `aria-labelledby`. |\n| `name` | `string` | — | Submits the selection with a form through hidden inputs (one per selected value). |\n| `disabled` | `boolean` | `false` | Disable every tile (dimmed, `aria-disabled` on the root and on each tile). |\n| `className` | `string` | — | Merged onto the root with `cn`. |\n\nOther props go to the root `<div role=\"radiogroup\">` (or `role=\"group\"`), which receives the forwarded `ref`. Tiles connect to their group through React context, so they can sit at any depth inside it.\n\n## Behavior\n\n- **Keyboard (group):** one tab stop (roving tabindex): the last focused tile, else the selected tile, else the first enabled tile. `ArrowRight` / `ArrowDown` and `ArrowLeft` / `ArrowUp` move to the next / previous tile in DOM order, skip disabled tiles and wrap around; `Home` / `End` jump to the first / last enabled tile. Under `dir=\"rtl\"` the left and right arrows swap. In a single-select group the arrows also **select** (standard radio behaviour); in a `multiple` group they only move focus. `Space` / `Enter` select the focused tile (toggle it with `multiple`).\n- **Keyboard (standalone):** the tile is a focusable `role=\"button\"` with `aria-pressed`; `Enter` / `Space` activate it.\n- **Styling:** `bg-surface` tile with a `border-line` border; selected tiles get a primary border, a `ring-1` primary ring, a light primary tint (dark-mode aware) and a filled check indicator in the top-right corner (round for radios, square for checkboxes). Tiles are at least 44px tall, lift on hover, show a `focus-visible` ring and shrink slightly while pressed. Transitions and the press effect are off under `prefers-reduced-motion`.\n- **Media:** decorative wireframes should carry `aria-hidden=\"true\"`; the tile's name comes from `title`.\n\n## Example\n\n```jsx\nconst [template, setTemplate] = useState('classic');\n\n<OptionCardGroup value={template} onChange={setTemplate} columns={3} aria-label=\"Template\">\n <OptionCard value=\"classic\" title=\"Classic\" description=\"Filters beside the grid\" media={<Sketch />} />\n <OptionCard value=\"stacked\" title=\"Stacked\" description=\"Filters above the grid\" badge=\"New\" />\n <OptionCard value=\"minimal\" title=\"Minimal\" description=\"No filters\" disabled />\n</OptionCardGroup>\n\n<OptionCardGroup multiple label=\"Blocks to add\" defaultValue={['hero']} onChange={setBlocks} columns={2}>\n <OptionCard value=\"hero\" title=\"Hero\" description=\"Large heading with a call to action\" />\n <OptionCard value=\"faq\" title=\"FAQ\" description=\"Collapsible questions\" />\n</OptionCardGroup>\n\n<OptionCard value=\"starter\" title=\"Starter kit\" description=\"Pages and blocks to begin with\" selected={on} onSelect={() => setOn(!on)} />\n```\n\n[← Component overview](README.md)\n",
6754
7223
  "PageHeader": "# PageHeader\n\nPage-level block: the page title (`h1`), a muted subtitle, the page's actions (one primary action) and an optional back link. Extracted from taskpro `features/shared/PageHeader.jsx`.\n\n## Import\n\n```jsx\nimport { PageHeader } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `title` | `ReactNode` | — | Page title, rendered as the page's only `h1` |\n| `subtitle` | `ReactNode` | — | One line under the title |\n| `actions` | `ReactNode` | — | Buttons on the right; keep one primary action |\n| `actionsPlacement` | `'inline' \\| 'bottom'` | `\"inline\"` | `inline`: actions next to the title. `bottom`: on phones (coarse pointer and ≤639px) the actions are pinned in a [`BottomActionBar`](BottomActionBar.md) (`fullWidth`) at the bottom of the screen; on desktop, and on the server until hydration, they stay inline |\n| `back` | `{ href: string; label: string }` | — | Back link for detail pages. Grows to a 44px touch target on coarse pointers; `focus-visible` ring |\n| `linkAs` | `ElementType` | `\"a\"` | Link element for the back link |\n| `className` | `string` | — | Extra CSS classes (merged with `cn`) |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>`; the ref is forwarded to it |\n\n## Example\n\n```jsx\n<PageHeader\n title=\"Compliance calendar\"\n subtitle=\"Track deadlines, ownership and filing readiness across clients\"\n actions={<Button leftIcon={<Plus className=\"h-4 w-4\" />}>Add obligation</Button>}\n/>\n```\n\n### Actions at the bottom on phones\n\n```jsx\n<PageHeader\n title=\"New invoice\"\n back={{ href: '/invoices', label: 'Invoices' }}\n actionsPlacement=\"bottom\"\n actions={\n <>\n <Button variant=\"outline\">Save draft</Button>\n <Button>Send invoice</Button>\n </>\n }\n/>\n{form}\n<BottomActionBar.Spacer className=\"sm:hidden\" />\n```\n\nWith `actionsPlacement=\"bottom\"`, put a `BottomActionBar.Spacer` at the end of the page so the bar doesn't cover the last fields. The actions render once (never duplicated), so ids and test queries stay unique.\n\n[← Component overview](README.md)\n",
6755
7224
  "Pagination": "# Pagination\n\nPage navigation: first, previous, page numbers, next, last. Renders only when `totalPages > 1`. Optional `theme` for styling (e.g. from Table). Page buttons use the semantic `bg-surface` / `border-line` / `text-foreground` tokens, so they follow dark mode.\n\n## Import\n\n```jsx\nimport { Pagination } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `currentPage` | `number` | `1` | Current page (1-based). |\n| `totalPages` | `number` | `1` | Total pages. If ≤ 1, component returns null. |\n| `onPageChange` | `(page: number) => void` | — | Called when user selects a page. |\n| `showFirstLast` | `boolean` | `true` | Show first/last page buttons (and the `...` ellipses). The jump-to-first / jump-to-last button only renders when page 1 / the last page is outside the visible page window, so no page number ever appears twice. |\n| `showPrevNext` | `boolean` | `true` | Show previous/next buttons. |\n| `maxVisiblePages` | `number` | `5` | Max page number buttons to show. |\n| `className` | `string` | — | Nav wrapper CSS classes. |\n| ...rest | — | — | Passed to `<nav>`. |\n\n**Responsive:** below the `sm` breakpoint (640 px) the bar collapses to a compact `‹ Page 3 of 10 ›` layout. Only previous/next and the status text show; the page numbers, jump buttons and ellipses are hidden with CSS (`hidden sm:inline-flex`). From `sm` up the full layout is unchanged. When `showPrevNext={false}`, nothing would drive the compact view, so the full layout shows at every width. Buttons wrap instead of overflowing, grow to a 44 px touch target on coarse pointers, show a `focus-visible` ring, and have dark-mode colours.\n\n## Example\n\n```jsx\n<Pagination\n currentPage={page}\n totalPages={Math.ceil(total / pageSize)}\n onPageChange={setPage}\n maxVisiblePages={5}\n/>\n```\n\n[← Component overview](README.md)\n",
6756
7225
  "Popover": "# Popover\n\nAnchored, click-triggered panel for **interactive** content — filter forms, share menus, quick editors. Unlike `Tooltip` (hover/focus, read-only text), a Popover takes focus, can hold buttons and inputs, and stays open until dismissed. Positioned in a portal by the shared floating engine (flip to the opposite side, shift to stay in view). On phones it becomes a bottom sheet (see **Presentation**). The panel uses the semantic `bg-surface` / `border-line` / `text-foreground` tokens, so it follows dark mode.\n\n## Import\n\n```jsx\nimport { Popover } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `trigger` | `ReactElement` | — | Element that toggles the popover (required). Gets `aria-haspopup`, `aria-expanded`, `aria-controls` and an `onClick`; its own `onClick` still runs (call `preventDefault()` to stop the toggle). A string is wrapped in a `<button>` |\n| `children` | `ReactNode or function` | — | Panel content, or `({ close }) => ReactNode` |\n| `open` | `boolean` | — | Controlled open state |\n| `defaultOpen` | `boolean` | `false` | Initial state when uncontrolled |\n| `onOpenChange` | `function` | — | `(open) => void`, called on every open/close |\n| `placement` | `string` | `\"bottom-start\"` | `top`, `bottom`, `left`, `right`, each optionally with `-start` or `-end`; flips when there is no room |\n| `offset` | `number` | `8` | Gap in px between trigger and panel |\n| `title` | `ReactNode` | — | Heading inside the panel; labels the dialog |\n| `initialFocusRef` | `RefObject` | — | Element to focus on open (default: first focusable element) |\n| `disabled` | `boolean` | `false` | Disable the trigger |\n| `presentation` | `\"auto\"`, `\"popover\"` or `\"sheet\"` | `\"auto\"` | `auto` shows a bottom [`Sheet`](Sheet.md) on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and the anchored panel elsewhere; `popover` / `sheet` force one. `auto` renders the panel on the server and while hydrating |\n| `id` | `string` | — | Panel id (auto-generated otherwise) |\n| `className` | `string` | — | Extra classes for the panel, or for the sheet panel in sheet mode (merged with `cn`) |\n| `...rest` | `HTMLAttributes` | — | Forwarded to the panel (`aria-label`, `data-*`, …); `ref` points at the panel (the sheet panel in sheet mode). The panel carries `data-presentation=\"popover\"` or `\"sheet\"` |\n\nSupports both modes: **uncontrolled** (`defaultOpen`) for most cases, **controlled** (`open` + `onOpenChange`) when something else must open or close it.\n\n## Examples\n\n### Basic\n\n```jsx\n<Popover trigger={<Button variant=\"outline\">Filters</Button>} title=\"Filter tasks\">\n {({ close }) => (\n <div className=\"space-y-3\">\n <Checkbox label=\"Assigned to me\" />\n <Button size=\"sm\" onClick={close}>Apply</Button>\n </div>\n )}\n</Popover>\n```\n\n### Controlled\n\n```jsx\nconst [open, setOpen] = useState(false);\n\n<Popover open={open} onOpenChange={setOpen} placement=\"top-end\" aria-label=\"Share\" trigger={<Button>Share</Button>}>\n <p>Anyone with the link can view.</p>\n</Popover>\n```\n\n### Presentation (phones)\n\n```jsx\n// Default: anchored panel on desktop, bottom sheet on phones\n<Popover trigger={<Button>Sort</Button>} title=\"Sort by\">…</Popover>\n\n// Keep the anchored panel everywhere\n<Popover presentation=\"popover\" trigger={<Button>Sort</Button>}>…</Popover>\n```\n\nIn sheet mode the content renders inside `<Sheet.Body>` of a bottom `Sheet`: `title` becomes the sheet title (with its close button), and the sheet adds a grab handle, drag-to-dismiss, a backdrop and the safe-area padding. `open` / `onOpenChange`, the `({ close })` render prop, `initialFocusRef`, `className`, `id` and `ref` work the same way.\n\n## Accessibility & keyboard\n\n| Behavior | Detail |\n|----------|--------|\n| Trigger | `aria-haspopup=\"dialog\"`, `aria-expanded`, `aria-controls` (the panel id while open) |\n| Panel | `role=\"dialog\"` (non-modal), labelled by `title` or your `aria-label` |\n| Focus on open | `initialFocusRef`, else the first focusable element, else the panel |\n| `Escape` | Closes and returns focus to the trigger |\n| Outside click | Closes; focus stays where the user clicked |\n| `Tab` past the last item / `Shift`+`Tab` before the first | Closes and continues from the trigger, as if the panel sat right after it |\n| Reduced motion | Fade-in is disabled under `prefers-reduced-motion` |\n| Dark mode | Panel uses `dark:` variants |\n| Sheet mode | The sheet is a **modal** `role=\"dialog\"` (`aria-modal=\"true\"`): focus is trapped inside, `Escape`, a backdrop tap or a downward drag closes it, and focus returns to the trigger |\n\n[← Component overview](README.md)\n",
6757
7226
  "PriceRangePicker": "# PriceRangePicker\n\nDual-thumb range slider for min/max values (e.g. price or any number). Optional number inputs and preset buttons. Controlled via `value` or uncontrolled via `defaultValue`.\n\n## Import\n\n```jsx\nimport { PriceRangePicker } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `min` | `number` | `0` | Minimum value. |\n| `max` | `number` | `1000` | Maximum value. |\n| `step` | `number` | `1` | Step for thumbs and inputs. |\n| `minGap` | `number` | `5` | Minimum gap between min and max. |\n| `value` | `{ min, max }` \\| `null` | `null` | Controlled value. |\n| `defaultValue` | `{ min, max }` | `{ min: 0, max: 1000 }` | Uncontrolled initial value. |\n| `onChange` | `(value: { min, max }) => void` | `() => {}` | Called on thumb/input change. |\n| `onFinalChange` | `(value: { min, max }) => void` | `() => {}` | Called when a thumb drag ends (mouse, touch or pen), on each thumb key press, and on Enter/blur in the inputs. |\n| `showInputs` | `boolean` | `true` | Show min/max number inputs. |\n| `showTooltips` | `boolean` | `true` | Show a value tooltip above the thumb while dragging. |\n| `disabled` | `boolean` | `false` | Disable the control. |\n| `presets` | `Array<{ id, label, value: { min, max } }>` | `[]` | Preset buttons. |\n| `formatValue` | `(n: number) => string` | `(n) => String(n)` | Format display (e.g. currency). |\n| `className` | `string` | `\"\"` | Container CSS classes. |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Thumb and spacing size. |\n| `colorClass` | `string` | `\"bg-primary-600\"` | Tailwind `bg-*` class for the active track and both thumbs. |\n| `ariaLabel` | `string` | `\"Price range\"` | Accessibility label. |\n\n## Accessibility and touch\n\n- Each thumb is a `role=\"slider\"` (`aria-label` is `\"{ariaLabel} - min\"` / `\"{ariaLabel} - max\"`). Arrow keys step, Page Up/Down move 10% of the range, Home/End jump to the bounds.\n- Pointer drag uses pointer capture: pressing anywhere on the track grabs the nearest thumb, and a release outside the track still ends the drag. Both thumbs work with mouse, touch and pen.\n- On touch screens each thumb has a 44px hit area; the number inputs use `inputMode=\"decimal\"`.\n- Since 1.4: the default colour is the brand `primary` token (was pink), and the component no longer injects a global `<style>` that restyled every `input[type=range]` on the page.\n\n## Example\n\n```jsx\nconst [range, setRange] = useState({ min: 100, max: 500 });\n<PriceRangePicker\n min={0}\n max={1000}\n value={range}\n onChange={setRange}\n formatValue={(n) => `$${n}`}\n presets={[\n { id: '1', label: 'Under $200', value: { min: 0, max: 200 } },\n ]}\n/>\n```\n\n[← Component overview](README.md)\n",
7227
+ "ProductCard": "# ProductCard\n\nPage-level block: a storefront product tile for listing grids, featured rows and \"similar products\". Cover image (with an optional second image on hover), badges over the image, brand, title, spec line, price with a struck-through compare-at price and a computed discount, an actions slot and an optional compare toggle. Replaces the Card + Badge + Checkbox + Button compositions apps kept hand-rolling (issue #181).\n\nThe whole card is one link: the title is the link, and its `::after` stretches over the card so the image and blank space are clickable too, with no interactive elements nested inside the link. `actions` and the compare checkbox sit above it and stay separately focusable. The focus ring is drawn around the whole card; the hover zoom and image swap are off under `prefers-reduced-motion`.\n\n## Import\n\n```jsx\nimport { ProductCard } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `title` | `ReactNode` | — | Product name; becomes the link when `href` / `to` / `as` is set. Also the default image `alt` when it is a string |\n| `image` | `string` \\| `{ src: string; alt?: string }` | — | Cover image |\n| `images` | `Array<string \\| { src: string; alt?: string }>` | — | More images; with `image` set they follow it, otherwise the first one is the cover. The second image is used by `hover=\"second-image\"` |\n| `imageAlt` | `string` | `title` | `alt` for images given as plain strings |\n| `imageRatio` | `4:3` \\| `1:1` \\| `3:4` | `\"4:3\"` | Aspect ratio of the image area |\n| `hover` | `none` \\| `zoom` \\| `second-image` | `\"none\"` | Image effect on hover; disabled under reduced motion. The second image is decorative (`alt=\"\"`) |\n| `eyebrow` | `ReactNode` | — | Small line above the title, usually the brand |\n| `meta` | `ReactNode` | — | Spec line under the title (size, colour, pack) |\n| `badges` | `ReactNode[]` | — | Overlaid on the image's top-left corner, e.g. `<Badge>` elements |\n| `price` | `number` \\| `string` | — | Selling price. Numbers go through `priceFormatter`; strings (\"From ₹999\") pass through |\n| `compareAtPrice` | `number` \\| `string` | — | Original price, struck through, announced as \"Original price\" to screen readers |\n| `discountLabel` | `ReactNode` | — | Shown after the prices. When omitted and both prices are numbers with `compareAtPrice` higher, it is computed, e.g. `\"26% off\"` |\n| `priceFormatter` | `(value: number) => ReactNode` | INR, `en-IN`, no decimals | Formats numeric prices (`2999` → `₹2,999`, `125000` → `₹1,25,000`) |\n| `layout` | `stack` \\| `row` | `\"stack\"` | `row`: image beside the text on phones, stacked from `sm` up |\n| `href` | `string` | — | Link target; renders the title as an `<a>` |\n| `to` | `string` | — | Router target passed to `as` (e.g. React Router `Link`); without `as` it is used as the `href` |\n| `as` | `ElementType` | `\"a\"` | Link component for the title, like `Button`'s `as` |\n| `actions` | `ReactNode` | — | Buttons under the price (Add to cart, Wishlist), above the stretched link |\n| `selectable` | `boolean` | `false` | Shows a \"Compare\" checkbox on the image |\n| `selected` | `boolean` | — | Controlled compare state; omit for uncontrolled |\n| `onSelectedChange` | `(selected: boolean, event) => void` | — | Called when the compare checkbox is toggled |\n| `placeholder` | `ReactNode` | image-off icon | Shown when there is no image or the cover fails to load |\n| `className` | `string` | — | Extra CSS classes on the card root; other props are forwarded to it |\n\n## Example\n\n```jsx\n<ProductCard\n title=\"Trail Runner 2\"\n eyebrow=\"Acme\"\n meta=\"Size 9 · Ocean blue\"\n images={['/shoes/tr2-front.jpg', '/shoes/tr2-side.jpg']}\n hover=\"second-image\"\n badges={[<Badge key=\"new\" variant=\"primary\" size=\"sm\">New</Badge>]}\n price={2999}\n compareAtPrice={4049}\n as={Link}\n to=\"/p/trail-runner-2\"\n actions={<Button size=\"sm\">Add to cart</Button>}\n/>\n\n<ProductCard\n layout=\"row\"\n title=\"Road Pacer\"\n image={{ src: '/shoes/rp.jpg', alt: 'Road Pacer, side view' }}\n price={3499}\n href=\"/p/road-pacer\"\n selectable\n selected={compare.includes('rp')}\n onSelectedChange={(on) => toggleCompare('rp', on)}\n/>\n```\n\n[← Component overview](README.md)\n",
6758
7228
  "ProgressBar": "# ProgressBar\n\nProgress bar showing a value between 0 and max. Uses the brand's `primary` tokens by default (`bg-primary-600` fill, `bg-primary-100` track), so it follows `--dt-*` theming. Pick another token tone with `variant`. Also supports an optional label, value text inside the bar, striped and animated styles.\n\n## Import\n\n```jsx\nimport { ProgressBar } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `value` | `number` | `0` | Current value. |\n| `max` | `number` | `100` | Maximum value. |\n| `size` | `\"xs\"` \\| `\"sm\"` \\| `\"md\"` \\| `\"lg\"` \\| `number` | `\"md\"` | Height: xs/sm/md/lg or px. |\n| `showValue` | `boolean` | `false` | Show percentage text inside the fill. |\n| `rounded` | `boolean` | `true` | Rounded corners. |\n| `variant` | `\"primary\"` \\| `\"secondary\"` \\| `\"success\"` \\| `\"warning\"` \\| `\"destructive\"` | `\"primary\"` | Token tone: fill `bg-<tone>-600`, track `bg-<tone>-100` (`dark:bg-<tone>-900/40`), value text `text-white`. `destructive` uses the `error` palette. Replaces the HEX `color` / `bgColor` / `valueColor` props removed in 2.0.0 ([migration](MIGRATION-2.0.md#progressbar-hex-colour-props)). |\n| `striped` | `boolean` | `false` | Striped fill pattern. |\n| `animated` | `boolean` | `false` | Animate stripe movement (`animate-progress-stripes`, a Tailwind keyframe, so no `<style>` tag is injected). Stops under `prefers-reduced-motion`. Value changes animate the fill width over 300 ms, with no transition under reduced motion. |\n| `label` | `string` | `\"\"` | Label above the bar. |\n| `ariaLabel` | `string` | `\"Progress\"` | Accessibility label. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n| `style` | `object` | `{}` | Wrapper inline style. |\n\n## Example\n\n```jsx\n<ProgressBar value={60} max={100} />\n<ProgressBar value={75} showValue label=\"Upload\" />\n<ProgressBar value={40} striped animated size=\"lg\" />\n<ProgressBar value={90} variant=\"success\" showValue label=\"Storage\" />\n```\n\n[← Component overview](README.md)\n",
6759
7229
  "PullToRefresh": "# PullToRefresh\n\nTouch pull-to-refresh around a scroll container. Pull down while the list is scrolled to the top: a spinner follows the finger (rubber-band resistance, rotation and opacity tracking the pull). Release past `threshold` and `onRefresh()` runs; the spinner keeps spinning until its promise settles, then retracts. Normal scrolling is never intercepted.\n\n## Import\n\n```jsx\nimport { PullToRefresh } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `onRefresh` | `() => Promise \\| void` | — | Called on release past `threshold`. The indicator stays in the refreshing state until the returned promise settles (resolve or reject); a synchronous return settles on the next tick. Handle your own errors inside it |\n| `threshold` | `number` | `64` | Pull distance in px (after resistance) that arms a refresh; the refreshing indicator rests at this offset |\n| `maxPull` | `number` | `threshold * 2` | Ceiling of the resistance-scaled pull in px |\n| `disabled` | `boolean` | `false` | Ignores pulls (a refresh already in flight still finishes) |\n| `refreshingLabel` | `string` | `\"Refreshing…\"` | Text of the polite live region (`role=\"status\"`, `aria-live=\"polite\"`) while refreshing |\n| `scrollRef` | `RefObject<HTMLElement>` | — | Your own scroll container inside `children`. Omit it and PullToRefresh renders the scroller (`h-full overflow-y-auto overscroll-y-contain`) |\n| `contentClassName` | `string` | — | Extra classes for the inner content element (the scroller when `scrollRef` is omitted) |\n| `children` | `ReactNode` | — | The list / feed |\n| `className` | `string` | — | Root classes (merged with `cn`). Give the root a height (`h-96`, `flex-1`, …) so the inner scroller can scroll |\n| `...rest` | `HTMLAttributes` | — | Forwarded to the root `div`; `ref` points at the root |\n\n## Behavior\n\n- **Gesture:** touch only (desktop has no pull-to-refresh; give mouse and keyboard users a refresh button too). A pull starts only while the scroller's `scrollTop` is `0` and the finger moves down; a sideways swipe, an upward drag, or a scrolled list is left to the browser. Scroll back to the top mid-gesture and you can keep pulling; scroll the content mid-pull and the gesture is handed back.\n- **Listeners:** `touchstart`, `touchend` and `touchcancel` are passive. `touchmove` is non-passive, but calls `preventDefault()` only while it is actively pulling at the top, so ordinary scrolling is never blocked. A cancelled touch retracts without refreshing.\n- **Page overscroll:** the library's global `overscroll-behavior-y: none` on `body` keeps the browser's own pull-to-refresh and bounce from fighting this one; the built-in scroller also uses `overscroll-y-contain`.\n- **States:** the root carries `data-state=\"idle\" | \"pulling\" | \"refreshing\"`; the content gets `aria-busy=\"true\"` while refreshing. The spinner turns the primary colour once the pull passes `threshold`.\n- **Reduced motion:** under `prefers-reduced-motion` the indicator is static: no rotation while pulling, no spin while refreshing, and the retract is instant (no transition). The content still follows the finger (direct manipulation).\n- **Dark mode:** the indicator uses the `bg-surface` / `border-line` / `text-muted` tokens.\n\n## Examples\n\n```jsx\n<PullToRefresh onRefresh={() => refetchOrders()} className=\"h-[calc(100dvh-4rem)]\">\n <ul>{orders.map((o) => <OrderRow key={o.id} order={o} />)}</ul>\n</PullToRefresh>\n```\n\n```jsx\n// Your own scroll container\nconst listRef = useRef(null);\n\n<PullToRefresh scrollRef={listRef} onRefresh={refetch} refreshingLabel=\"Loading new orders…\" className=\"h-96\">\n <ul ref={listRef} className=\"h-full overflow-y-auto\">{rows}</ul>\n</PullToRefresh>\n```\n\n[← Component overview](README.md)\n",
7230
+ "QuantityStepper": "# QuantityStepper\n\nA **− / value / +** control for cart lines and buy boxes, where `Input type=\"number\"` is awkward on phones. The value is a `role=\"spinbutton\"` with `aria-valuenow` / `aria-valuemin` / `aria-valuemax`; the decrement and increment buttons step it and are disabled at the bounds. Controlled or uncontrolled.\n\n## Import\n\n```jsx\nimport { QuantityStepper } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `value` | `number` | — | Controlled value. Values outside `min` / `max` are clamped for display. |\n| `defaultValue` | `number` | `min` | Uncontrolled initial value. Falls back to `min`, or `0` when `min` is not finite. |\n| `onChange` | `(value) => void` | — | Receives the next number (not a DOM event), already clamped to `min` / `max` and rounded to the precision of `step`. Not called when the value would not change. |\n| `min` | `number` | `0` | Lowest value. The decrement button is disabled at `min`. |\n| `max` | `number` | `Infinity` | Highest value, e.g. the stock count. The increment button is disabled at `max`. Without a finite `max`, `aria-valuemax` is omitted and `End` does nothing. |\n| `step` | `number` | `1` | Amount each button press or arrow key moves the value. Decimal steps (`0.5`) do not drift. Non-positive values fall back to `1`. |\n| `size` | `string` | `\"md\"` | `sm`, `md` or `lg` (32 / 40 / 48px tall). The buttons grow to 44 × 44px on coarse pointers (touch screens). |\n| `disabled` | `boolean` | `false` | Dims the control, disables both buttons, removes the spinbutton from the tab order and sets `aria-disabled`. |\n| `loading` | `boolean` | `false` | For a pending server update: shows a spinner in place of the value, disables the buttons, ignores keys and sets `aria-busy`. The spinbutton keeps focus. The spinner stops under `prefers-reduced-motion`. |\n| `label` | `ReactNode` | — | Visible label above the control; it names the spinbutton through `aria-labelledby`. |\n| `hideLabel` | `boolean` | `false` | Keeps `label` for screen readers only (`sr-only`), for tight cart rows. |\n| `formatValue` | `(value) => ReactNode` | — | Renders the displayed value, e.g. `` v => `${v} kg` ``. When it returns a string or number it is also used as `aria-valuetext`. |\n| `decrementLabel` | `string` | `\"Decrease quantity\"` | Accessible name of the − button. |\n| `incrementLabel` | `string` | `\"Increase quantity\"` | Accessible name of the + button. |\n| `name` | `string` | — | Submits the value with a form through a hidden input. |\n| `aria-label` | `string` | — | Accessible name of the spinbutton when there is no `label`. |\n| `className` | `string` | — | Merged onto the root with `cn`, so your classes win on conflict. |\n\nOther props (`id`, `data-*`, event handlers, …) go to the root `<div>`, which also receives the forwarded `ref`.\n\n## Behavior\n\n- **Keyboard:** the spinbutton is the single tab stop (the − / + buttons are pointer targets with `tabIndex={-1}`, as in the WAI-ARIA spinbutton pattern). `ArrowUp` / `ArrowDown` step by `step`, `PageUp` / `PageDown` by ten steps, `Home` / `End` jump to `min` / `max`. Every change is clamped to the bounds.\n- **Bounds:** the − button is disabled at `min` and the + button at `max`, so a cart line with `min={1}` cannot reach zero by accident; use a separate remove action for that.\n- **Styling:** a bordered `bg-surface` group with `border-line` dividers, `text-foreground` value in tabular figures, and `hover:bg-line` buttons that turn `text-muted` when disabled. Buttons show a `focus-visible` ring and shrink slightly while pressed (off under reduced motion). All tokens follow dark mode.\n\n## Example\n\n```jsx\nconst [qty, setQty] = useState(1);\n\n<QuantityStepper label=\"Quantity\" value={qty} min={1} max={stock} onChange={setQty} />\n\n<QuantityStepper\n label=\"Quantity, Ceramic mug\"\n hideLabel\n size=\"sm\"\n value={line.qty}\n min={1}\n max={line.stock}\n loading={line.updating}\n onChange={(next) => updateLine(line.id, next)}\n/>\n\n<QuantityStepper label=\"Weight\" defaultValue={0.5} min={0.5} max={5} step={0.5} formatValue={(v) => `${v} kg`} />\n```\n\n[← Component overview](README.md)\n",
6760
7231
  "Radio": "# Radio\n\nSingle radio button; use multiple with the same `name` for a group. Controlled via `checked` or uncontrolled via `defaultChecked`.\n\n## Import\n\n```jsx\nimport { Radio } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `id` | `string` | auto | Input id. |\n| `name` | `string` | — | Group name (required for grouping). |\n| `value` | `any` | — | Value when selected. |\n| `checked` | `boolean` | — | Controlled checked. |\n| `defaultChecked` | `boolean` | — | Uncontrolled initial. |\n| `onChange` | `(e: Event) => void` | — | Change handler. |\n| `disabled` | `boolean` | `false` | Disable. |\n| `required` | `boolean` | `false` | Required. |\n| `label` | `string` | — | Label text. |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Size. |\n| `labelPosition` | `\"left\"` \\| `\"right\"` | `\"right\"` | Label side. |\n| `radioColor` | `string` | `\"rgb(var(--dt-primary-600))\"` | Accent colour for the checked dot, as any CSS colour. Defaults to the brand's primary token, so it follows theming; prefer leaving it unset. |\n| `className` | `string` | `\"\"` | Label wrapper classes. |\n| `ariaLabel` | `string` | — | Accessibility label. |\n\n## Behavior\n\n- **Uncontrolled groups** (`defaultChecked`, no `checked`): selecting one radio\n re-syncs every same-`name` Radio in the same form, so only one dot is ever\n shown as selected. Give the group a shared `name`.\n- **Focus:** the native input is visually hidden; keyboard focus draws a\n `focus-visible` ring on the visual circle.\n- The visual circle exposes `data-state=\"checked\" | \"unchecked\"` for styling.\n- **Look:** token surfaces (`bg-surface`, grey ring with a dark counterpart); the\n ring takes the accent colour when checked and the dot springs in (off under\n `prefers-reduced-motion`). The whole row is the hit area and is at least 44px\n tall on coarse pointers.\n\n## Example\n\n```jsx\nconst [choice, setChoice] = useState('a');\n<>\n <Radio name=\"choice\" value=\"a\" checked={choice === 'a'} onChange={() => setChoice('a')} label=\"Option A\" />\n <Radio name=\"choice\" value=\"b\" checked={choice === 'b'} onChange={() => setChoice('b')} label=\"Option B\" />\n</>\n```\n\n[← Component overview](README.md)\n",
6761
7232
  "Rate": "# Rate\n\nStar (or custom icon) rating input/display. Controlled via `value` or uncontrolled via `defaultValue`. Supports hover preview and optional read-only. Stars use token classes (`text-warning-400` active, `text-gray-200` inactive); the inline `activeColor` / `inactiveColor` overrides were removed in 2.0.0 ([migration](MIGRATION-2.0.md#rate-activecolor-and-inactivecolor)).\n\n## Import\n\n```jsx\nimport { Rate } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `count` | `number` | `5` | Number of stars. |\n| `value` | `number` | — | Controlled value (0 to count). |\n| `defaultValue` | `number` | `0` | Uncontrolled initial value. |\n| `readOnly` | `boolean` | `false` | Read-only (no onChange). |\n| `icon` | `ReactNode` | `<StarOutline />` | Icon for inactive star. |\n| `toggledIcon` | `ReactNode` | `<StarFilled />` | Icon for active star. |\n| `size` | `number` | `20` | Icon size in px. |\n| `onChange` | `(value: number) => void` | — | Called when value changes. |\n| `onClick` | `(value: number) => void` | — | Called on star click. |\n| `text` | `ReactNode` | — | Optional text (e.g. label) next to stars. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n| `id` | `string` | — | Wrapper id. |\n| `name` | `string` | — | Form field name. |\n\n## Accessibility & interaction\n\n- Rendered as a `radiogroup` of star `radio`s. Only the star equal to the\n current value has `aria-checked=\"true\"`.\n- **Roving tabindex:** the group is one tab stop — the selected star, or the\n first star when the value is `0`.\n- **Keyboard:** `ArrowRight` / `ArrowUp` and `ArrowLeft` / `ArrowDown` move\n focus to the next / previous star **and** select it (calling `onChange`,\n clamped to 1…`count`); `Home` / `End` jump to the first / last star;\n `Enter` / `Space` commit the focused star (calling `onChange` and `onClick`).\n- **Hover preview** is shown for mouse/pen pointers only and clears on pointer\n leave; touch taps never leave a stuck preview.\n- **Touch drag:** on touch screens, slide a finger across the stars — the\n highlighted value follows the finger (pointer capture keeps tracking it\n even off the stars; sliding past either end clamps to 1 or `count`) and is\n committed once, on release (`onChange` and `onClick` fire then, and focus\n moves to that star). A tap without sliding still selects via click.\n `pointercancel` drops the preview. The group uses `touch-action: pan-y`,\n so vertical page scrolling still works over it.\n- Each star grows to a 44×44 px hit area on coarse (touch) pointers.\n- `readOnly` removes the stars from the tab order.\n\n## Example\n\n```jsx\nconst [rating, setRating] = useState(0);\n<Rate value={rating} onChange={setRating} count={5} />\n<Rate defaultValue={3} readOnly />\n```\n\n[← Component overview](README.md)\n",
6762
7233
  "RichTextEditor": "# RichTextEditor\n\nWYSIWYG rich-text field built on Tiptap (StarterKit): bold, italic, strike, inline code, headings, lists, quotes, links and undo/redo, with a keyboard-accessible toolbar and the same `label` / `error` / `helperText` wiring as `Input`. Lives on the `/rich-text` entry point so Tiptap is an optional peer dependency. The editor box and the toolbar link input use the semantic `bg-surface` / `border-line` / `text-foreground` tokens, so they follow dark mode.\n\n## Install\n\n```bash\nnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit\n# TypeScript + React 18: keep the React type packages on your React major\nnpm install -D @types/react@18 @types/react-dom@18\n```\n\n## Import\n\n```jsx\nimport { RichTextEditor } from '@dreamtree-org/twreact-ui/rich-text';\n```\n\nNever import it from the root `@dreamtree-org/twreact-ui` — it is not exported there.\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `value` | `string` or `JSONContent` | — | Controlled content: an HTML string (or Tiptap JSON). An external change replaces the content without calling `onChange`. |\n| `defaultValue` | `string` or `JSONContent` | — | Uncontrolled initial content. |\n| `onChange` | `(value, editor) => void` | — | Called on every edit with HTML (or Tiptap JSON when `outputFormat=\"json\"`) and the Tiptap editor. |\n| `outputFormat` | `\"html\"` or `\"json\"` | `\"html\"` | What `onChange` receives. |\n| `label` | `ReactNode` | — | Visible label; names the editor through `aria-labelledby`. Clicking it focuses the editor. |\n| `error` | `ReactNode` | — | Error message: red border, `aria-invalid`, icon + text linked with `aria-describedby`. |\n| `helperText` | `ReactNode` | — | Hint below the field, linked with `aria-describedby` (shown together with `error`). |\n| `placeholder` | `string` | — | Shown while the document is empty; also `aria-placeholder`, and the accessible name when there is no `label` or `aria-label`. |\n| `required` | `boolean` | `false` | Visual `*` and `aria-required`. |\n| `disabled` | `boolean` | `false` | Not editable, dimmed, toolbar buttons disabled, `aria-disabled`. |\n| `readOnly` | `boolean` | `false` | Shows the formatted content, not editable, toolbar hidden, `aria-readonly`. |\n| `toolbar` | `RichTextToolbarItem[]` or `false` | `bold, italic, strike, code, heading2, heading3, bulletList, orderedList, blockquote, link, undo, redo` | Toolbar buttons in order (also available: `heading1`, `codeBlock`), or `false` for none. |\n| `toolbarLabel` | `string` | `\"Formatting\"` | Accessible name of the `role=\"toolbar\"`. |\n| `stickyToolbar` | `boolean`, `number` or `string` | `false` | Keep the toolbar pinned (`sticky`, `z-sticky`) to the top of the scrolling page while long content scrolls. `true` sticks at the top; a number (px) or CSS length sets the offset, e.g. `\"3.5rem\"` below `AppShell`'s sticky top bar. The frame switches from `overflow-hidden` to `overflow-clip` so the toolbar can stick. |\n| `minHeight` | `number` or `string` | `120` | Minimum height of the editable area (px or CSS length). |\n| `maxHeight` | `number` or `string` | — | Height after which the editable area scrolls. |\n| `autoFocus` | `boolean` | `false` | Focus the editor on mount. |\n| `onFocus` | `(event) => void` | — | Editor focus. |\n| `onBlur` | `(event) => void` | — | Editor blur. |\n| `extensions` | `AnyExtension[]` | — | Extra Tiptap extensions, appended after StarterKit. |\n| `editorClassName` | `string` | — | Extra classes for the editable (ProseMirror) element. |\n| `id` | `string` | — | Id of the editable element (auto-generated otherwise). |\n| `className` | `string` | — | Extra classes for the root `<div>` (merged with `cn`). |\n| `ref` | `Ref<RichTextEditorHandle>` | — | Imperative handle: `editor`, `element`, `focus()`, `clear()`, `getHTML()`, `getJSON()`, `setContent(content)`. |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>`. |\n\nWorks controlled (`value` + `onChange`) or uncontrolled (`defaultValue`, read with the ref). Controlled is recommended for forms.\n\nThe editor renders on the client after mount (`immediatelyRender: false`), so it is safe in Next.js / SSR; the label, toolbar and placeholder render on the server.\n\n### Security: sanitize the HTML on the server\n\n`onChange` returns whatever HTML the editor holds, and a user can send any HTML to your API without using the editor at all. **Always sanitize rich-text HTML on the server before you store or render it** (for example with DOMPurify / `sanitize-html` and an allow-list of the tags and attributes above). This library ships no sanitizer, and its components never render raw HTML. The link form only accepts `http(s):`, `mailto:`, `tel:`, relative and `#` URLs, but that is a UX guard, not a security boundary.\n\n## Examples\n\n### Controlled\n\n```jsx\nconst [body, setBody] = useState('<p>Hello</p>');\n\n<RichTextEditor\n label=\"Description\"\n placeholder=\"Describe the task…\"\n helperText=\"Markdown shortcuts work: **bold**, # heading, - list.\"\n value={body}\n onChange={setBody}\n maxHeight={320}\n/>\n```\n\n### Compact toolbar with validation\n\n```jsx\n<RichTextEditor label=\"Comment\" required toolbar={['bold', 'italic', 'link']} minHeight={80} error={errors.comment} />\n```\n\n### Uncontrolled + ref\n\n```jsx\nconst editorRef = useRef(null);\n\n<RichTextEditor label=\"Notes\" defaultValue=\"<p>Draft</p>\" ref={editorRef} />\n<Button onClick={() => save(editorRef.current.getHTML())}>Save</Button>\n```\n\n### Sticky toolbar for long documents\n\n```jsx\n// Inside AppShell: stick below its 56px top bar.\n<RichTextEditor label=\"Article\" stickyToolbar=\"3.5rem\" value={body} onChange={setBody} />\n```\n\n### JSON output\n\n```jsx\n<RichTextEditor label=\"Body\" outputFormat=\"json\" onChange={(doc) => setDoc(doc)} />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Field | The editable element is `role=\"textbox\"` with `aria-multiline=\"true\"`, named by `label` (`aria-labelledby`), else `aria-label`, else `placeholder` |\n| Description | `error` and `helperText` ids (plus any `aria-describedby` you pass) are joined into `aria-describedby`; `aria-invalid`, `aria-required`, `aria-readonly`, `aria-disabled` reflect the props |\n| Toolbar | `role=\"toolbar\"` with `aria-label` and `aria-controls`; one tab stop with roving focus (Left / Right arrows, Home, End); every control is a real `<button>` with `aria-label`, `title` and `aria-keyshortcuts` |\n| Touch | Toolbar buttons are 32px on desktop and grow to 44px on coarse pointers. They cancel `pointerdown` (and `mousedown`), so tapping one keeps the editor selection on iOS and the format applies to the selected text |\n| Toggle state | Mark / block buttons expose `aria-pressed`; Undo / Redo expose `aria-disabled` when nothing can be undone / redone |\n| Keyboard shortcuts | StarterKit defaults: Mod+B, Mod+I, Mod+Shift+S, Mod+E, Mod+Alt+1..3, Mod+Shift+7 / 8, Mod+Shift+B, Mod+Z, Mod+Shift+Z, plus Markdown input rules |\n| Links | The link button opens an inline URL field (Enter applies, Escape cancels and returns focus to the editor); invalid schemes show a `role=\"alert\"` message |\n| Error | Paired with an icon and text, not colour alone |\n| Focus ring | `focus-within:ring-2 ring-primary-500` on the editing area (error ring when invalid); toolbar buttons use `focus-visible:ring-2` |\n| Dark mode | Surface, border and content (headings, quotes, code, links) use `dark:` variants; the toolbar uses the role tokens (`bg-background`, `border-line`, `text-muted`, `text-foreground`) |\n\n[← Component overview](README.md)\n",
@@ -6773,7 +7244,7 @@
6773
7244
  "SortableGroup": "# SortableGroup\n\nConnects several [`Sortable`](Sortable.md) lists so items can **move between lists** — by pointer drag, or by keyboard (pick an item up, then use the cross-axis arrow keys). A `canDrop` predicate is asked *before* a target is shown as droppable, so a move the domain would refuse never looks like it succeeded. Built for boards (drag a card between columns) and backlogs (drag a task into a sprint).\n\n## Import\n\n```jsx\nimport { Sortable, SortableGroup } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `onMove` | `function` | — | `({ item, from: { listId, index }, to: { listId, index } }) => void`, called once when an item is dropped in another list (and `canDrop` allowed it). Remove the item from `from.listId` and insert it into `to.listId` at `to.index`. |\n| `canDrop` | `function` | — | `(item, target: { listId, index }, from: { listId, index }) => boolean`. Asked whenever the carried item targets another list. Return `true` to allow; a refused target is marked and announced as not droppable and dropping there does nothing. Omit to allow every list. Not consulted for reordering inside one list. |\n| `children` | `ReactNode` | — | Your layout, containing the `<Sortable id=\"…\">` lists (at any depth). |\n| `className` | `string` | — | Classes for the wrapper `<div>` (merged with `cn`), e.g. `\"grid grid-cols-3 gap-4\"`. |\n| `...rest` | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the wrapper `<div>`; `ref` points at it. |\n\nEach `Sortable` inside the group needs an **`id`** — it is the `listId` in `onMove` / `canDrop` (and is still set as the list's DOM `id`). A `Sortable` without an `id` stays a standalone list. Reordering inside a list still calls that list's own `onReorder`; only cross-list moves call `onMove`.\n\nThe group is controlled, like `Sortable`: nothing changes until you update your state in `onMove`.\n\n## Examples\n\n### Board\n\n```jsx\nconst [board, setBoard] = useState({ todo: [...], review: [...], done: [...] });\n\nfunction moveCard(board, { from, to }) {\n const next = { ...board, [from.listId]: board[from.listId].slice() };\n const [card] = next[from.listId].splice(from.index, 1);\n if (to.listId !== from.listId) next[to.listId] = board[to.listId].slice();\n next[to.listId].splice(to.index, 0, card);\n return next;\n}\n\n<SortableGroup\n className=\"grid grid-cols-3 gap-4\"\n onMove={(move) => setBoard((b) => moveCard(b, move))}\n // Server-computed transitions: \"done\" only accepts cards from \"review\".\n canDrop={(card, target, from) => target.listId !== 'done' || from.listId === 'review'}\n>\n {[['todo', 'To do'], ['review', 'Review'], ['done', 'Done']].map(([id, name]) => (\n <section key={id}>\n <h3>{name}</h3>\n <Sortable\n id={id}\n aria-label={name}\n className=\"min-h-24\"\n items={board[id]}\n onReorder={(next) => setBoard((b) => ({ ...b, [id]: next }))}\n getItemLabel={(t) => t.title}\n renderItem={(t) => <div className=\"rounded-md border border-line bg-surface p-3\">{t.title}</div>}\n />\n </section>\n ))}\n</SortableGroup>\n```\n\nGive the lists a minimum size (`min-h-24` above) so an empty list still has an area to drop on.\n\n### Confirm before committing\n\n`onMove` fires once, on drop, so a transition that needs extra input can open a prompt first and only update state on confirm — the card never jumps to the new column and back.\n\n```jsx\n<SortableGroup onMove={(move) => (needsComment(move) ? openCommentPrompt(move) : apply(move))} />\n```\n\n## Pointer\n\n- Drag an item over another list of the group: that list shows a dashed placeholder where the item would land (`data-drop-target=\"allowed\"` on the list, `data-sortable-placeholder=\"allowed\"` on the placeholder). The source list keeps its order and the dragged item is dimmed.\n- If `canDrop` refuses the target, the list and placeholder are marked `refused` (the placeholder is drawn in the error colour with `cursor-not-allowed`) and the dragged item gets `data-leaving=\"refused\"`. Releasing there drops nothing: the item stays where it was and neither `onMove` nor `onReorder` is called.\n- Move back over the source list to reorder within it as usual. Touch long-press, handles and auto-scroll work as in `Sortable`.\n\n## Keyboard\n\n| Key (an item picked up with `Space` / `Enter`) | Action |\n|------------------------------------------------|--------|\n| `ArrowLeft` / `ArrowRight` (vertical lists) or `ArrowUp` / `ArrowDown` (horizontal lists) | Carry the item to the previous / next list of the group, in document order. Back in its own list, it returns to its original position |\n| `ArrowUp` / `ArrowDown` (vertical) or `ArrowLeft` / `ArrowRight` (horizontal) | Move it within the current list, including a target list |\n| `Space` / `Enter` | Drop. On a refused target nothing is dropped and the item stays picked up, so you can carry it on |\n| `Escape` | Cancel; nothing moves |\n\nEach step is announced through the source list's live region: \"Alpha moved to Doing, position 2 of 3.\", \"Alpha cannot be dropped in Done.\", \"Alpha dropped in Doing at position 2 of 3.\". List names come from the list's `aria-label` (falling back to its `id`). Grouped lists append a sentence about the cross-list keys to their visually hidden instructions. After a keyboard drop, focus follows the item into its new list. All texts can be changed with the `Sortable` `messages` prop (`groupInstructions`, `movedToList`, `droppedInList`, `refused`).\n\n[← Component overview](README.md)\n",
6774
7245
  "Sparkline": "# Sparkline\n\nA word-sized trend line (or area) with no axes, grid or legend — for stat cards and table cells. Still accessible: it is a named `role=\"img\"` with a hidden data table. Wraps `recharts` (optional peer, `/charts` entry point).\n\n## Install\n\nCharts live on a separate entry point so apps that don't use them never install or bundle `recharts`:\n\n```bash\nnpm install recharts\n# React 18 apps: also pin react-is to your React major (recharts resolves it as a peer)\nnpm install react-is@18\n```\n\n## Import\n\n```jsx\nimport { Sparkline } from '@dreamtree-org/twreact-ui/charts';\n```\n\nNever import charts from the root `@dreamtree-org/twreact-ui` — they are not exported there.\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `object[]` | — | **Required.** Rows to plot. An empty array renders `emptyState`. |\n| `category` | `string` | `\"value\"` | Key of the plotted value in each row. |\n| `index` | `string` | — | Optional key of a label per row (tooltip label and hidden-table row header). |\n| `color` | `ChartColor` | `\"primary\"` | Line colour token (`\"success\"`, `\"error-400\"`, …). |\n| `type` | `\"line\"` or `\"area\"` | `\"line\"` | Draw a line or a filled area. |\n| `valueFormatter` | `(value: number) => string` | `toLocaleString` | Formats values in the tooltip, value axis and hidden data table. |\n| `height` | `number` or `string` | `40` | Chart height (px number or any CSS length). The width is always 100% of the parent (responsive). |\n| `title` | `string` | — | Accessible name: `aria-label` of the `role=\"img\"` region and caption of the hidden table. Falls back to `aria-label`, then `\"Sparkline\"`. |\n| `description` | `string` | — | Longer text alternative (the takeaway), linked with `aria-describedby`. |\n| `emptyState` | `ReactNode` | `\"No data\"` | Shown (as `role=\"status\"`) instead of the chart when `data` is empty. |\n| `accessibleTable` | `boolean` | `true` | Repeat the numbers in a visually-hidden `<table>` for screen-reader users. |\n| `animate` | `boolean` | `false` | Animate on mount / data change. Always off when the user prefers reduced motion. |\n| `showTooltip` | `boolean` | `false` | Hover tooltip (themed, dark-mode aware). |\n| `className` | `string` | — | Extra classes for the root `<div>` (merged with `cn`). |\n| `ref` | `Ref<HTMLDivElement>` | — | Forwarded to the root `<div>`. |\n| ...rest | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to the root `<div>` (`id`, `data-*`, `aria-*`, handlers). |\n\n## Examples\n\n### In a stat card\n\n```jsx\nconst weekly = [{ week: 'W1', signups: 12 }, { week: 'W2', signups: 18 }, { week: 'W3', signups: 15 }, { week: 'W4', signups: 27 }];\n\n<Sparkline data={weekly} index=\"week\" category=\"signups\" color=\"success\" title=\"Signups, last 4 weeks\" />\n```\n\n### Area with tooltip\n\n```jsx\n<Sparkline data={weekly} index=\"week\" category=\"signups\" type=\"area\" showTooltip height={56} />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Name | The SVG sits in a `role=\"img\"` region named by `title` (falls back to `aria-label`, then a generic name such as \"Sparkline\") |\n| Description | `description` is rendered visually hidden and linked with `aria-describedby` |\n| Numbers | `accessibleTable` (default on) repeats the data in a visually-hidden `<table>` with a caption, column headers and row headers, formatted with `valueFormatter`; recharts' SVG itself has no accessible content |\n| Legend | Real DOM text, not SVG |\n| Motion | Animation is disabled when `prefers-reduced-motion: reduce` is set (and with `animate={false}`) |\n| Colour | Series colours come from the design tokens; pair the chart with the table / description so colour is never the only signal |\n| Dark mode | Axis text (`--dt-muted`), grid (`--dt-line`) and series tokens are re-read when the `dark` class or `data-theme` on `<html>` changes; tooltip and legend use `dark:` variants |\n| Empty | An empty `data` array renders `emptyState` in a `role=\"status\"` box with the same accessible name |\n\n## Server rendering\n\nSafe to render on the server (a Next.js `\"use client\"` page). The first client render matches the server's HTML: token colours stay `rgb(var(--dt-*))` and reduced motion reads as off until hydration finishes, then the live values apply. SVG ids come from `React.useId()` (`dt-chart-…`), not a module counter.\n\n[← Component overview](README.md)\n",
6775
7246
  "SpeechToText": "# SpeechToText\n\nVoice-to-text using the Web Speech API. Headless by default — render a custom\ncontrol via `renderButton`, or drive it imperatively through the ref. The\nbuilt-in fallback button is `className`-extensible and forwards arbitrary props.\n\n## Import\n\n```jsx\nimport { SpeechToText } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `lang` | `string` | `\"en-US\"` | Recognition language |\n| `continuous` | `boolean` | `true` | Keep listening until stopped (forced `false` on mobile) |\n| `interimResults` | `boolean` | `true` | Return interim results |\n| `onSpeechComplete` | `function` | — | Final transcript chunk callback `(text) => void` |\n| `onSpeaking` | `function` | — | Interim transcript callback `(text) => void` |\n| `onError` | `function` | — | Fatal error callback `(Error) => void`. Routine events (`no-speech`, `aborted`) are **not** surfaced |\n| `onStart` | `function` | — | Start callback |\n| `onStop` | `function` | — | Stop callback |\n| `renderButton` | `function` | — | Custom control render-prop (see signature below) |\n| `autoStart` | `boolean` | `false` | Start on mount (once, after support is detected) |\n| `disabled` | `boolean` | `false` | Disable |\n| `resetOnStart` | `boolean` | `false` | Clear the accumulated transcript when a new session starts |\n| `className` | `string` | — | Merged (via `cn`) onto the default button |\n| `...rest` | — | — | Forwarded to the default `<button>` |\n\nThe default button is keyboard-accessible (`aria-pressed`, `aria-label`) and\ntoken-driven. When `renderButton` is supplied, `className`/`...rest` are not\napplied (you own the rendered control).\n\n## `renderButton` signature\n\n```js\nrenderButton({ isListening, isHearing, isSupported, error, start, stop, toggle, disabled })\n```\n\n`isHearing` is `true` while the engine reports speech (between `speechstart` and `speechend`, or once results arrive); it resets when listening stops.\n\n## Listening feedback\n\nWhile listening, the default button shows a pulsing ring behind the mic icon and three small level bars after the label. The bars rest until speech is heard, then animate. There is no audio-level API without `getUserMedia` (which this component does not request), so the bars show *whether* speech is detected, not its volume. Under `prefers-reduced-motion` the pulse becomes a static ring and the bars stop animating. The button also has a `focus-visible` ring, press feedback, a 44 px minimum height on touch screens, and dark-mode colours; it exposes `data-listening` / `data-hearing` attributes for styling.\n\n## Ref methods\n\n`start()`, `stop()`, `toggle()`, `isListening()`, `isSupported()`,\n`getTranscript()`, `clearTranscript()`, `getError()`.\n\n## Example\n\n```jsx\nconst [transcript, setTranscript] = useState('');\n<SpeechToText\n onSpeechComplete={(chunk) => setTranscript((t) => `${t} ${chunk}`.trim())}\n onError={(e) => console.error(e)}\n/>\n<div>Transcript: {transcript}</div>\n```\n\n[← Component overview](README.md)\n",
6776
- "StatCard": "# StatCard\n\nPage-level block: a KPI tile with an icon, a label, a big tabular number and an optional change vs a previous period. Extracted from PracticePro `components/ui/StatCard.tsx`.\n\n## Import\n\n```jsx\nimport { StatCard } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `label` | `ReactNode` | — | What the number counts |\n| `value` | `ReactNode` | — | The figure |\n| `icon` | `ReactNode` | — | Lucide icon shown in a tinted chip |\n| `tone` | `primary` \\| `secondary` \\| `success` \\| `warning` \\| `error` \\| `info` | `\"primary\"` | Chip colour (light tint in light mode, a deep translucent tint with a lighter icon in dark mode) |\n| `change` | `{ value: number; period?: string; unit?: '%' \\| ''; goodWhenUp?: boolean }` | — | Change indicator; `goodWhenUp: false` makes a rise red. Drawn in `success-700` / `error-700` (at least 4.5:1 on white), `-400` in dark mode |\n| `loading` | `boolean` | `false` | Shows a skeleton instead of the value |\n| `className` | `string` | — | Extra CSS classes |\n\n## Example\n\n```jsx\n<StatCard label=\"Due this week\" value={18} tone=\"primary\" icon={<CalendarDays className=\"h-6 w-6\" />} />\n<StatCard label=\"Overdue\" value={3} tone=\"error\" change={{ value: 2, unit: '', period: 'vs last week', goodWhenUp: false }} />\n```\n\n[← Component overview](README.md)\n",
7247
+ "StatCard": "# StatCard\n\nPage-level block: a KPI tile with an icon, a label, a big tabular number and an optional change vs a previous period. Extracted from PracticePro `components/ui/StatCard.tsx`.\n\n## Import\n\n```jsx\nimport { StatCard } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `label` | `ReactNode` | — | What the number counts |\n| `value` | `ReactNode` | — | The figure |\n| `icon` | `ReactNode` | — | Lucide icon shown in a tinted chip |\n| `tone` | `primary` \\| `secondary` \\| `success` \\| `warning` \\| `error` \\| `info` | `\"primary\"` | Chip colour (light tint in light mode, a deep translucent tint with a lighter icon in dark mode) |\n| `change` | `{ value: number; period?: string; unit?: '%' \\| ''; goodWhenUp?: boolean }` | — | Change indicator; `goodWhenUp: false` makes a rise red. Drawn in `success-700` / `error-700` (at least 4.5:1 on white), `-400` in dark mode |\n| `loading` | `boolean` | `false` | Shows a skeleton instead of the value |\n| `tinted` | `boolean` | `false` | Draws the whole card in `tone`: `<tone>-50` fill, `<tone>-200` border, `<tone>-800` text (deep translucent tints in dark mode) |\n| `children` | `ReactNode` | — | Footer under the value with the card's spacing: a [`ProgressBar`](ProgressBar.md), a line of text or a `Button variant=\"link\"` |\n| `className` | `string` | — | Extra CSS classes |\n\n## Example\n\n```jsx\n<StatCard label=\"Due this week\" value={18} tone=\"primary\" icon={<CalendarDays className=\"h-6 w-6\" />} />\n<StatCard label=\"Overdue\" value={3} tone=\"error\" change={{ value: 2, unit: '', period: 'vs last week', goodWhenUp: false }} />\n\n<StatCard label=\"Occupancy\" value=\"92%\">\n <ProgressBar value={44} max={48} size=\"sm\" ariaLabel=\"Beds filled\" />\n <p className=\"text-muted\">44 of 48 beds filled · 4 free</p>\n</StatCard>\n\n<StatCard label=\"Overdue rent\" value=\"₹18,400\" tone=\"error\" tinted>\n <Button variant=\"link\" tone=\"error\" rightIcon=\"→\">See who owes</Button>\n</StatCard>\n```\n\n[← Component overview](README.md)\n",
6777
7248
  "StatusPill": "# StatusPill\n\nPage-level block: one status badge for every domain state. Map each state to a tone and a human label once; unknown states are humanized (`in_review` → \"In review\"), never shown raw. Extracted from billpro `components/StatusPill.jsx`.\n\n## Import\n\n```jsx\nimport { StatusPill } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `status` | `string` | — | Domain state to look up in `map` |\n| `map` | `Record<string, { label; tone; icon? }>` | — | State → label and tone (`neutral`, `info`, `success`, `warning`, `danger`, `muted`). `info` renders with the `info` palette (Badge `variant=\"info\"`), not the brand accent |\n| `tone` | `string` | `\"neutral\"` | Tone when not using `map`; also accepts semantic tones |\n| `icon` | `ReactNode` | — | Small leading icon (rendered in Badge's decorative `icon` slot) |\n| `children` | `ReactNode` | — | Label override |\n| `className` | `string` | — | Extra CSS classes |\n\nColours come from `Badge`, so every tone has dark-mode tones too.\n\n## Example\n\n```jsx\nconst OBLIGATION_STATUS = {\n overdue: { label: 'Overdue', tone: 'danger' },\n docs_missing: { label: 'Missing documents', tone: 'warning' },\n ready: { label: 'Ready for review', tone: 'info' },\n filed: { label: 'Filed', tone: 'success' },\n};\n\n<StatusPill status=\"docs_missing\" map={OBLIGATION_STATUS} />\n<StatusPill tone=\"success\">Filed</StatusPill>\n```\n\n[← Component overview](README.md)\n",
6778
7249
  "Stepper": "# Stepper\n\nHorizontal or vertical step indicator. Each step has circle (with check for completed), optional line, and label. Optional `onStepClick` for clickable steps.\n\n## Import\n\n```jsx\nimport { Stepper } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `steps` | `Array` | `[]` | Each step: `{ id?, title?, label?, description? }`. `title` is the step's text; `label` is accepted as an alias (when both are set, `title` wins). |\n| `currentStep` | `number` | `0` | Zero-based index of current step. |\n| `orientation` | `\"horizontal\"` \\| `\"vertical\"` \\| `\"auto\"` | `\"horizontal\"` | Layout. `\"auto\"` stacks vertically below the `sm` breakpoint (640 px) and lays out horizontally from `sm` up, using CSS only. |\n| `variant` | `\"default\"` \\| `\"dots\"` | `\"default\"` | `\"dots\"` renders compact progress dots: one labelled dot per step, the current step as a wider pill, and no titles or descriptions. Clicks work as with `onStepClick`. Unknown values render the default stepper. |\n| `onStepClick` | `(step, stepIndex) => void` | — | Called when a step is clicked (e.g. for completed/current). |\n| `className` | `string` | — | Nav wrapper CSS classes. |\n| ...rest | — | — | Passed to `<nav>`. The nav defaults to `aria-label=\"Progress\"`; a caller's `aria-label` replaces it. |\n\n## Step status\n\n- **completed**: `stepIndex < currentStep` (check icon, primary color).\n- **current**: `stepIndex === currentStep`.\n- **upcoming**: `stepIndex > currentStep` (gray circle; title and description use the `text-muted` role, 7.5:1 on white).\n\nConnectors flex to fill the space between circles (horizontal) or between steps (vertical), so any number of steps fits its container without overflowing. The connector after a completed step is primary. Step buttons show a `focus-visible` ring, have dark-mode colours, and grow to a 44 px touch target on coarse pointers when they are clickable.\n\nEach step button (and each dot) is named `Step <n>: <title or label>`, with ` (completed)` for completed steps (their circle shows only a check icon).\n\n## Example\n\n```jsx\n<Stepper\n steps={[\n { id: '1', label: 'Details' },\n { id: '2', label: 'Payment' },\n { id: '3', label: 'Confirm' },\n ]}\n currentStep={1}\n orientation=\"horizontal\"\n onStepClick={(step, idx) => setStep(idx)}\n/>\n```\n\n[← Component overview](README.md)\n",
6779
7250
  "SwipeableListItem": "# SwipeableListItem\n\nA list row with swipe actions, the iOS Mail / Gmail pattern. Swipe right to reveal the **leading** actions, left to reveal the **trailing** ones; release to snap open or closed, or swipe all the way to commit the first action on that side. Past the actions the row rubber-bands. Every action is also reachable without a gesture through a **More actions** button that opens them as a menu (an action sheet on phones).\n\n## Import\n\n```jsx\nimport { SwipeableListItem } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `leadingActions` | `SwipeAction[]` | `[]` | Revealed by swiping right. The first one sits at the left edge and is the full-swipe action |\n| `trailingActions` | `SwipeAction[]` | `[]` | Revealed by swiping left. The first one sits at the right edge and is the full-swipe action |\n| `fullSwipe` | `boolean` \\| `{ leading?, trailing? }` | `true` | A swipe past `fullSwipeThreshold` commits the first action of that side. Pass an object to allow it on one side only |\n| `fullSwipeThreshold` | `number` | `0.6` | Fraction of the row width a full swipe must travel (and at least 24px past the revealed actions) |\n| `actionWidth` | `number` | `80` | Width in px of each revealed action |\n| `open` | `'leading'` \\| `'trailing'` \\| `null` | — | Controlled open side |\n| `defaultOpen` | `'leading'` \\| `'trailing'` \\| `null` | `null` | Uncontrolled initial open side |\n| `onOpenChange` | `(side) => void` | — | Called with `'leading'`, `'trailing'` or `null` when the row opens or closes |\n| `disabled` | `boolean` | `false` | Turns off the gesture and the More actions button |\n| `moreActions` | `'focus'` \\| `'always'` \\| `false` | `'focus'` | The gesture-free button. `focus`: shown on keyboard focus and mouse hover. `always`: always visible. `false`: removed (only when you provide another way to reach the actions) |\n| `moreActionsLabel` | `string` | `\"More actions\"` | Accessible name of the button and its menu |\n| `menuPresentation` | `'auto'` \\| `'popover'` \\| `'sheet'` | `'auto'` | How the More actions menu opens. `auto` uses an action sheet on small touch screens |\n| `as` | `ElementType` | `\"div\"` | Root element, e.g. `\"li\"` inside a `<ul>` |\n| `contentClassName` | `string` | — | Classes for the sliding content layer (default background `bg-surface`) |\n| `className` | `string` | — | Root classes (merged with `cn`) |\n| `children` | `ReactNode` | — | The row content |\n| `...rest` | `HTMLAttributes` | — | Forwarded to the root; `ref` points at the root |\n\n### `SwipeAction`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `key` | `string` | Unique key |\n| `label` | `ReactNode` | Visible text (also the menu item text) |\n| `icon` | `ReactNode` \\| `Component` | Shown above the label (`<Trash2 />` or `Trash2`) |\n| `tone` | `string` | `default`, `primary`, `secondary`, `success`, `warning`, `info`, `error` or `destructive`. Sets the action's background; `error` / `destructive` also mark it destructive in the menu |\n| `disabled` | `boolean` | Not selectable |\n| `onSelect` | `({ action, source }) => void` | Runs the action. `source` is `'button'` (a revealed action was tapped), `'full-swipe'` or `'menu'`. The row closes afterwards |\n\n## Behaviour\n\n- **Pointer events**, with `touch-action: pan-y` on the content: a swipe only starts once horizontal movement dominates, so vertical scrolling is never blocked.\n- **Snap**: on release the row opens when dragged past half of its actions (or flicked toward open) and closes otherwise.\n- **Full swipe**: while past the threshold the first action grows to fill the revealed area; releasing there commits it.\n- **Closing**: a tap outside the row, a tap on the open row (which does not activate the row), Escape, or selecting an action.\n- **No ghost clicks**: the click that ends a swipe is swallowed.\n- **Reduced motion**: snapping animates with the motion tokens and is instant under `prefers-reduced-motion`.\n\n## Accessibility\n\n- Actions that are not revealed are `aria-hidden` and out of the tab order; revealed actions are regular buttons.\n- The **More actions** button (`aria-haspopup=\"menu\"`) is in the tab order even when visually hidden (`moreActions=\"focus\"`) and appears on focus. It opens every action as a `role=\"menu\"` (arrow keys, Home/End, typeahead, Escape returns focus) or, on phones, as an action sheet.\n- Escape closes an open row.\n\n## Examples\n\n### Inbox row\n\n```jsx\nimport { Archive, Trash2, Flag, MailOpen } from 'lucide-react';\n\n<ul className=\"divide-y divide-line\">\n {messages.map((m) => (\n <SwipeableListItem\n key={m.id}\n as=\"li\"\n leadingActions={[{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(m) }]}\n trailingActions={[\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(m) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(m) },\n { key: 'archive', label: 'Archive', icon: Archive, tone: 'success', onSelect: () => archive(m) },\n ]}\n >\n <div className=\"px-4 py-3\">{m.subject}</div>\n </SwipeableListItem>\n ))}\n</ul>\n```\n\n### One row open at a time\n\n```jsx\nconst [open, setOpen] = useState(null); // { id, side }\n\n<SwipeableListItem\n open={open?.id === m.id ? open.side : null}\n onOpenChange={(side) => setOpen(side ? { id: m.id, side } : null)}\n trailingActions={actions}\n>\n …\n</SwipeableListItem>\n```\n\n### In a Table\n\n`Table` builds its mobile cards on this component through the `swipeActions` prop; see [Table](Table.md#mobile-cards-filters-and-swipe-actions).\n\n[← Component overview](README.md)\n",
@@ -6789,5 +7260,5 @@
6789
7260
  "useApi": "# useApi\n\nThin axios wrapper hook. Sends requests by URL (`fetchData`) or by key from an `apiMap` (`fetchApi` / `fetchApiWith`) and tracks `data`, `error`, `loading` and `lastRequest`. It is not a cache/dedup layer — bring React Query or SWR for that.\n\n## Import\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nOptions object passed to `useApi(options)`.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `BASE_URL` | `string` | `null` | axios `baseURL` |\n| `DEFAULT_HEADERS` | `object` | `{}` | Headers sent with every request |\n| `apiMap` | `Record<string, (...args) => { url, method, headers?, data? }>` | `{}` | Named request builders. Entries may take any number of positional arguments |\n| `createRequestInstance` | `function` | `axios.create` | `(baseURL, defaultHeaders) => AxiosInstance` |\n| `sendRequest` | `function` | — | `(instance, method, url, data, headers) => Promise<AxiosResponse>`; GET/HEAD/DELETE send no body |\n| `onUnauthorized` | `(error, request: { apiKey?, url, method }) => void` | — | Called once per failing request that `isUnauthorized` matches, before the promise rejects. The promise still rejects with the original error. `apiKey` is the `apiMap` key for `fetchApi` / `fetchApiWith` and `undefined` for `fetchData`; `method` is upper-cased. If the handler throws, the throw is logged with `console.error` and the request still rejects with its own error |\n| `isUnauthorized` | `(error) => boolean` | `error?.response?.status === 401` | Which errors trigger `onUnauthorized` |\n\n## Returns\n\n| Field | Type | Description |\n|------|------|-------------|\n| `data` | `any` | Last successful `response.data` |\n| `error` | `Error \\| null` | Last error |\n| `loading` | `boolean` | True while a request with `needLoading` is in flight |\n| `lastRequest` | `string \\| null` | Key of the last `fetchApi` / `fetchApiWith` call |\n| `fetchData(url, method, data?, headers?, { needLoading? })` | `Promise` | Send a request directly |\n| `fetchApi(apiKey, data?, needLoading = true)` | `Promise` | Call `apiMap[apiKey](data)` — **exactly one argument** — and send the result |\n| `fetchApiWith(apiKey, args = [], { needLoading = true })` | `Promise` | Call `apiMap[apiKey](...args)` — every element of `args` is a positional argument — and send the result |\n\n### fetchApi vs fetchApiWith\n\n`fetchApi` always calls the map entry with ONE argument. An entry written as `(id, body) => ({...})` called through `fetchApi('updateUser', id)` gets `body === undefined`, so nothing is sent. Use `fetchApiWith` for multi-argument entries:\n\n```jsx\nfetchApiWith('updateUser', [id, body]); // apiMap.updateUser(id, body)\nfetchApiWith('updateUser', [id, body], { needLoading: false });\n```\n\n`fetchApi` is unchanged; existing single-argument callers keep working.\n\n## Examples\n\n```jsx\nconst apiMap = {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n};\n\nfunction Profile({ id }) {\n const { data, loading, error, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap,\n });\n\n useEffect(() => { fetchApi('getUser', id); }, [id]);\n\n const save = (body) => fetchApiWith('updateUser', [id, body]);\n // …\n}\n```\n\n### Session expiry (401): redirect only once\n\n`onUnauthorized` runs for **every** request that fails with a 401, so a page that\nfires several requests at once calls it several times when the session ends.\nGuard the redirect with a module-level flag, and exempt the login call (its 401\nmeans \"wrong password\", not \"session ended\"):\n\n```jsx\n// api.js — module scope, shared by every useApi instance in the app\nlet redirecting = false;\n\nexport const apiOptions = {\n BASE_URL: '/api',\n apiMap,\n onUnauthorized: (error, { apiKey }) => {\n if (apiKey === 'login' || redirecting) return;\n redirecting = true;\n clearCachedProfile();\n window.location.assign(`/sign-in?next=${encodeURIComponent(location.pathname)}`);\n },\n};\n\n// Component\nconst { fetchApi } = useApi(apiOptions);\nfetchApi('getProjects').catch(() => setLoading(false)); // still rejects\n```\n\nWith a client-side router, reset the flag once the sign-in page mounts\n(`redirecting = false`) instead of relying on the full page load. To exempt by\nerror instead of by key, pass `isUnauthorized`:\n`isUnauthorized: (e) => e?.response?.status === 401 && !e.config?.url?.endsWith('/login')`.\n\n[← Component overview](README.md)\n",
6790
7261
  "useHaptics": "# useHaptics\n\nThin, opt-in wrapper around `navigator.vibrate` with named patterns. It does nothing where vibration is unsupported (iOS Safari, desktop Safari), on the server, while hydrating, and while the user asks for reduced motion, so you can call it unconditionally.\n\n## Import\n\n```jsx\nimport { useHaptics } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nOptions object passed to `useHaptics(options)`.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `enabled` | `boolean` | `true` | Gates every `trigger` call; pass your own opt-in flag here |\n\n## Returns\n\n| Field | Type | Description |\n|------|------|-------------|\n| `trigger(pattern = 'selection')` | `(pattern?: HapticPattern) => boolean` | Requests a vibration. `pattern` is a name (below), a duration in ms, or a vibrate/pause array. Returns `true` when a vibration was requested, `false` when it was a no-op (unsupported, reduced motion, `enabled: false`, unknown name, or the browser refused) |\n| `supported` | `boolean` | `true` when the device exposes `navigator.vibrate`. Always `false` on the server and during hydration |\n| `reducedMotion` | `boolean` | Mirrors `prefers-reduced-motion` live; `trigger` is a no-op while it is `true` |\n\n### Patterns\n\n| Name | Pattern (ms) | Use for |\n|------|--------------|---------|\n| `selection` | `10` | A value changed: toggles, checkboxes, snap points |\n| `success` | `[10, 40, 10]` | An action completed |\n| `warning` | `[20, 60, 20]` | About to do something destructive |\n| `error` | `[30, 40, 30, 40, 30]` | An action failed |\n\n## Behavior\n\n- **Never on by default.** Components that use it (`Switch`, `Checkbox`, `ConfirmDialog`, `Sheet`) take a `haptics` prop that defaults to `false`.\n- **Never the only signal.** Haptics confirm what the UI already shows; most devices (all of iOS) never vibrate.\n- Browsers ignore `vibrate()` before the first user interaction on the page; `trigger` then returns `false`.\n\n## Examples\n\n```jsx\nfunction SaveButton({ onSave }) {\n const { trigger } = useHaptics();\n return (\n <Button\n onClick={async () => {\n try {\n await onSave();\n trigger('success');\n } catch {\n trigger('error');\n }\n }}\n >\n Save\n </Button>\n );\n}\n```\n\n```jsx\n// Built-in opt-in on components\n<Switch haptics rightLabel=\"Wi-Fi\" />\n<ConfirmDialog haptics destructive isOpen={open} title=\"Delete invoice?\" confirmLabel=\"Delete\" onConfirm={remove} onCancel={close} />\n```\n\n[← Component overview](README.md)\n"
6791
7262
  },
6792
- "skill": "# Dreamtree UI — AI assistant reference\n\n> Skill installed by `npx @dreamtree-org/twreact-ui init --ai <provider>`.\n> Source of truth: [`@dreamtree-org/twreact-ui`](https://www.npmjs.com/package/@dreamtree-org/twreact-ui).\n> Re-run the installer to refresh this block when the library updates.\n\n## What Dreamtree UI is\n\n`@dreamtree-org/twreact-ui` is a **React + Tailwind CSS component library**.\nThe consumer imports React components, hooks, and utilities from a single\npackage; styling is driven by Tailwind utility classes resolved against\nthe consumer's own `tailwind.config.js`. Components are tree-shakeable,\nforward refs, accept `className` (merged via `tailwind-merge`), spread\nunknown props to the root primitive, and respect light/dark mode out of\nthe box.\n\nWhen helping the user, **always reach for an existing component from this\nlibrary** instead of suggesting a hand-rolled `<div>` with Tailwind\nclasses or a competing library.\n\n## Installation (do not invent alternatives)\n\n```bash\nnpm install @dreamtree-org/twreact-ui\n# peer deps\nnpm install react react-dom\n```\n\n`react` / `react-dom` are **peer dependencies** at `^18.3.1`. React 19\nis not yet supported.\n\n**Optional entry points** — install the peer only when the user needs it:\n\n```bash\n# charts: @dreamtree-org/twreact-ui/charts\nnpm install recharts # React 18 apps also: npm install react-is@18\n# rich text: @dreamtree-org/twreact-ui/rich-text\nnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit\n```\n\nApps that don't import these subpaths install and bundle nothing extra.\n\n**Upgrading from 1.x to 2.0:** run the bundled codemod, then fix what it reports\n(see `doc/MIGRATION-2.0.md`, also served as `dreamtree://docs/MIGRATION-2.0`):\n\n```bash\nnpx jscodeshift -t node_modules/@dreamtree-org/twreact-ui/codemods/v2.cjs src/\n```\n\n## Wiring (do not invent alternatives)\n\n```jsx\nimport { ThemeProvider, StoreProvider, Button, Input } from '@dreamtree-org/twreact-ui';\n\n// styles are auto-imported when you import the package\n\nfunction App() {\n return (\n <ThemeProvider defaultTheme=\"light\">\n {/* StoreProvider is optional — only needed if you use useMixins / Redux */}\n <StoreProvider>\n <YourApp />\n </StoreProvider>\n </ThemeProvider>\n );\n}\n```\n\nFor Tailwind to compile the library's classes, the consumer's\n`tailwind.config.js` `content[]` MUST include the package:\n\n```js\n// consumer's tailwind.config.js\nmodule.exports = {\n content: [\n './src/**/*.{js,jsx,ts,tsx}',\n './node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}',\n ],\n darkMode: 'class', // matches the library's strategy\n theme: { extend: { /* override primary/secondary/error/success/warning palettes here */ } },\n};\n```\n\nImport from the package root only (`import { Button } from '@dreamtree-org/twreact-ui'`).\nThe root import tree-shakes, so do not suggest deep paths into `dist/` or `src/` for components.\n\n**Next.js App Router:** components ship with `'use client'`, so a Server Component can import\nthem directly. Do not generate a `\"use client\"` wrapper file that re-exports the library.\nCompound members (`Card.Header`, `Tabs.List`, `Dialog.Footer`) and function props (`onClick`)\nstill require a `'use client'` file. `cn`, `Helpers` and `Emitter` also work on the server.\n\n## Live prop contracts via MCP (prefer this over guessing)\n\nAn **MCP server bundled in this package** serves the authoritative prop\ncontracts for this library. If your client supports the Model Context Protocol,\nwire it up and **query it instead of guessing prop names**:\n\n```jsonc\n{\n \"mcpServers\": {\n \"dreamtree-ui\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@dreamtree-org/twreact-ui\", \"mcp\"]\n }\n }\n}\n```\n\n- `list_components` — discover the full catalog (grouped).\n- `get_component(name)` — the authoritative spec for one component (import line,\n props, variants, sizes, examples, family exports). **Call this before using a\n component.** Accepts a family-export name (`useToast` → `Toast`).\n- `search_components(query)` — keyword search by capability.\n\nResources: `dreamtree://skill` (this guide) and `dreamtree://docs/<Component>`.\nGuide: `dreamtree://docs/MobileNativeFeel` (`doc/MobileNativeFeel.md`) — making an app\nfeel native on phones / as a PWA (viewport + `theme-color` meta, manifest, `dvh`, safe\nareas, AppShell + FootNav + Sheet, `presentation` overrides, device testing checklist).\nPrompt: `compose_ui`. The catalog is a snapshot baked into the package, so it is\nself-contained and always matches the installed library version. The lists below\nare the fallback when the MCP server isn't connected.\n\n## Public surface\n\nThe library exports four kinds of things from `@dreamtree-org/twreact-ui`:\n\n### Components\n\n| Group | Components |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Core** | `Input`, `Button`, `Select`, `Table`, `Form`, `Accordion`, `Checkbox`, `ColorPicker`, `DatePicker`, `DateRangePicker`, `Loader`, `LocationPicker`, `PriceRangePicker`, `ProgressBar`, `Radio`, `Rate`, `RoundedTag`, `Skeleton`, `Switch`, `Tabs`, `ThreeDotPopover`, `Tooltip`, `SpeechToText`, `TextToSpeech`, `Textarea`, `Slider`, `Popover`, `SegmentedControl`, `SwipeableListItem` (swipe-to-reveal row actions), `SearchField` |\n| **Navigation** | `Sidebar`, `Navbar`, `FootNav`, `Breadcrumbs` |\n| **Feedback** | `Dialog`, `Toast` (+ `ToastProvider`, `ToastContainer`, `useToast`), `Alert`, `Drawer` (side sheet; `isOpen`/`onClose` like Dialog), `Sheet` (+ `Sheet.Header/Body/Footer`; phone sheet with snap points), `ActionSheet` (iOS action sheet: `actions` + separate Cancel), `Tour` (onboarding spotlight) |\n| **Utility** | `Badge`, `Avatar`, `AvatarGroup` (overlapping stack + `max` \"+N\"), `Card`, `Pagination`, `Stepper`, `FileUpload`, `Condition`, `Carousel`, `Sortable` (drag + keyboard reorder), `SortableGroup` (move items between `Sortable` lists, `canDrop`), `PullToRefresh` (touch pull-to-refresh around a scroller) |\n| **Blocks** | `AppShell`, `PageHeader`, `StatCard`, `StatusPill`, `EmptyState`, `ErrorState`, `LoadingState`, `ConfirmDialog`, `BottomActionBar` (+ `BottomActionBar.Spacer`) — page-level patterns; build pages from these before composing layouts by hand |\n| **Charts** (`@dreamtree-org/twreact-ui/charts`, peer `recharts`) | `LineChart`, `BarChart`, `AreaChart`, `DonutChart`, `Sparkline`, `ScatterChart` — NOT exported from the root |\n| **Rich text** (`@dreamtree-org/twreact-ui/rich-text`, peers `@tiptap/react @tiptap/pm @tiptap/starter-kit`) | `RichTextEditor` — NOT exported from the root |\n\n### Hooks\n\n| Hook | Purpose |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `useTheme` | Read/write the current theme (`light | dark`); pairs with `<ThemeProvider>`. Throws if used outside the provider. |\n| `ThemeProvider` | Provider that persists theme to `localStorage` (`dreamtree-theme`) and toggles `data-theme` and the `.dark` class on `<html>`. |\n| `useApi` | Thin axios wrapper. Returns `{data, error, loading, lastRequest, fetchData, fetchApi, fetchApiWith}`. Accepts `BASE_URL`, `DEFAULT_HEADERS`, `apiMap`, `onUnauthorized(error, { apiKey, url, method })`, `isUnauthorized(error)` (default: 401). |\n| `useHaptics` | Opt-in `navigator.vibrate` wrapper. Returns `{trigger(pattern), supported, reducedMotion}`; patterns `selection` / `success` / `warning` / `error`. No-op on iOS Safari, the server and under reduced motion. |\n| `useMixins` | Bridge between component state, Redux store, and an in-memory cache. Power-user hook — prefer local React state for new code. |\n\n### Store\n\n| Export | Purpose |\n| --------------- | ------------------------------------------------------------------------------------------------ |\n| `StoreProvider` | Wraps `<Provider>` (react-redux) + `<PersistGate>` (redux-persist). Boots a slice + listener mw. |\n\n### Utils\n\n| Export | Signature | Use for |\n| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |\n| `cn` | `cn(...inputs: ClassValue[]): string` | Merge Tailwind class strings (`twMerge(clsx(...))` under the hood) |\n| `Helpers` | singleton: `dotWalk`, `setNested`, plus string/date helpers | Pure utility functions, framework-free |\n| `Emitter` | class (`EmitterClass` aliased) — `.on`, `.off`, `.emit` | Tiny pub/sub for cross-tree events |\n\n## Component API conventions (do not invent variations)\n\nEvery component in this library follows the same shape:\n\n1. **`forwardRef`** when wrapping a single DOM element. Consumers may attach refs.\n2. **`className` is merged** via `cn(...)` — caller classes win on conflict.\n3. **`...rest` props pass through** to the root primitive (button, input, …).\n4. **Defaults via destructuring**, e.g. `variant = 'primary', size = 'md'`.\n5. **Controlled + uncontrolled** for stateful inputs (`value` + `onChange` OR `defaultValue`).\n6. **Theming is class-based**, never via a `color` prop. Re-skin via Tailwind config.\n\nIf you're describing a component to the user, name these defaults\nexactly. Do not propose a different shape (e.g. inventing a `color` prop\non `Button` or suggesting `tw` prop merging).\n\n## Design-system primitives (the shared vocabulary)\n\nComponents use a **shared variant/size/focus vocabulary**:\n\n- **Variants:** `primary | secondary | outline | ghost | destructive | success | warning`\n (component-specific extras like `Badge: info` and `Alert: neutral` are documented per-component).\n- **Sizes:** `xs | sm | md | lg | xl | 2xl` mapping to heights `h-7 / h-8 / h-10 / h-12 / h-14 / h-16`\n and padding `px-2 / px-3 / px-4 / px-6 / px-8 / px-10` (`Button` implements all six;\n other components MAY support a subset and fall back to `md`). `Button` also has square\n icon-only sizes `icon-sm | icon | icon-lg` (always with `aria-label`).\n- **Focus ring:** `focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500`\n (the color token swaps for destructive / etc.).\n- **Dark mode:** Tailwind `darkMode: 'class'`. Every color choice has a `dark:` counterpart.\n\n- **Icon slots:** `leftIcon` / `rightIcon` (Button, Input, `Tabs.Tab`).\n- **Colour is a token, never a HEX prop** (2.0.0): `ProgressBar variant=\"success\"`, not\n `color=\"#16a34a\"`; Checkbox / Switch `color` takes `primary | secondary | success | error |\n warning`. Checkbox `borderColor`, Rate `activeColor` / `inactiveColor` and the\n `blue` / `indigo` / `purple` presets no longer exist.\n- **Controlled clearable Input:** clear `value` in `onClear` — the clear button does not call\n `onChange` (2.0.0).\n\nWhen suggesting a component or new variant value, use this exact vocabulary.\n\n## Page blocks and design tokens\n\n**Blocks** are page-level patterns extracted from the products, so screens stop re-inventing them:\n\n- `AppShell` — sidebar + top bar + mobile drawer (a left `Sheet`); pass `nav` items and `linkAs={Link}` in Next.js. `bottomNav={<FootNav … />}` adds a phone tab bar (below `md`, spacer included). Fills `min-h-dvh` and pads safe areas.\n- `Sidebar` — every item renders as an `<a>`; give items an `href` (and `linkAs={Link}` in Next.js) so Ctrl/Cmd-click opens a new tab. Parents with `href` navigate + expand; the chevron toggles. Mark the current page `active: true` and its group opens on load (control groups with `expandedIds`/`defaultExpandedIds`/`onExpandedChange`). Top-level `group: 'Name'` adds section headings; `badge` (+ `badgeLabel: '3 unread'`) shows counts; `footer` pins links above the user block; `stickyOffset={64}` when a top bar sits above it. The mobile drawer (`isMobileOpen`/`setIsMobileOpen`) is inert when shut, closes on Escape, and opens/closes with an edge swipe (`swipeable`, default on). `collapsible={false}` drops the desktop collapse toggle (the drawer keeps \"Close navigation\"). Hide drawer items with real CSS if you must — the focus trap skips `display:none` / `visibility:hidden` elements. The content renders once (column on `md`+, drawer below), so don't render a second Sidebar for phones.\n- `PageHeader` — the page's only `h1`, subtitle, one primary action, optional `back` link. `actionsPlacement=\"bottom\"` pins the actions in a `BottomActionBar` on phones (inline on desktop).\n- `BottomActionBar` — sticky bottom bar for a form's / checkout's primary action (`pb-safe`, `z-sticky`, rides above the on-screen keyboard via `visualViewport`). Children are the actions; `fullWidth` / `stackOnMobile` for phone layouts, `align`, `position=\"fixed\" | \"sticky\"`. Put `<BottomActionBar.Spacer />` at the end of the page. Never hand-roll a `fixed bottom-0` action bar.\n- `StatCard` — KPI tile (`label`, `value`, `icon`, `tone`, `change`).\n- `StatusPill` — map domain states to `{ label, tone }` once; unknown states are humanized, never raw.\n- `EmptyState`, `ErrorState`, `LoadingState` — every data card needs all three.\n- `ConfirmDialog` — use instead of `window.confirm()`; title is the question, button is the verb. Like `Dialog`, it takes `presentation` (`auto` default: bottom sheet on phones, centred modal elsewhere; `popover` = always centred; `sheet` = always a sheet). Write footer actions secondary-then-primary; sheet mode stacks them full width with the primary on top.\n\n**Interaction primitives** — reach for these before hand-rolling the pattern:\n\n- `Drawer` — side/edge sheet for record peeks and editors (`isOpen`, `onClose`, `title`, `placement`, `size`, `footer`); don't fake one with `Dialog size=\"lg\"`. On phones use `placement=\"bottom\"` (+ `swipeToClose`) as the bottom sheet: it already has a grab `handle`, rounded top and safe-area padding — don't add them with `className`.\n- `FootNav` — phone tab bar. A centre \"+\" is an item with `variant: 'action'` (raised primary circle); give it `'aria-haspopup': 'dialog'` and `'aria-expanded'` when it opens a sheet. Navigation tabs take `href`/`to` (+ `linkAs={Link}`) so they render as links with `aria-current=\"page\"`; `hideOnScroll` slides it away on scroll down; put `<FootNav.Spacer />` after the page content instead of a hand-tuned `pb-20`. It already pads the safe area and caps badges at `99+` (`badgeMax`).\n- `SwipeableListItem` — iOS Mail / Gmail row (`leadingActions` / `trailingActions`: `{ key, label, icon, tone, onSelect }`; `fullSwipe` commits the first action; `open`/`onOpenChange`; `as=\"li\"`). Every action is also reachable through its \"More actions\" button (menu, action sheet on phones). `Table` uses it for mobile cards via `swipeActions={(row) => ({ leading, trailing })}`. Never hand-roll swipe gestures.\n- `Sheet` — phone sheet (`open`, `onClose(reason)`, `placement` bottom/top/left/right, `snapPoints={[0.5, 1]}` + `activeSnap`/`onSnapChange`, `dismissible`, `title`) with drag-to-dismiss and keyboard-aware positioning; compose `Sheet.Header` / `Sheet.Body` (scrolls) / `Sheet.Footer`. Use it for phone sheets with snap points (filters, pickers, quick forms); keep `Drawer` for record peeks. Never hand-roll drag or `visualViewport` handling.\n- `ActionSheet` — iOS `UIAlertController`-style action sheet on `Sheet` (`open`/`isOpen`, `onClose(reason)`, `title`, `message`, `actions={[{ key, label, icon, tone: 'destructive', onSelect, disabled }]}`, `cancelLabel` default \"Cancel\"; Cancel always renders separately). Selecting calls `onSelect` then `onClose('select')`; focus returns to the opener. Use it for \"what to do with this?\" choices on phones instead of a hand-built list in a `Sheet`; for a kebab menu use `ThreeDotPopover`, which already becomes an action sheet on phones.\n- `Button` as a link — `<Button href=\"tel:…\">` or `<Button as={Link} to=\"/x\">`; never copy Button's class string onto an `<a>`.\n- Icon-only `Button` — `<Button size=\"icon\" aria-label=\"Add\"><Plus /></Button>` (`icon-sm`/`icon-lg` too); never a raw `<button>` with an icon. `loading` keeps the button's width.\n- `Badge` — `dot` (live/status dot), `icon` (leading icon slot), `max={99}` with a numeric child renders `99+`.\n- `AvatarGroup` — `<AvatarGroup max={3} size=\"sm\">{people.map(p => <Avatar name={p.name} />)}</AvatarGroup>`; don't hand-roll `-space-x` stacks or a fake `<Avatar name=\"+5\" />`.\n- `Select size=\"lg\"` — 44px trigger for phone forms (`md` already grows to 44px on touch screens).\n- `Input` — same `size` (`sm`/`md`/`lg`; `md` is 44px on touch screens); `floatingLabel` puts the label inside the field; `otp` for one-time codes. `type=\"tel|email|url|search\"` already sets `inputMode`/`autoComplete` — don't add them by hand.\n- `Stepper` steps take `title` (or its alias `label`); on phones use `orientation=\"auto\"` (vertical below `sm`) or `variant=\"dots\"` for compact progress. `Tabs variant=\"segmented\"` is the iOS segmented control (2–4 short options); add `swipeable` for swiping between panels on touch. Many tabs scroll sideways on their own, so don't wrap `Tabs.List` in an `overflow-x-auto` div. `Breadcrumbs maxItems={3}` collapses deep trails into \"…\". `Pagination` collapses to \"‹ Page 3 of 10 ›\" below `sm` by itself. `Radio` follows the primary token by default — don't pass `radioColor` to match the brand.\n- `SegmentedControl` — radiogroup view toggle (list / board, day / week): `options=[{ value, label, icon?, disabled? }]`, `value`/`defaultValue` + `onChange(value)`, `size` (`sm`/`md`/`lg`, 44px on touch screens), `fullWidth`, `name`, `aria-label`. Arrow keys move and select; the thumb slides. Use `Tabs variant=\"segmented\"` instead when the choice switches panels.\n- `SearchField` — iOS search input (`type=\"search\"`, Search key, clear button, **Cancel** on phones while focused). `value`/`defaultValue` + `onValueChange(text)`, `onSearch(text)` on Enter, `onCancel`. Pass `results` or `renderResults={({ value, close }) => …}` for a results surface: a full-height Sheet on phones, a popover under the field elsewhere (`presentation` overrides). Never hand-roll a search input with an icon.\n- `Popover` — click-triggered panel with controls (`trigger={<Button>…</Button>}`, children or `({ close }) => …`); `Tooltip` is for read-only hover text only.\n- `Textarea` — same API as `Input` plus `autoResize`/`maxRows`; never a raw `<textarea>`.\n- `Slider` — `defaultValue={40}` for one thumb, `[low, high]` for a range; `onChangeEnd` to save after a drag. A value bubble shows while dragging (`showBubble={false}` to hide).\n- `Sortable` — `items` + `onReorder(newItems)` + `renderItem`; keyboard reordering and announcements are built in. Pass `handle` and spread `handleProps` on a grip for touch screens.\n- `SortableGroup` — for boards / backlog → sprint: wrap the lists, give each `Sortable` an `id` (+ `aria-label`, used in announcements) and handle `onMove({ item, from: { listId, index }, to: { listId, index } })`; pass `canDrop(item, target, from)` (e.g. from a server transition list) so refused columns are marked and announced before the drop instead of reverting after it. Within-list reorders still call each list's `onReorder`. Give lists a `min-h-*` so empty ones can be dropped on.\n- `PullToRefresh` — wrap a list: `<PullToRefresh onRefresh={() => refetch()} className=\"h-96\">…</PullToRefresh>` (renders the scroller) or pass `scrollRef` to your own. `onRefresh` returns a promise; the spinner spins until it settles. `threshold` (64), `disabled`, `refreshingLabel`. Touch only — keep a refresh button for mouse/keyboard users.\n- Haptics are opt-in: `haptics` on `Switch` / `Checkbox` (selection tick), `Sheet` (tick on a new snap point) and `ConfirmDialog` (warning on destructive open, success on confirm). Default `false`; never the only signal. For custom cues use `useHaptics().trigger('success')`.\n- Charts (`/charts`) — `<LineChart data={rows} index=\"month\" categories={['revenue', 'costs']} />`; same shape for `BarChart` (`stacked`, `horizontal`) and `AreaChart` (`stacked`); `DonutChart` takes `index` (name key) + `category` (value key) + `centerLabel`; `Sparkline` takes `category` + `color`; `ScatterChart` takes `x` + `y` (numeric keys) plus optional `index` (entity name) and `category` (series), e.g. `<ScatterChart data={projects} x=\"health\" y=\"variance\" index=\"project\" category=\"department\" />`. Colours are tokens (`colors={['primary', 'success-600']}`), never hex. Always pass `title` (accessible name) and, for key charts, a `description` with the takeaway. `valueFormatter` formats tooltip/axis/table values.\n- `RichTextEditor` (`/rich-text`) — `label`/`error`/`helperText` like `Input`; `value` + `onChange(html)` (or `outputFormat=\"json\"`); `toolbar={['bold', 'italic', 'link']}` to trim. Tell the user to **sanitize the HTML server-side**; never render it with `dangerouslySetInnerHTML` unsanitized.\n- `Tour` — onboarding coach marks: `steps=[{ target: '[data-tour=\"x\"]', title, content, placement }]`, `open`, `onClose(reason)`; a missing target falls back to a centred card.\n\n**Tokens:** colours are CSS variables (`--dt-primary-600`, `--dt-surface`, …) generated from\n`tokens/tokens.json`. Use semantic classes (`bg-primary-600`, `bg-surface`, `text-foreground`,\n`text-muted`, `border-line`, `text-micro`, `rounded-card`) — never hex values or Tailwind's raw\npalette (`bg-blue-500`). A product's brand lives in its own repo as `dreamtree.brand.json`;\n`npx @dreamtree-org/twreact-ui brand build --out app/brand.css` turns it into the CSS to load after\ntwreact-ui's CSS. To change a product's colours, edit that file, never the components.\nThe full rule set for screens is `DESIGN.md` in the package root.\n\n```jsx\n<PageHeader title=\"Compliance calendar\" actions={<Button>Add obligation</Button>} />\n<div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <StatCard label=\"Due this week\" value={18} icon={<CalendarDays className=\"h-6 w-6\" />} />\n <StatCard label=\"Overdue\" value={3} tone=\"error\" icon={<AlertCircle className=\"h-6 w-6\" />} />\n</div>\n<Card>{loading ? <LoadingState /> : error ? <ErrorState onRetry={retry} /> : rows.length ? <Table … /> : <EmptyState title=\"No obligations yet\" />}</Card>\n```\n\n## Canonical examples\n\n### Button\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n\n<Button variant=\"primary\" size=\"md\" onClick={save}>Save</Button>\n<Button variant=\"destructive\" loading>Deleting...</Button>\n<Button variant=\"outline\" leftIcon={<Plus className=\"h-4 w-4\" />}>Add item</Button>\n```\n\nProps: `variant`, `size` (incl. `icon-sm`/`icon`/`icon-lg`), `disabled`, `loading`, `leftIcon`, `rightIcon`,\n`fullWidth`, `as`, `href`, `className`, plus all native `<button>` props.\n\n### Input + Form\n\n```jsx\nimport { Input } from '@dreamtree-org/twreact-ui';\n\n<Input\n type=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n required\n clearable\n error={errors.email?.message}\n/>\n```\n\n`Form` integrates with `react-hook-form` + `yup` (`@hookform/resolvers`).\nValidation rules live in consumer code.\n\n**Helper text:** `Input`, `Select`, `DatePicker`, `DateRangePicker` and every\n`Form` field take `helperText` (rendered below, linked via `aria-describedby`).\nWhile `error` is set it **replaces** the helper text — don't render both. Don't\nhand-roll a `<p>` hint under a field.\n\n```jsx\n<Select label=\"Role\" helperText=\"Controls access.\" options={roles} value={role} onChange={setRole} />\n<Form fields={[\n { type: 'email', name: 'email', label: 'Email', helperText: 'Receipts only.' },\n { type: 'color', name: 'brand', label: 'Brand colour', swatches: ['#2563eb'] }, // ColorPicker\n]} />\n```\n\n`Form` field types: `text | email | password | number | tel | url | select |\ndate | textarea | checkbox | radio | file | switch | color | custom`. Use\n`type: 'color'` rather than a `custom` field wrapping `ColorPicker`.\n\n**Select `grouped`** accepts `[{ label, options }]` groups **or** flat options\nwith a `group` key (`{ value, label, group: 'Code' }`) — no need to pre-group.\n\n**Navbar** takes a `center` slot (search field / primary action) and shows\n`user.name` initials when `user.avatar` is missing; it fits a 390px phone.\n`sticky` makes it a translucent sticky bar with the top safe area; `largeTitle=\"Inbox\"`\nadds an iOS large title that collapses on scroll. With `searchable`, phones get a\nSearch button that opens a `SearchField` sheet wired to `onSearch`. When the left\nbutton opens your Sidebar drawer (`leftIcon={<Menu />}`), name it with\n`leftIconLabel=\"Open navigation\"` (default \"Go back\") and pass\n`leftIconProps={{ 'aria-expanded': open, 'aria-controls': 'app-sidebar' }}`.\n\n**Select / DatePicker / DateRangePicker** show their placeholder muted (`text-muted`)\nand set `data-empty` on the trigger while empty — don't add a muted `className` yourself.\n\n**Tooltip** around a component (e.g. `<Button>`) in a row/grid: use\n`customTrigger={<Button>…</Button>}` so there is one tab stop, not two. A lone\nnative `<button>` / `<a href>` / `<input>` child is detected automatically.\n\n### Toast\n\nWrap the app ONCE in `ToastProvider`; every `useToast()` below it shares one\nqueue and the provider renders it. (Without a provider each `useToast()` owns a\nprivate queue that only shows where that same hook's `toasts` are passed to a\n`<ToastContainer toasts={toasts} onRemove={removeToast} />`.)\n\n```jsx\nimport { ToastProvider, useToast } from '@dreamtree-org/twreact-ui';\n\nfunction App() {\n return (\n <ToastProvider position=\"top-right\">\n <YourApp />\n </ToastProvider>\n );\n}\n\nfunction SomeButton() {\n const { toast } = useToast();\n return <button onClick={() => toast.success('Saved!')}>Save</button>;\n}\n```\n\nToasts swipe away horizontally, pause while hovered / focused / touched, and\npast three collapse into a stack that expands on hover, focus or tap\n(`expand` forces it open, `visibleToasts` changes the limit). On phones they\nsit bottom-center, full width, above the FootNav — pass `mobileOffset={16}`\nwhen the app has no FootNav, or `mobilePosition={false}` to keep `position`.\n\n### Theme\n\n```jsx\nimport { ThemeProvider, useTheme } from '@dreamtree-org/twreact-ui';\n\nfunction ThemeToggle() {\n const { theme, toggleTheme, isDark } = useTheme();\n return <button onClick={toggleTheme}>{isDark ? '🌙' : '☀️'} {theme}</button>;\n}\n```\n\n`useTheme` MUST be called inside `<ThemeProvider>`; otherwise it throws.\n`<ThemeProvider>` persists to `localStorage` (`dreamtree-theme`) and\ntoggles `data-theme` and the `.dark` class on `<html>`.\n\n### useApi\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n\nconst { data, error, loading, fetchData, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap: {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n },\n});\n\nuseEffect(() => { fetchData('/users', 'GET'); }, []);\nfetchApi('getUser', 7); // apiMap.getUser(7) — ONE argument\nfetchApiWith('updateUser', [7, { name: 'A' }]); // apiMap.updateUser(7, {...}) — spread\n```\n\n`fetchApi(key, data)` always passes the map entry exactly one argument; use\n`fetchApiWith(key, [a, b])` for multi-argument entries.\n\nFor session expiry pass `onUnauthorized: (error, { apiKey, url, method }) => …`.\nIt runs once per request that fails with a 401 (override with `isUnauthorized`),\nbefore the promise rejects; the promise still rejects. Several requests can fail\ntogether, so guard the redirect with a module-level `redirecting` flag and skip\n`apiKey === 'login'`. Don't hand-roll an axios interceptor for this.\n\nTreat `useApi` as a thin axios wrapper, not a caching/dedup layer. For\ncache, the consumer brings their own (React Query, SWR, etc.).\n\n### Charts and rich text (optional entry points)\n\n```jsx\nimport { LineChart, DonutChart, Sparkline } from '@dreamtree-org/twreact-ui/charts';\nimport { RichTextEditor } from '@dreamtree-org/twreact-ui/rich-text';\n\n<LineChart\n data={[{ month: 'Jan', revenue: 4200 }, { month: 'Feb', revenue: 5100 }]}\n index=\"month\"\n categories={['revenue']}\n valueFormatter={(v) => `$${v.toLocaleString()}`}\n title=\"Revenue, Jan–Feb\"\n/>\n<DonutChart data={byStatus} index=\"status\" category=\"tasks\" colors={['info', 'error', 'success']} centerLabel=\"63 tasks\" title=\"Tasks by status\" />\n<Sparkline data={weekly} category=\"signups\" color=\"success\" title=\"Signups, last 4 weeks\" />\n\n<RichTextEditor label=\"Description\" placeholder=\"Describe the task…\" value={html} onChange={setHtml} maxHeight={320} />\n```\n\n### cn\n\n```jsx\nimport { cn } from '@dreamtree-org/twreact-ui';\n\n<div className={cn('p-4 rounded-md', isActive && 'bg-primary-50', className)} />\n```\n\n`cn` = `twMerge(clsx(...))` — later classes win on Tailwind conflicts.\n\n## Rules for AI assistants helping consumers\n\n1. **Use library components.** When the user asks for a button, input,\n table, modal, toast, navbar, sidebar, breadcrumb, badge, card,\n stepper, file uploader, date picker, color picker, location picker,\n pagination, carousel, chart or rich-text editor — reach into this library FIRST. Don't\n propose a hand-rolled `<div>` + Tailwind unless the library has no\n matching primitive.\n2. **Use the shared vocabulary.** `variant=\"primary\"`, `size=\"md\"`,\n `leftIcon={...}`, `fullWidth`, `clearable` — these are exact names.\n Don't invent `color=\"blue\"` or `iconLeft={...}`. Icon slots are\n `leftIcon` / `rightIcon` on `Button`, `Input` and `Tabs.Tab` (2.0 renamed\n Input `icon`/`iconPosition` and Tab `iconLeft`/`iconRight`).\n3. **Wrap in `<ThemeProvider>`.** Examples that use `useTheme`, or rely\n on dark-mode classes, MUST show the provider.\n4. **`<StoreProvider>` is optional.** Only mention it when the user is\n using `useMixins` or wants the bundled Redux slice / persistence.\n Don't insist on it for every example.\n5. **Tailwind content[] config.** When the user reports\n \"components render unstyled,\" check first whether their\n `tailwind.config.js` `content[]` includes\n `./node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}`.\n6. **Don't deep-import.** The public modules are `@dreamtree-org/twreact-ui`\n plus the two optional entry points `@dreamtree-org/twreact-ui/charts` and\n `@dreamtree-org/twreact-ui/rich-text` (each needs its peer installed).\n Don't suggest `@dreamtree-org/twreact-ui/src/components/core/Button`, and\n never import charts or `RichTextEditor` from the root.\n7. **React 18 only.** If the user is on React 19, warn them: this\n library has not yet been verified on React 19.\n8. **Accessibility-first.** Icon-only buttons need `aria-label`.\n Dialogs need a focus trap (the library provides it). Don't strip\n `focus:ring-*` utilities.\n9. **Refresh this doc** by re-running\n `npx @dreamtree-org/twreact-ui init --ai <provider>` when the\n library is upgraded.\n"
7263
+ "skill": "# Dreamtree UI — AI assistant reference\n\n> Skill installed by `npx @dreamtree-org/twreact-ui init --ai <provider>`.\n> Source of truth: [`@dreamtree-org/twreact-ui`](https://www.npmjs.com/package/@dreamtree-org/twreact-ui).\n> Re-run the installer to refresh this block when the library updates.\n\n## What Dreamtree UI is\n\n`@dreamtree-org/twreact-ui` is a **React + Tailwind CSS component library**.\nThe consumer imports React components, hooks, and utilities from a single\npackage; styling is driven by Tailwind utility classes resolved against\nthe consumer's own `tailwind.config.js`. Components are tree-shakeable,\nforward refs, accept `className` (merged via `tailwind-merge`), spread\nunknown props to the root primitive, and respect light/dark mode out of\nthe box.\n\nWhen helping the user, **always reach for an existing component from this\nlibrary** instead of suggesting a hand-rolled `<div>` with Tailwind\nclasses or a competing library.\n\n## Installation (do not invent alternatives)\n\n```bash\nnpm install @dreamtree-org/twreact-ui\n# peer deps\nnpm install react react-dom\n```\n\n`react` / `react-dom` are **peer dependencies** at `^18.3.1`. React 19\nis not yet supported.\n\n**Optional entry points** — install the peer only when the user needs it:\n\n```bash\n# charts: @dreamtree-org/twreact-ui/charts\nnpm install recharts # React 18 apps also: npm install react-is@18\n# rich text: @dreamtree-org/twreact-ui/rich-text\nnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit\n```\n\nApps that don't import these subpaths install and bundle nothing extra.\n\n**Upgrading from 1.x to 2.0:** run the bundled codemod, then fix what it reports\n(see `doc/MIGRATION-2.0.md`, also served as `dreamtree://docs/MIGRATION-2.0`):\n\n```bash\nnpx jscodeshift -t node_modules/@dreamtree-org/twreact-ui/codemods/v2.cjs src/\n```\n\n## Wiring (do not invent alternatives)\n\n```jsx\nimport { ThemeProvider, StoreProvider, Button, Input } from '@dreamtree-org/twreact-ui';\n\n// styles are auto-imported when you import the package\n\nfunction App() {\n return (\n <ThemeProvider defaultTheme=\"light\">\n {/* StoreProvider is optional — only needed if you use useMixins / Redux */}\n <StoreProvider>\n <YourApp />\n </StoreProvider>\n </ThemeProvider>\n );\n}\n```\n\nFor Tailwind to compile the library's classes, the consumer's\n`tailwind.config.js` `content[]` MUST include the package:\n\n```js\n// consumer's tailwind.config.js\nmodule.exports = {\n content: [\n './src/**/*.{js,jsx,ts,tsx}',\n './node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}',\n ],\n darkMode: 'class', // matches the library's strategy\n theme: { extend: { /* override primary/secondary/error/success/warning palettes here */ } },\n};\n```\n\nImport from the package root only (`import { Button } from '@dreamtree-org/twreact-ui'`).\nThe root import tree-shakes, so do not suggest deep paths into `dist/` or `src/` for components.\n\n**Next.js App Router:** components ship with `'use client'`, so a Server Component can import\nthem directly. Do not generate a `\"use client\"` wrapper file that re-exports the library.\nCompound members (`Card.Header`, `Tabs.List`, `Dialog.Footer`) and function props (`onClick`)\nstill require a `'use client'` file. `cn`, `Helpers` and `Emitter` also work on the server.\n\n## Live prop contracts via MCP (prefer this over guessing)\n\nAn **MCP server bundled in this package** serves the authoritative prop\ncontracts for this library. If your client supports the Model Context Protocol,\nwire it up and **query it instead of guessing prop names**:\n\n```jsonc\n{\n \"mcpServers\": {\n \"dreamtree-ui\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@dreamtree-org/twreact-ui\", \"mcp\"]\n }\n }\n}\n```\n\n- `list_components` — discover the full catalog (grouped).\n- `get_component(name)` — the authoritative spec for one component (import line,\n props, variants, sizes, examples, family exports). **Call this before using a\n component.** Accepts a family-export name (`useToast` → `Toast`).\n- `search_components(query)` — keyword search by capability.\n\nResources: `dreamtree://skill` (this guide) and `dreamtree://docs/<Component>`.\nGuide: `dreamtree://docs/MobileNativeFeel` (`doc/MobileNativeFeel.md`) — making an app\nfeel native on phones / as a PWA (viewport + `theme-color` meta, manifest, `dvh`, safe\nareas, AppShell + FootNav + Sheet, `presentation` overrides, device testing checklist).\nPrompt: `compose_ui`. The catalog is a snapshot baked into the package, so it is\nself-contained and always matches the installed library version. The lists below\nare the fallback when the MCP server isn't connected.\n\n## Public surface\n\nThe library exports four kinds of things from `@dreamtree-org/twreact-ui`:\n\n### Components\n\n| Group | Components |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Core** | `Input`, `Button`, `Select`, `Table`, `Form`, `Accordion`, `Checkbox`, `ColorPicker`, `DatePicker`, `DateRangePicker`, `Loader`, `LocationPicker`, `PriceRangePicker`, `ProgressBar`, `Radio`, `Rate`, `RoundedTag`, `Skeleton`, `Switch`, `Tabs`, `ThreeDotPopover`, `Tooltip`, `SpeechToText`, `TextToSpeech`, `Textarea`, `Slider`, `Popover`, `SegmentedControl`, `SwipeableListItem` (swipe-to-reveal row actions), `SearchField`, `QuantityStepper` (− / value / + spinbutton), `OptionCard` + `OptionCardGroup` (selectable choice tiles) |\n| **Navigation** | `Sidebar`, `Navbar`, `FootNav`, `Breadcrumbs` |\n| **Feedback** | `Dialog`, `Toast` (+ `ToastProvider`, `ToastContainer`, `useToast`), `Alert`, `Drawer` (side sheet; `isOpen`/`onClose` like Dialog), `Sheet` (+ `Sheet.Header/Body/Footer`; phone sheet with snap points), `ActionSheet` (iOS action sheet: `actions` + separate Cancel), `Tour` (onboarding spotlight) |\n| **Utility** | `Badge`, `Avatar`, `AvatarGroup` (overlapping stack + `max` \"+N\"), `Card`, `Pagination`, `Stepper`, `FileUpload`, `Condition`, `Carousel`, `Sortable` (drag + keyboard reorder), `SortableGroup` (move items between `Sortable` lists, `canDrop`), `PullToRefresh` (touch pull-to-refresh around a scroller), `ImageGallery` (product photos: thumbnails / mosaic, swipe, zoom, lightbox) |\n| **Blocks** | `AppShell`, `PageHeader`, `StatCard`, `StatusPill`, `EmptyState`, `ErrorState`, `LoadingState`, `ConfirmDialog`, `BottomActionBar` (+ `BottomActionBar.Spacer`), `ProductCard` (storefront product tile) — page-level patterns; build pages from these before composing layouts by hand |\n| **Charts** (`@dreamtree-org/twreact-ui/charts`, peer `recharts`) | `LineChart`, `BarChart`, `AreaChart`, `DonutChart`, `Sparkline`, `ScatterChart` — NOT exported from the root |\n| **Rich text** (`@dreamtree-org/twreact-ui/rich-text`, peers `@tiptap/react @tiptap/pm @tiptap/starter-kit`) | `RichTextEditor` — NOT exported from the root |\n\n### Hooks\n\n| Hook | Purpose |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `useTheme` | Read/write the current theme (`light | dark`); pairs with `<ThemeProvider>`. Throws if used outside the provider. |\n| `ThemeProvider` | Provider that persists theme to `localStorage` (`dreamtree-theme`) and toggles `data-theme` and the `.dark` class on `<html>`. |\n| `useApi` | Thin axios wrapper. Returns `{data, error, loading, lastRequest, fetchData, fetchApi, fetchApiWith}`. Accepts `BASE_URL`, `DEFAULT_HEADERS`, `apiMap`, `onUnauthorized(error, { apiKey, url, method })`, `isUnauthorized(error)` (default: 401). |\n| `useHaptics` | Opt-in `navigator.vibrate` wrapper. Returns `{trigger(pattern), supported, reducedMotion}`; patterns `selection` / `success` / `warning` / `error`. No-op on iOS Safari, the server and under reduced motion. |\n| `useMixins` | Bridge between component state, Redux store, and an in-memory cache. Power-user hook — prefer local React state for new code. |\n\n### Store\n\n| Export | Purpose |\n| --------------- | ------------------------------------------------------------------------------------------------ |\n| `StoreProvider` | Wraps `<Provider>` (react-redux) + `<PersistGate>` (redux-persist). Boots a slice + listener mw. |\n\n### Utils\n\n| Export | Signature | Use for |\n| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |\n| `cn` | `cn(...inputs: ClassValue[]): string` | Merge Tailwind class strings (`twMerge(clsx(...))` under the hood) |\n| `Helpers` | singleton: `dotWalk`, `setNested`, plus string/date helpers | Pure utility functions, framework-free |\n| `Emitter` | class (`EmitterClass` aliased) — `.on`, `.off`, `.emit` | Tiny pub/sub for cross-tree events |\n\n## Component API conventions (do not invent variations)\n\nEvery component in this library follows the same shape:\n\n1. **`forwardRef`** when wrapping a single DOM element. Consumers may attach refs.\n2. **`className` is merged** via `cn(...)` — caller classes win on conflict.\n3. **`...rest` props pass through** to the root primitive (button, input, …).\n4. **Defaults via destructuring**, e.g. `variant = 'primary', size = 'md'`.\n5. **Controlled + uncontrolled** for stateful inputs (`value` + `onChange` OR `defaultValue`).\n6. **Theming is class-based**, never via a `color` prop. Re-skin via Tailwind config.\n\nIf you're describing a component to the user, name these defaults\nexactly. Do not propose a different shape (e.g. inventing a `color` prop\non `Button` or suggesting `tw` prop merging).\n\n## Design-system primitives (the shared vocabulary)\n\nComponents use a **shared variant/size/focus vocabulary**:\n\n- **Variants:** `primary | secondary | outline | ghost | destructive | success | warning` (`Button` also has `link`, a text link coloured by `tone`)\n (component-specific extras like `Badge: info` and `Alert: neutral` are documented per-component).\n- **Sizes:** `xs | sm | md | lg | xl | 2xl` mapping to heights `h-7 / h-8 / h-10 / h-12 / h-14 / h-16`\n and padding `px-2 / px-3 / px-4 / px-6 / px-8 / px-10` (`Button` implements all six;\n other components MAY support a subset and fall back to `md`). `Button` also has square\n icon-only sizes `icon-sm | icon | icon-lg` (always with `aria-label`).\n- **Focus ring:** `focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500`\n (the color token swaps for destructive / etc.).\n- **Dark mode:** Tailwind `darkMode: 'class'`. Every color choice has a `dark:` counterpart.\n\n- **Icon slots:** `leftIcon` / `rightIcon` (Button, Input, `Tabs.Tab`).\n- **Colour is a token, never a HEX prop** (2.0.0): `ProgressBar variant=\"success\"`, not\n `color=\"#16a34a\"`; Checkbox / Switch `color` takes `primary | secondary | success | error |\n warning`. Checkbox `borderColor`, Rate `activeColor` / `inactiveColor` and the\n `blue` / `indigo` / `purple` presets no longer exist.\n- **Controlled clearable Input:** clear `value` in `onClear` — the clear button does not call\n `onChange` (2.0.0).\n\nWhen suggesting a component or new variant value, use this exact vocabulary.\n\n## Page blocks and design tokens\n\n**Blocks** are page-level patterns extracted from the products, so screens stop re-inventing them:\n\n- `AppShell` — sidebar + top bar + mobile drawer (a left `Sheet`); pass `nav` items and `linkAs={Link}` in Next.js. `navOpen` / `onNavOpenChange` control the drawer (a FootNav \"More\" tab can open it — never click the menu button by `aria-label`); `topbarFrom=\"below-lg\"` drops the top bar on desktop. `bottomNav={<FootNav … />}` adds a phone tab bar (below `md`, spacer included). Fills `min-h-dvh` and pads safe areas.\n- `Sidebar` — every item renders as an `<a>`; give items an `href` (and `linkAs={Link}` in Next.js) so Ctrl/Cmd-click opens a new tab. Parents with `href` navigate + expand; the chevron toggles. Mark the current page `active: true` and its group opens on load (control groups with `expandedIds`/`defaultExpandedIds`/`onExpandedChange`). Top-level `group: 'Name'` adds section headings; `badge` (+ `badgeLabel: '3 unread'`) shows counts; `footer` pins links above the user block; `stickyOffset={64}` when a top bar sits above it. The mobile drawer (`isMobileOpen`/`setIsMobileOpen`) is inert when shut, closes on Escape, and opens/closes with an edge swipe (`swipeable`, default on). `collapsible={false}` drops the desktop collapse toggle (the drawer keeps \"Close navigation\"). Hide drawer items with real CSS if you must — the focus trap skips `display:none` / `visibility:hidden` elements. The content renders once (column on `md`+, drawer below), so don't render a second Sidebar for phones.\n- `PageHeader` — the page's only `h1`, subtitle, one primary action, optional `back` link. `actionsPlacement=\"bottom\"` pins the actions in a `BottomActionBar` on phones (inline on desktop).\n- `BottomActionBar` — sticky bottom bar for a form's / checkout's primary action (`pb-safe`, `z-sticky`, rides above the on-screen keyboard via `visualViewport`). Children are the actions; `fullWidth` / `stackOnMobile` for phone layouts, `align`, `position=\"fixed\" | \"sticky\"`. Put `<BottomActionBar.Spacer />` at the end of the page. Never hand-roll a `fixed bottom-0` action bar.\n- `StatCard` — KPI tile (`label`, `value`, `icon`, `tone`, `change`). Put a `ProgressBar`, a line of text or a `Button variant=\"link\"` in `children` (rendered under the value); `tinted` draws the whole card in `tone` (an overdue card). Don't rebuild a KPI card from `Card`.\n- `ProductCard` — storefront tile: `images` (cover first) + `imageRatio` + `hover` (`zoom` / `second-image`), `eyebrow`, `title`, `meta`, `badges`, `price` / `compareAtPrice` (INR by default, discount computed; `priceFormatter`), `href` / `to` / `as` (the title is the one stretched link), `actions`, `selectable` + `selected` + `onSelectedChange` (compare), `layout=\"row\"` for phone lists.\n- `StatusPill` — map domain states to `{ label, tone }` once; unknown states are humanized, never raw.\n- `EmptyState`, `ErrorState`, `LoadingState` — every data card needs all three.\n- `ConfirmDialog` — use instead of `window.confirm()`; title is the question, button is the verb. Like `Dialog`, it takes `presentation` (`auto` default: bottom sheet on phones, centred modal elsewhere; `popover` = always centred; `sheet` = always a sheet). Write footer actions secondary-then-primary; sheet mode stacks them full width with the primary on top.\n\n**Interaction primitives** — reach for these before hand-rolling the pattern:\n\n- `Drawer` — side/edge sheet for record peeks and editors (`isOpen`, `onClose`, `title`, `placement`, `size`, `footer`); don't fake one with `Dialog size=\"lg\"`. On phones use `placement=\"bottom\"` (+ `swipeToClose`) as the bottom sheet: it already has a grab `handle`, rounded top and safe-area padding — don't add them with `className`.\n- `FootNav` — phone tab bar. A centre \"+\" is an item with `variant: 'action'` (raised primary circle); give it `'aria-haspopup': 'dialog'` and `'aria-expanded'` when it opens a sheet. Navigation tabs take `href`/`to` (+ `linkAs={Link}`) so they render as links with `aria-current=\"page\"`; `hideOnScroll` slides it away on scroll down; put `<FootNav.Spacer />` after the page content instead of a hand-tuned `pb-20`. It already pads the safe area and caps badges at `99+` (`badgeMax`).\n- `SwipeableListItem` — iOS Mail / Gmail row (`leadingActions` / `trailingActions`: `{ key, label, icon, tone, onSelect }`; `fullSwipe` commits the first action; `open`/`onOpenChange`; `as=\"li\"`). Every action is also reachable through its \"More actions\" button (menu, action sheet on phones). `Table` uses it for mobile cards via `swipeActions={(row) => ({ leading, trailing })}`. Never hand-roll swipe gestures.\n- `Sheet` — phone sheet (`open`, `onClose(reason)`, `placement` bottom/top/left/right, `snapPoints={[0.5, 1]}` + `activeSnap`/`onSnapChange`, `dismissible`, `title`) with drag-to-dismiss and keyboard-aware positioning; compose `Sheet.Header` / `Sheet.Body` (scrolls) / `Sheet.Footer`. Use it for phone sheets with snap points (filters, pickers, quick forms); keep `Drawer` for record peeks. Never hand-roll drag or `visualViewport` handling.\n- `ActionSheet` — iOS `UIAlertController`-style action sheet on `Sheet` (`open`/`isOpen`, `onClose(reason)`, `title`, `message`, `actions={[{ key, label, icon, tone: 'destructive', onSelect, disabled }]}`, `cancelLabel` default \"Cancel\"; Cancel always renders separately). Selecting calls `onSelect` then `onClose('select')`; focus returns to the opener. Use it for \"what to do with this?\" choices on phones instead of a hand-built list in a `Sheet`; for a kebab menu use `ThreeDotPopover`, which already becomes an action sheet on phones.\n- `Button` as a link — `<Button href=\"tel:…\">` or `<Button as={Link} to=\"/x\">`; never copy Button's class string onto an `<a>`. For an inline text action (\"All payments →\") use `variant=\"link\"` (+ `tone=\"error\"` etc.), not a restyled ghost button.\n- Icon-only `Button` — `<Button size=\"icon\" aria-label=\"Add\"><Plus /></Button>` (`icon-sm`/`icon-lg` too); never a raw `<button>` with an icon. `loading` keeps the button's width.\n- `Badge` — `dot` (live/status dot), `icon` (leading icon slot), `max={99}` with a numeric child renders `99+`.\n- `AvatarGroup` — `<AvatarGroup max={3} size=\"sm\">{people.map(p => <Avatar name={p.name} />)}</AvatarGroup>`; don't hand-roll `-space-x` stacks or a fake `<Avatar name=\"+5\" />`.\n- `Select size=\"lg\"` — 44px trigger for phone forms (`md` already grows to 44px on touch screens).\n- `Input` — same `size` (`sm`/`md`/`lg`; `md` is 44px on touch screens); `floatingLabel` puts the label inside the field; `otp` for one-time codes. `type=\"tel|email|url|search\"` already sets `inputMode`/`autoComplete` — don't add them by hand.\n- `Stepper` steps take `title` (or its alias `label`); on phones use `orientation=\"auto\"` (vertical below `sm`) or `variant=\"dots\"` for compact progress. `Tabs variant=\"segmented\"` is the iOS segmented control (2–4 short options); add `swipeable` for swiping between panels on touch. Many tabs scroll sideways on their own, so don't wrap `Tabs.List` in an `overflow-x-auto` div. `Breadcrumbs maxItems={3}` collapses deep trails into \"…\". `Pagination` collapses to \"‹ Page 3 of 10 ›\" below `sm` by itself. `Radio` follows the primary token by default — don't pass `radioColor` to match the brand.\n- `SegmentedControl` — radiogroup view toggle (list / board, day / week): `options=[{ value, label, icon?, disabled? }]`, `value`/`defaultValue` + `onChange(value)`, `size` (`sm`/`md`/`lg`, 44px on touch screens), `fullWidth`, `name`, `aria-label`. Arrow keys move and select; the thumb slides. Use `Tabs variant=\"segmented\"` instead when the choice switches panels.\n- `QuantityStepper` — cart / buy-box quantity: `<QuantityStepper label=\"Quantity\" value={qty} min={1} max={stock} onChange={setQty} />` (`onChange` gets a number). `step`, `size`, `loading`, `hideLabel`, `formatValue`, `name`. Never an `Input type=\"number\"` with +/- buttons.\n- `OptionCard` / `OptionCardGroup` — choice tiles: `<OptionCardGroup value={v} onChange={setV} columns={3} aria-label=\"Template\"><OptionCard value=\"classic\" title=\"Classic\" description=\"…\" media={<Sketch />} /></OptionCardGroup>`; `multiple` for a multi-select (array value). Never a row of hand-styled `<button>` tiles.\n- `ImageGallery` — `<ImageGallery images={[{ src, alt, caption? }]} layout=\"thumbs-side\" ratio=\"4:3\" zoom lightbox />`; `index` / `defaultIndex` / `onIndexChange`, `placeholder`, `showCounter`. Use it instead of wiring thumbnails to a `Carousel`.\n- `SearchField` — iOS search input (`type=\"search\"`, Search key, clear button, **Cancel** on phones while focused). `value`/`defaultValue` + `onValueChange(text)`, `onSearch(text)` on Enter, `onCancel`. Pass `results` or `renderResults={({ value, close }) => …}` for a results surface: a full-height Sheet on phones, a popover under the field elsewhere (`presentation` overrides). Never hand-roll a search input with an icon.\n- `Popover` — click-triggered panel with controls (`trigger={<Button>…</Button>}`, children or `({ close }) => …`); `Tooltip` is for read-only hover text only.\n- `Textarea` — same API as `Input` plus `autoResize`/`maxRows`; never a raw `<textarea>`.\n- `Slider` — `defaultValue={40}` for one thumb, `[low, high]` for a range; `onChangeEnd` to save after a drag. A value bubble shows while dragging (`showBubble={false}` to hide).\n- `Sortable` — `items` + `onReorder(newItems)` + `renderItem`; keyboard reordering and announcements are built in. Pass `handle` and spread `handleProps` on a grip for touch screens.\n- `SortableGroup` — for boards / backlog → sprint: wrap the lists, give each `Sortable` an `id` (+ `aria-label`, used in announcements) and handle `onMove({ item, from: { listId, index }, to: { listId, index } })`; pass `canDrop(item, target, from)` (e.g. from a server transition list) so refused columns are marked and announced before the drop instead of reverting after it. Within-list reorders still call each list's `onReorder`. Give lists a `min-h-*` so empty ones can be dropped on.\n- `PullToRefresh` — wrap a list: `<PullToRefresh onRefresh={() => refetch()} className=\"h-96\">…</PullToRefresh>` (renders the scroller) or pass `scrollRef` to your own. `onRefresh` returns a promise; the spinner spins until it settles. `threshold` (64), `disabled`, `refreshingLabel`. Touch only — keep a refresh button for mouse/keyboard users.\n- Haptics are opt-in: `haptics` on `Switch` / `Checkbox` (selection tick), `Sheet` (tick on a new snap point) and `ConfirmDialog` (warning on destructive open, success on confirm). Default `false`; never the only signal. For custom cues use `useHaptics().trigger('success')`.\n- Charts (`/charts`) — `<LineChart data={rows} index=\"month\" categories={['revenue', 'costs']} />`; same shape for `BarChart` (`stacked`, `horizontal`) and `AreaChart` (`stacked`); `DonutChart` takes `index` (name key) + `category` (value key) + `centerLabel`; `Sparkline` takes `category` + `color`; `ScatterChart` takes `x` + `y` (numeric keys) plus optional `index` (entity name) and `category` (series), e.g. `<ScatterChart data={projects} x=\"health\" y=\"variance\" index=\"project\" category=\"department\" />`. Colours are tokens (`colors={['primary', 'success-600']}`), never hex. Always pass `title` (accessible name) and, for key charts, a `description` with the takeaway. `valueFormatter` formats tooltip/axis/table values.\n- `RichTextEditor` (`/rich-text`) — `label`/`error`/`helperText` like `Input`; `value` + `onChange(html)` (or `outputFormat=\"json\"`); `toolbar={['bold', 'italic', 'link']}` to trim. Tell the user to **sanitize the HTML server-side**; never render it with `dangerouslySetInnerHTML` unsanitized.\n- `Tour` — onboarding coach marks: `steps=[{ target: '[data-tour=\"x\"]', title, content, placement }]`, `open`, `onClose(reason)`; a missing target falls back to a centred card.\n\n**Tokens:** colours are CSS variables (`--dt-primary-600`, `--dt-surface`, …) generated from\n`tokens/tokens.json`. Use semantic classes (`bg-primary-600`, `bg-surface`, `text-foreground`, `text-primary-foreground` on a primary band,\n`text-muted`, `border-line`, `text-micro`, `rounded-card`) — never hex values or Tailwind's raw\npalette (`bg-blue-500`). A product's brand lives in its own repo as `dreamtree.brand.json`;\n`npx @dreamtree-org/twreact-ui brand build --out app/brand.css` turns it into the CSS to load after\ntwreact-ui's CSS. To change a product's colours, edit that file, never the components.\nThe full rule set for screens is `DESIGN.md` in the package root.\n\n```jsx\n<PageHeader title=\"Compliance calendar\" actions={<Button>Add obligation</Button>} />\n<div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <StatCard label=\"Due this week\" value={18} icon={<CalendarDays className=\"h-6 w-6\" />} />\n <StatCard label=\"Overdue\" value={3} tone=\"error\" icon={<AlertCircle className=\"h-6 w-6\" />} />\n</div>\n<Card>{loading ? <LoadingState /> : error ? <ErrorState onRetry={retry} /> : rows.length ? <Table … /> : <EmptyState title=\"No obligations yet\" />}</Card>\n```\n\n## Canonical examples\n\n### Button\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n\n<Button variant=\"primary\" size=\"md\" onClick={save}>Save</Button>\n<Button variant=\"destructive\" loading>Deleting...</Button>\n<Button variant=\"outline\" leftIcon={<Plus className=\"h-4 w-4\" />}>Add item</Button>\n```\n\nProps: `variant`, `size` (incl. `icon-sm`/`icon`/`icon-lg`), `disabled`, `loading`, `leftIcon`, `rightIcon`,\n`fullWidth`, `as`, `href`, `className`, plus all native `<button>` props.\n\n### Input + Form\n\n```jsx\nimport { Input } from '@dreamtree-org/twreact-ui';\n\n<Input\n type=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n required\n clearable\n error={errors.email?.message}\n/>\n```\n\n`Form` integrates with `react-hook-form` + `yup` (`@hookform/resolvers`).\nValidation rules live in consumer code.\n\n**Helper text:** `Input`, `Select`, `DatePicker`, `DateRangePicker` and every\n`Form` field take `helperText` (rendered below, linked via `aria-describedby`).\nWhile `error` is set it **replaces** the helper text — don't render both. Don't\nhand-roll a `<p>` hint under a field.\n\n```jsx\n<Select label=\"Role\" helperText=\"Controls access.\" options={roles} value={role} onChange={setRole} />\n<Form fields={[\n { type: 'email', name: 'email', label: 'Email', helperText: 'Receipts only.' },\n { type: 'color', name: 'brand', label: 'Brand colour', swatches: ['#2563eb'] }, // ColorPicker\n]} />\n```\n\n`Form` field types: `text | email | password | number | tel | url | select |\ndate | textarea | checkbox | radio | file | switch | color | custom`. Use\n`type: 'color'` rather than a `custom` field wrapping `ColorPicker`.\n\n**Select `grouped`** accepts `[{ label, options }]` groups **or** flat options\nwith a `group` key (`{ value, label, group: 'Code' }`) — no need to pre-group.\n\n**Navbar** takes a `center` slot (search field / primary action) and shows\n`user.name` initials when `user.avatar` is missing; it fits a 390px phone.\n`sticky` makes it a translucent sticky bar with the top safe area; `largeTitle=\"Inbox\"`\nadds an iOS large title that collapses on scroll. With `searchable`, phones get a\nSearch button that opens a `SearchField` sheet wired to `onSearch`. When the left\nbutton opens your Sidebar drawer (`leftIcon={<Menu />}`), name it with\n`leftIconLabel=\"Open navigation\"` (default \"Go back\") and pass\n`leftIconProps={{ 'aria-expanded': open, 'aria-controls': 'app-sidebar' }}`.\n\n**Select / DatePicker / DateRangePicker** show their placeholder muted (`text-muted`)\nand set `data-empty` on the trigger while empty — don't add a muted `className` yourself.\n\n**Tooltip** around a component (e.g. `<Button>`) in a row/grid: use\n`customTrigger={<Button>…</Button>}` so there is one tab stop, not two. A lone\nnative `<button>` / `<a href>` / `<input>` child is detected automatically.\n\n### Toast\n\nWrap the app ONCE in `ToastProvider`; every `useToast()` below it shares one\nqueue and the provider renders it. (Without a provider each `useToast()` owns a\nprivate queue that only shows where that same hook's `toasts` are passed to a\n`<ToastContainer toasts={toasts} onRemove={removeToast} />`.)\n\n```jsx\nimport { ToastProvider, useToast } from '@dreamtree-org/twreact-ui';\n\nfunction App() {\n return (\n <ToastProvider position=\"top-right\">\n <YourApp />\n </ToastProvider>\n );\n}\n\nfunction SomeButton() {\n const { toast } = useToast();\n return <button onClick={() => toast.success('Saved!')}>Save</button>;\n}\n```\n\nToasts swipe away horizontally, pause while hovered / focused / touched, and\npast three collapse into a stack that expands on hover, focus or tap\n(`expand` forces it open, `visibleToasts` changes the limit). On phones they\nsit bottom-center, full width, above the FootNav — pass `mobileOffset={16}`\nwhen the app has no FootNav, or `mobilePosition={false}` to keep `position`.\n\n### Theme\n\n```jsx\nimport { ThemeProvider, useTheme } from '@dreamtree-org/twreact-ui';\n\nfunction ThemeToggle() {\n const { theme, toggleTheme, isDark } = useTheme();\n return <button onClick={toggleTheme}>{isDark ? '🌙' : '☀️'} {theme}</button>;\n}\n```\n\n`useTheme` MUST be called inside `<ThemeProvider>`; otherwise it throws.\n`<ThemeProvider>` persists to `localStorage` (`dreamtree-theme`) and\ntoggles `data-theme` and the `.dark` class on `<html>`.\n\n### useApi\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n\nconst { data, error, loading, fetchData, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap: {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n },\n});\n\nuseEffect(() => { fetchData('/users', 'GET'); }, []);\nfetchApi('getUser', 7); // apiMap.getUser(7) — ONE argument\nfetchApiWith('updateUser', [7, { name: 'A' }]); // apiMap.updateUser(7, {...}) — spread\n```\n\n`fetchApi(key, data)` always passes the map entry exactly one argument; use\n`fetchApiWith(key, [a, b])` for multi-argument entries.\n\nFor session expiry pass `onUnauthorized: (error, { apiKey, url, method }) => …`.\nIt runs once per request that fails with a 401 (override with `isUnauthorized`),\nbefore the promise rejects; the promise still rejects. Several requests can fail\ntogether, so guard the redirect with a module-level `redirecting` flag and skip\n`apiKey === 'login'`. Don't hand-roll an axios interceptor for this.\n\nTreat `useApi` as a thin axios wrapper, not a caching/dedup layer. For\ncache, the consumer brings their own (React Query, SWR, etc.).\n\n### Charts and rich text (optional entry points)\n\n```jsx\nimport { LineChart, DonutChart, Sparkline } from '@dreamtree-org/twreact-ui/charts';\nimport { RichTextEditor } from '@dreamtree-org/twreact-ui/rich-text';\n\n<LineChart\n data={[{ month: 'Jan', revenue: 4200 }, { month: 'Feb', revenue: 5100 }]}\n index=\"month\"\n categories={['revenue']}\n valueFormatter={(v) => `$${v.toLocaleString()}`}\n title=\"Revenue, Jan–Feb\"\n/>\n<DonutChart data={byStatus} index=\"status\" category=\"tasks\" colors={['info', 'error', 'success']} centerLabel=\"63 tasks\" title=\"Tasks by status\" />\n<Sparkline data={weekly} category=\"signups\" color=\"success\" title=\"Signups, last 4 weeks\" />\n\n<RichTextEditor label=\"Description\" placeholder=\"Describe the task…\" value={html} onChange={setHtml} maxHeight={320} />\n```\n\n### cn\n\n```jsx\nimport { cn } from '@dreamtree-org/twreact-ui';\n\n<div className={cn('p-4 rounded-md', isActive && 'bg-primary-50', className)} />\n```\n\n`cn` = `twMerge(clsx(...))` — later classes win on Tailwind conflicts.\n\n## Rules for AI assistants helping consumers\n\n1. **Use library components.** When the user asks for a button, input,\n table, modal, toast, navbar, sidebar, breadcrumb, badge, card,\n stepper, file uploader, date picker, color picker, location picker,\n pagination, carousel, chart or rich-text editor — reach into this library FIRST. Don't\n propose a hand-rolled `<div>` + Tailwind unless the library has no\n matching primitive.\n2. **Use the shared vocabulary.** `variant=\"primary\"`, `size=\"md\"`,\n `leftIcon={...}`, `fullWidth`, `clearable` — these are exact names.\n Don't invent `color=\"blue\"` or `iconLeft={...}`. Icon slots are\n `leftIcon` / `rightIcon` on `Button`, `Input` and `Tabs.Tab` (2.0 renamed\n Input `icon`/`iconPosition` and Tab `iconLeft`/`iconRight`).\n3. **Wrap in `<ThemeProvider>`.** Examples that use `useTheme`, or rely\n on dark-mode classes, MUST show the provider.\n4. **`<StoreProvider>` is optional.** Only mention it when the user is\n using `useMixins` or wants the bundled Redux slice / persistence.\n Don't insist on it for every example.\n5. **Tailwind content[] config.** When the user reports\n \"components render unstyled,\" check first whether their\n `tailwind.config.js` `content[]` includes\n `./node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}`.\n6. **Don't deep-import.** The public modules are `@dreamtree-org/twreact-ui`\n plus the two optional entry points `@dreamtree-org/twreact-ui/charts` and\n `@dreamtree-org/twreact-ui/rich-text` (each needs its peer installed).\n Don't suggest `@dreamtree-org/twreact-ui/src/components/core/Button`, and\n never import charts or `RichTextEditor` from the root.\n7. **React 18 only.** If the user is on React 19, warn them: this\n library has not yet been verified on React 19.\n8. **Accessibility-first.** Icon-only buttons need `aria-label`.\n Dialogs need a focus trap (the library provides it). Don't strip\n `focus:ring-*` utilities.\n9. **Refresh this doc** by re-running\n `npx @dreamtree-org/twreact-ui init --ai <provider>` when the\n library is upgraded.\n"
6793
7264
  }