@astherix/ui 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,933 @@
1
+ # @astherix/ui
2
+
3
+ Tailwind v4 + React components for Next.js and Laravel (React + Inertia) apps.
4
+
5
+ ## Layout
6
+
7
+ ```
8
+ packages/ui/
9
+ theme.css Tokens (colors, font, radius), light/dark themes
10
+ src/lib/cn.ts Class merging helper
11
+ src/components/activity/ ActivityIndicator, ProgressBar, ProgressRing, Skeleton, ActivitySteps, LoadingOverlay
12
+ src/components/avatar/ Avatar, AvatarGroup, AvatarLabel, AvatarUpload
13
+ src/components/button/ Button
14
+ src/components/card/ Card, ChoiceCard
15
+ src/components/editor/ Editor (entry: @astherix/ui/editor)
16
+ src/components/input/ Field, Input, Textarea
17
+ src/components/menu/ DropdownMenu, SplitButton
18
+ src/components/modal/ Modal
19
+ src/components/pill/ Pill, PillGroup, PillOption
20
+ src/components/choice/ Checkbox, CheckboxGroup, RadioGroup, Radio, Switch
21
+ src/components/select/ Select
22
+ src/components/sidebar/ SidebarProvider, Sidebar, SidebarNav, SidebarTrigger …
23
+ src/components/table/ DataTable
24
+ src/components/tabs/ Tabs
25
+ src/components/timeline/ Timeline, Roadmap
26
+ src/components/upload/ FileDropzone, FileInput, FileUploadButton, useFileUploads, xhrUpload
27
+ src/components/toast/ Toaster, toast
28
+ src/components/typography/ Heading, Text, Lead, Link, Code, Kbd, Mark, Blockquote, List, Prose, Stat
29
+ docs/ User guide
30
+ index.html Built guide — open it in any browser, works offline
31
+ src/main.tsx Navigation and page list
32
+ src/pages/ One page per component (Getting started, Button, Modal…)
33
+ src/components/Doc.tsx Shared building blocks: Section, Code, PageHeader
34
+ ```
35
+
36
+ ## Develop
37
+
38
+ ```
39
+ npm install
40
+ npm run build # builds packages/ui/dist
41
+ npm run typecheck
42
+ npm run docs # rebuilds docs/index.html
43
+ ```
44
+
45
+ ### Adding a component page to the guide
46
+
47
+ 1. Create `docs/src/pages/InputPage.tsx` using `Section` blocks (demo + code example).
48
+ 2. Add it to the `pages` list in `docs/src/main.tsx`.
49
+ 3. Run `npm run docs`.
50
+
51
+ ## Use in an app
52
+
53
+ Until the package is published, link it locally (`npm install ../ui-framework/packages/ui`)
54
+ or add it to the same workspace.
55
+
56
+ **1. Stylesheet** (`app/globals.css` in Next.js, `resources/css/app.css` in Laravel):
57
+
58
+ ```css
59
+ @import "tailwindcss";
60
+ @import "@astherix/ui/theme.css";
61
+ @source "../node_modules/@astherix/ui/dist"; /* adjust the path relative to this file */
62
+ ```
63
+
64
+ **2. Font — Schibsted Grotesk**
65
+
66
+ Next.js (`app/layout.tsx`):
67
+
68
+ ```tsx
69
+ import { Schibsted_Grotesk } from "next/font/google";
70
+ const font = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-schibsted" });
71
+ // <html className={font.variable}>
72
+ ```
73
+
74
+ Then in your stylesheet, after the theme import:
75
+
76
+ ```css
77
+ @theme { --font-sans: var(--font-schibsted), ui-sans-serif, system-ui, sans-serif; }
78
+ ```
79
+
80
+ Laravel (`resources/views/app.blade.php`, inside `<head>`):
81
+
82
+ ```html
83
+ <link rel="preconnect" href="https://fonts.bunny.net">
84
+ <link href="https://fonts.bunny.net/css?family=schibsted-grotesk:400,500,600,700" rel="stylesheet">
85
+ ```
86
+
87
+ **3. Dark mode** — add the `dark` class to `<html>` (next-themes with `attribute="class"`,
88
+ or the appearance handling in Laravel's starter kit).
89
+
90
+ ## Accordion
91
+
92
+ ```tsx
93
+ <Accordion type="single" collapsible defaultValue="refunds" variant="outline" linkToHash>
94
+ <AccordionItem value="refunds" title="How do refunds work?">…</AccordionItem>
95
+ </Accordion>
96
+
97
+ <AccordionItem value="shipping" title="Delivery" status="complete" summary="Express · ₱350">…</AccordionItem>
98
+ <AccordionItem value="reminders" title="Reminders" action={<Switch … />}>…</AccordionItem>
99
+ ```
100
+
101
+ `Accordion`: `type` (`single` `multiple`), `value` / `defaultValue` / `onValueChange`, `collapsible`,
102
+ `variant` (`separated` `outline` `flush`), `size`, `headingLevel`, `linkToHash`. `AccordionItem`:
103
+ `value`, `title`, `description`, `summary` (while closed), `icon`, `meta`, `action` (header
104
+ controls), `status` (`complete` `current` `error` `locked`), `disabled`, `keepMounted`.
105
+ `AccordionToggleAll`, `useAccordion()` → `{ open, toggle, expandAll, collapseAll }`. Closed
106
+ sections use `hidden="until-found"`, so Ctrl/⌘+F finds and opens them (Chrome/Edge).
107
+
108
+ ## Activity indicators
109
+
110
+ ```tsx
111
+ <ActivityIndicator variant="spinner" | "dots" | "bars" | "pulse" | "orbit" label="Loading invoices" showLabel />
112
+ <ProgressBar label="Uploading" value={pct} showValue striped />
113
+ <ProgressRing value={72} size="xl" label="Storage used" />
114
+ <Skeleton shape="text" lines={3} />
115
+ <ActivitySteps steps={[{ label: "Build", status: "done" }, { label: "Deploy", status: "active" }]} />
116
+ <LoadingOverlay loading={refreshing} label="Refreshing">…</LoadingOverlay>
117
+ ```
118
+
119
+ `ActivityIndicator`: `variant`, `size` (`xs`–`xl`), `tone` (`primary` `neutral` `current` `success`
120
+ `warning` `danger` `info`), `label`, `showLabel`, `labelPosition`. `ProgressBar`: `value` (omit for
121
+ indeterminate), `max`, `label`, `showValue`, `valueText`, `size`, `tone`, `striped`.
122
+ `ProgressRing`: `value`, `max`, `size` (`sm`–`xl`), `tone`, `label`, `valueText`, center `children`.
123
+ `Skeleton`: `shape` (`rect` `circle` `text`), `lines`. `ActivitySteps`: `steps` (`label`,
124
+ `description`, `status`: `done` `active` `pending` `error` `skipped`, `meta`), `orientation`,
125
+ `size`. `LoadingOverlay`: `loading`, `label`, `variant`, `delay`.
126
+
127
+ ## Avatar
128
+
129
+ ```tsx
130
+ <Avatar src={user.avatarUrl} name="Maria Santos" status="online" />
131
+ <Avatar name="Dan Torres" /> {/* initials, color from the name */}
132
+ <Avatar shape="square" icon="bi bi-building" name="Northwind" />
133
+ <AvatarGroup max={5} onOverflowClick={openMembers}>{…}</AvatarGroup>
134
+ <AvatarLabel src={…} name="Maria Santos" description="Product designer" end={<Pill>Admin</Pill>} />
135
+ <AvatarUpload name={user.name} value={user.avatarUrl} onChange={setFile} inputName="avatar" />
136
+ ```
137
+
138
+ `Avatar`: `src`, `name`, `size` (`xs` `sm` `md` `lg` `xl` `2xl`), `shape` (`circle` `square`),
139
+ `status` (`online` `away` `busy` `offline`), `icon`, `badge`, `ring`, `decorative`, `fallback`.
140
+ `AvatarGroup`: `size`, `shape`, `max`, `spacing`, `onOverflowClick`. `AvatarLabel`: Avatar props
141
+ + `description`, `end`. `AvatarUpload`: `value`, `onChange`, `name`, `size`, `shape`,
142
+ `maxSizeMB`, `accept`, `inputName`, `disabled`. `getInitials(name)` is exported too.
143
+
144
+ ## Button
145
+
146
+ ```tsx
147
+ import { Button } from "@astherix/ui";
148
+
149
+ <Button>Save changes</Button>
150
+ <Button variant="secondary" leadingIcon={<DownloadIcon />}>Export CSV</Button>
151
+ <Button variant="danger" loading={isDeleting}>Delete project</Button>
152
+ <Button variant="ghost" iconOnly aria-label="More options"><MoreIcon /></Button>
153
+
154
+ // Links keep button styles with asChild
155
+ <Button asChild><Link href="/billing">Open billing</Link></Button>
156
+ ```
157
+
158
+ | Prop | Values | Default |
159
+ | -------------- | ----------------------------------------------- | ----------- |
160
+ | `variant` | `primary` `secondary` `ghost` `danger` | `primary` |
161
+ | `size` | `sm` `md` `lg` | `md` |
162
+ | `rounded` | `none` `sm` `md` `lg` `full` | `md` |
163
+ | `raised` | boolean — darker bottom edge + press effect | `true` |
164
+ | `shadow` | `none` `sm` `md` `lg` `xl` | `none` |
165
+ | `iconOnly` | boolean (needs `aria-label`) | `false` |
166
+ | `fullWidth` | boolean | `false` |
167
+ | `loading` | boolean — show the spinner on demand | `false` |
168
+ | `spinnerPlacement` | `center` `start` `end` | `center` |
169
+ | `loadingIndicator` | `spinner` `orbit` `progress` (both experimental) | `spinner` |
170
+ | `progressStyle` | `fill` `bar` (with `progress` indicator) | `fill` |
171
+ | `progress` | 0–100 real progress; omit for automatic | |
172
+ | `loadingLabel` | label while loading, e.g. "Saving…" (start/end) | |
173
+ | `minLoadingTime` | ms the spinner stays visible at minimum | `0` |
174
+ | `onClick` | may return a Promise → spinner until it settles | |
175
+ | `leadingIcon` | element or icon-font class string | |
176
+ | `trailingIcon` | element or icon-font class string | |
177
+ | `icon` | icon for `iconOnly` buttons (same options) | |
178
+ | `asChild` | boolean — render the child (e.g. a Link) | `false` |
179
+
180
+ ### Corners
181
+
182
+ ```tsx
183
+ <Button rounded="sm">Slightly rounded</Button>
184
+ <Button rounded="full">Pill</Button>
185
+ <Button iconOnly rounded="full" icon="bi bi-plus-lg" aria-label="Add" /> // circle
186
+ ```
187
+
188
+ Change the default for a whole app with `--radius-control` (and `--radius-control-sm`,
189
+ `--radius-control-lg`) in your stylesheet.
190
+
191
+ ### Raised edge and shadows
192
+
193
+ ```tsx
194
+ <Button raised={false}>Flat</Button>
195
+ <Button shadow="lg">Raised with a large shadow</Button>
196
+ <Button raised={false} shadow="xl" iconOnly rounded="full" icon="bi bi-plus-lg" aria-label="Create" />
197
+ ```
198
+
199
+ Shadow weights are theme tokens (`--ui-shadow-sm` … `--ui-shadow-xl`, and
200
+ `--ui-shadow-color` for the tint), with separate values for dark mode.
201
+
202
+ ### Loading (Ladda-style)
203
+
204
+ Return a Promise from `onClick` and the spinner shows until the response arrives,
205
+ whether the request succeeds or fails:
206
+
207
+ ```tsx
208
+ <Button onClick={() => fetch("/api/report", { method: "POST" })}>Generate report</Button>
209
+ <Button spinnerPlacement="start" loadingLabel="Saving…" onClick={() => axios.put("/profile", data)}>
210
+ Save profile
211
+ </Button>
212
+ ```
213
+
214
+ Or control it yourself with `loading`:
215
+
216
+ ```tsx
217
+ <Button type="submit" loading={form.processing}>Save</Button> // Inertia useForm
218
+ <Button loading={isPending} onClick={() => startTransition(save)}>Save</Button> // Next.js
219
+ ```
220
+
221
+ While loading, the button stays focusable, is marked `aria-busy`, and ignores
222
+ further clicks (so forms can't be submitted twice).
223
+
224
+ ### Running light (experimental)
225
+
226
+ ```tsx
227
+ <Button loadingIndicator="orbit" loadingLabel="Saving…" onClick={save}>Save profile</Button>
228
+ ```
229
+
230
+ A light runs around the button's edge while loading; the label stays visible. Tune it with
231
+ `--ui-orbit-color`, `--ui-orbit-width` and `--ui-orbit-speed`. It needs CSS `@property`
232
+ (current Chrome, Edge, Safari and Firefox); older browsers show a still light.
233
+
234
+ ### Progress (experimental)
235
+
236
+ ```tsx
237
+ <Button loadingIndicator="progress" onClick={generate}>Generate report</Button>
238
+ <Button loadingIndicator="progress" progressStyle="bar" loading={uploading} progress={pct}>
239
+ Upload
240
+ </Button>
241
+ ```
242
+
243
+ `fill` sweeps a translucent fill across the button; `bar` runs a thin bar along the bottom.
244
+ Without `progress` it creeps toward ~92% and completes when loading ends. Tune with
245
+ `--ui-progress-fill`, `--ui-progress-bar`, `--ui-progress-height`, `--ui-progress-duration`.
246
+
247
+ ### Icon fonts (Bootstrap Icons, Font Awesome, Glyphicons)
248
+
249
+ Pass the icon's class string and the Button renders `<i class="…" aria-hidden="true">`,
250
+ sized to match the label:
251
+
252
+ ```tsx
253
+ <Button leadingIcon="bi bi-cloud-arrow-up">Upload files</Button>
254
+ <Button variant="secondary" trailingIcon="bi bi-chevron-down">Sort by date</Button>
255
+ <Button iconOnly icon="bi bi-gear" aria-label="Settings" />
256
+ <Button leadingIcon="glyphicon glyphicon-print">Print</Button>
257
+ ```
258
+
259
+ Load the icon font's CSS once in each app, e.g. `npm install bootstrap-icons`, then
260
+ `import "bootstrap-icons/font/bootstrap-icons.css"` in Next.js `app/layout.tsx`
261
+ or Laravel `resources/js/app.tsx`.
262
+
263
+ `type` defaults to `"button"`; set `type="submit"` on form submit buttons.
264
+
265
+ ## Carousel
266
+
267
+ ```tsx
268
+ <Carousel aria-label="Photos" indicators="thumbnails">
269
+ <CarouselSlide label="Sagada" thumbnail={<img … />}><img … /></CarouselSlide>
270
+ </Carousel>
271
+
272
+ <Carousel aria-label="Plans" effect="coverflow" slidesPerView={{ base: 1.3, md: 2.4 }} defaultIndex={1} />
273
+ <Carousel aria-label="Highlights" indicators="stories" autoplay={4000} loop />
274
+ ```
275
+
276
+ Native scroll snapping (real swipe momentum, trackpads, no scroll-jacking) plus mouse dragging
277
+ and keyboard. `Carousel`: `slidesPerView` (number or `{ base, sm, md, lg }` by container width;
278
+ fractions peek), `gap`, `peek`, `align` (`start` `center`), `effect` (`coverflow`), `loop`,
279
+ `autoplay` (ms; pauses on hover/touch/focus, hidden tab or off-screen; off with reduced motion;
280
+ pause button), `indicators` (`dots` `counter` `stories` `thumbnails` `none`), `controls`
281
+ (`overlay` `below` `none`), `defaultIndex`, `onSlideChange`; ref → `{ next, prev, goTo, index }`.
282
+ `CarouselSlide`: `label`, `thumbnail`.
283
+
284
+ ## Checkbox, radio & switch
285
+
286
+ ```tsx
287
+ <Checkbox label="Attach PDF" description="Adds the invoice as an attachment." />
288
+ <Checkbox label="All clients" checked={all} indeterminate={some && !all} onCheckedChange={toggleAll} />
289
+ <CheckboxGroup name="methods[]" options={[{ value: "card", label: "Card" }, …]} defaultValue={["card"]} />
290
+ <RadioGroup label="Send" defaultValue="now"><Radio value="now" label="Now" /><Radio value="draft" label="Save as draft" /></RadioGroup>
291
+ <Switch label="Weekly summary" onCheckedChange={(on) => api.save(on)} /> {/* spinner; flips back on failure */}
292
+ ```
293
+
294
+ Real inputs underneath (keyboard, screen readers and form posts work natively). `Checkbox`:
295
+ `label`, `description`, `indeterminate`, `onCheckedChange`, `invalid`, `size` (`sm` `md`) + input
296
+ props. `CheckboxGroup`: `label`, `options`, `value` / `defaultValue` / `onValueChange`, `name`,
297
+ `orientation`. `RadioGroup`: `label`, `value` / `defaultValue` / `onValueChange`, `name`,
298
+ `orientation`, `size`, `disabled`, `invalid`, `required`; `Radio`: `value`, `label`, `description`.
299
+ `Switch`: `color` (`primary` `secondary` `tertiary` `success` `warning` `danger` `info` or any CSS
300
+ color; tokens `--ui-tone-secondary` / `--ui-tone-tertiary` + `-fg`), `colorForeground`, `variant` (`labelled` — the word in the pill, `mark` — × / ✓ on the knob, `liquid` —
301
+ blue floods from the knob), `label`, `description`, `onCheckedChange` (may return a Promise:
302
+ spinner, flips back on failure), `size`, `labelPosition`, `onLabel`, `offLabel`.
303
+
304
+ ## Alert
305
+
306
+ A message that stays on the page. (For one that appears and leaves on its own, use Toast.)
307
+
308
+ ```tsx
309
+ <Alert tone="success" title="Invoice sent to Northwind Traders" />
310
+ <Alert tone="warning" variant="accent" title="Your card expires this month" dismissible onDismiss={hide}
311
+ actions={<Button size="sm">Update card</Button>}>
312
+ Update it before 30 September so invoices keep going out.
313
+ </Alert>
314
+ <Alert tone="danger" title="We couldn't save this invoice" items={Object.values(errors)} />
315
+ <Alert banner size="sm" tone="warning" title="Your trial ends in 3 days" />
316
+ ```
317
+
318
+ `tone` (`info` `success` `warning` `danger` `neutral`), `variant` (`soft` `outline` `accent`
319
+ `solid`), `title`, `children`, `items` (a bulleted summary — the shape Laravel's validation errors
320
+ arrive in), `actions`, `icon` (your own, or `false`), `dismissible` + `onDismiss`, `autoDismiss`
321
+ (ms; pauses on hover or focus), `size`, `banner` (edge to edge, square corners), `live`
322
+ (`auto` announces danger and warning immediately, everything else politely; `off` stays quiet).
323
+ Dismissal fades and collapses the space instead of snapping the page up.
324
+
325
+ ## Toast
326
+
327
+ ```tsx
328
+ <Toaster position="bottom-right" /> {/* once, near the root */}
329
+
330
+ toast("Draft saved");
331
+ toast.success("Invoice sent", { description: "…" });
332
+ toast.promise(api.send(id), { loading: "Sending…", success: "Sent", error: (e) => e.message });
333
+ toast("Invoice deleted", { action: { label: "Undo", onClick: restore } });
334
+ ```
335
+
336
+ `toast(title, options)` and `toast.success / error / warning / info / loading / promise / dismiss`.
337
+ Options: `id` (update in place), `description`, `action`, `duration`, `icon`, `dismissible`,
338
+ `onDismiss`. `Toaster`: `position`, `expand`, `visibleToasts`, `richColors`, `showTimer`, `hotkey`.
339
+ Stacks and spreads on hover, pauses while hovered/focused/hidden, swipe or Escape to dismiss.
340
+
341
+ ## File upload
342
+
343
+ ```tsx
344
+ <FileDropzone upload={xhrUpload("/api/receipts")} accept="image/*,.pdf" maxSize={10 * 1024 * 1024} maxFiles={6} />
345
+ <FileUploadButton upload={xhrUpload("/api/contracts")} accept=".pdf">Upload contract</FileUploadButton>
346
+ <FileInput name="resume" accept=".pdf" maxSize={5 * 1024 * 1024} /> {/* posts with the form */}
347
+ ```
348
+
349
+ `FileDropzone`: `upload`, `accept`, `maxSize`, `maxFiles`, `multiple`, `concurrency`, `onChange`,
350
+ `onUploaded`, `title`, `hint`, `name`, `disabled`, `pasteable`, `size`. `FileUploadButton`: Button
351
+ props + `upload`, `accept`, `maxSize`, `onUploaded`, `onRemove`, `completeLabel`. `FileInput`:
352
+ native file-input props + `onFilesChange`, `buttonLabel`, `placeholder`, `size`, `rounded`,
353
+ `invalid`, `maxSize`, `clearable`. `xhrUpload(url, { fieldName, method, headers, data,
354
+ withCredentials, xsrfCookie })` gives real progress and reads Laravel's XSRF cookie and
355
+ validation errors. `useFileUploads(…)` and `FileList` for custom layouts.
356
+
357
+ ## Field, Input and Textarea
358
+
359
+ ```tsx
360
+ import { Field, Input, Textarea } from "@astherix/ui";
361
+
362
+ <Field label="Email" description="We'll send receipts here." error={form.errors.email} required>
363
+ <Input type="email" leadingIcon="bi bi-envelope" />
364
+ </Field>
365
+
366
+ <Field label="Message">
367
+ <Textarea autoResize minRows={3} maxRows={8} showCount maxLength={500} />
368
+ </Field>
369
+ ```
370
+
371
+ **Field** — `label`, `description`, `error` (marks the control invalid), `required`, `optional`,
372
+ `disabled`, `id`. Wires the label, `aria-describedby` and `aria-invalid` for you. Custom
373
+ controls can read it with `useField()`.
374
+
375
+ **Input** — `size` (`sm` `md` `lg`, same heights as Button), `rounded` (`none` … `full`),
376
+ `leadingIcon` / `trailingIcon` (element or icon-font class), `prefix` / `suffix` text,
377
+ `invalid`, `clearable` + `onClear`, `revealable` (password show/hide, default on),
378
+ `frameClassName`. All native input props work; `ref` goes to the `<input>`.
379
+
380
+ **Textarea** — `size`, `rounded` (`none` … `lg`), `invalid`, `autoResize` with `minRows` /
381
+ `maxRows`, `showCount` (with `maxLength` shows "12 / 280"), `resize` (`vertical` `none`),
382
+ `frameClassName`.
383
+
384
+ ## Pagination
385
+
386
+ ```tsx
387
+ <Pagination page={page} pageCount={12} onPageChange={setPage} />
388
+ <Pagination page={p.current_page} pageCount={p.last_page} total={p.total} pageSize={p.per_page}
389
+ getHref={(n) => `${p.path}?page=${n}`} linkComponent={Link} showSummary />
390
+ <LoadMore loaded={items.length} total={total} onLoadMore={fetchNext} auto />
391
+ ```
392
+
393
+ `Pagination`: `page`, `pageCount` or `total` + `pageSize`, `onPageChange`, `getHref` +
394
+ `linkComponent` (real links), `siblingCount`, `boundaryCount`, `showFirstLast`, `variant`
395
+ (`numbers` `simple`), `responsive` (fits its own width), `showSummary`, `pageSizeOptions` +
396
+ `onPageSizeChange`, `showJump`, `size`, `labels`. Gaps jump five pages; the highlight slides.
397
+ `LoadMore`: `loaded`, `total`, `onLoadMore` (Promise → progress), `hasMore`, `auto` (infinite
398
+ scroll), `label`. `getPageItems(page, count, siblings, boundaries)` is exported too.
399
+
400
+ ## Pills
401
+
402
+ ```tsx
403
+ <Pill tone="success" dot>Paid</Pill>
404
+ <Pill tone="danger" appearance="solid">Overdue</Pill>
405
+ <Pill size="lg" onRemove={() => removeTag(tag)}>{tag}</Pill>
406
+ <Pill asChild tone="primary"><Link href="/topics/laravel">Laravel</Link></Pill>
407
+
408
+ <PillGroup value={category} onValueChange={setCategory} aria-label="Category">
409
+ <PillOption value="all" count={5}>All</PillOption>
410
+ <PillOption value="design">Design</PillOption>
411
+ </PillGroup>
412
+ <PillGroup type="multiple" value={tags} onValueChange={setTags}>…</PillGroup>
413
+ ```
414
+
415
+ `Pill`: `tone` (`neutral` `primary` `success` `warning` `danger` `info`), `appearance`
416
+ (`soft` `solid` `outline`), `size` (`sm` `md` `lg`), `icon`, `dot` (`true` or `"pulse"`),
417
+ `count`, `onRemove`, `removeLabel`, `asChild`. `PillGroup`: `type` (`single` `multiple`),
418
+ `value` / `defaultValue` / `onValueChange`, `allowDeselect`, `size`, `tone`, `showCheck`,
419
+ `disabled`. `PillOption`: `value`, `icon`, `count`, `disabled`.
420
+ Status colors are theme tokens: `--ui-success`, `--ui-warning`, `--ui-info` (+ `-fg`).
421
+
422
+ ## Autocomplete
423
+
424
+ A text field that suggests as you type — the value is free text (use Select when it must be from a list).
425
+
426
+ ```tsx
427
+ <Autocomplete value={city} onChange={setCity} suggestions={cities} />
428
+ <Autocomplete loadSuggestions={(q, { signal }) => api.searchClients(q, signal)} minLength={2} />
429
+ <Autocomplete type="search" recentKey="recent-searches" onSubmit={runSearch} suggestions={items} />
430
+ ```
431
+
432
+ Props (plus Input's: `size`, `rounded`, icons, `clearable`, …): `value` / `defaultValue` / `onChange`
433
+ (string), `suggestions` (array of strings or `{ value, label, description, icon, group }`, or a
434
+ function of the query), `loadSuggestions`, `minLength`, `debounce`, `maxSuggestions`,
435
+ `inlineComplete` (faint completion; Tab/→ accepts), `onSelectSuggestion`, `onSubmit`, `recentKey`,
436
+ `maxRecent`, `emptyMessage`, `filter`.
437
+
438
+ ## Slider
439
+
440
+ ```tsx
441
+ <Slider value={fee} onChange={setFee} max={25} formatValue={(v) => `${v}%`} />
442
+ <Slider value={[5000, 60000]} onChange={setRange} max={100000} step={1000} minDistance={5000} />
443
+ <Slider min={1} max={10} marks color="secondary" onValueCommit={save} />
444
+ ```
445
+
446
+ Press the thumb and it becomes a frosted glass lens with a glass value bubble above the finger;
447
+ drag past an end and it stretches, then springs back; crossing marks gives a haptic tick on
448
+ supporting phones. Props: `value` / `defaultValue` (number, or `[low, high]` for a range),
449
+ `onChange`, `onValueCommit` (on release), `min`, `max`, `step`, `marks` (`true`, values, or
450
+ `{ value, label }`), `formatValue`, `showValue` (`active` `always` `never`), `color` (Switch
451
+ palette or any CSS color), `size` (`sm` `md` `lg`), `disabled`, `minDistance`, `haptics`, `name`,
452
+ `thumbLabels`.
453
+
454
+ ## Signature
455
+
456
+ ```tsx
457
+ const pad = useRef<SignaturePadHandle>(null);
458
+
459
+ <SignaturePad ref={pad} name="signature" allowTyped onChange={setSignature} />
460
+
461
+ pad.current.toDataURL({ trim: true, ink: "#111827", background: "#ffffff" }) // PNG
462
+ pad.current.toSVG({ trim: true, ink: "#111827" }) // vector, for PDFs
463
+ pad.current.clear(); pad.current.undo(); pad.current.isEmpty(); pad.current.fromDataURL(png);
464
+ ```
465
+
466
+ Ink thickens and thins with speed (and stylus pressure); strokes are kept as points, so it
467
+ redraws crisply on resize and undo works stroke by stroke. Props: `onChange` (PNG data URL or
468
+ null), `onBegin`, `onEnd`, `penColor`, `penWidth` `[min, max]`, `height`, `background`, `guide`,
469
+ `guideLabel`, `toolbar`, `actions`, `readOnly`, `defaultValue`, `name` (posts with the form),
470
+ `allowTyped` (a Type tab in a handwriting font — the accessible alternative), `typedFont`.
471
+ Export `ink` matters: the pen follows the theme, so force a dark color for people signing in
472
+ dark mode.
473
+
474
+ ## Select
475
+
476
+ A searchable dropdown in the spirit of Select2.
477
+
478
+ ```tsx
479
+ <Field label="Country">
480
+ <Select options={countries} value={country} onChange={setCountry} />
481
+ </Field>
482
+
483
+ <Select multiple options={skills} value={skills} onChange={setSkills} maxSelected={5} clearable />
484
+ <Select loadOptions={(q) => api.searchRepos(q)} minSearchLength={2} />
485
+ <Select multiple creatable options={labels} onCreateOption={(text) => api.createLabel(text)} />
486
+ ```
487
+
488
+ Options: `{ value, label, description?, icon?, group?, disabled?, keywords? }`.
489
+
490
+ Props: `options`, `value` / `defaultValue` / `onChange` (string | null, or string[] with
491
+ `multiple`), `multiple`, `maxSelected`, `searchable` (default true), `searchPlaceholder`,
492
+ `clearable`, `placeholder`, `loadOptions` + `minSearchLength` + `searchDelay`, `creatable` +
493
+ `onCreateOption`, `renderOption`, `noOptionsMessage`, `closeOnSelect`, `onOpenChange`,
494
+ `name` (hidden inputs for form posts), `size`, `rounded`, `invalid`, `disabled`, `required`.
495
+
496
+ Keyboard: Enter/Space/↓ open, type to search, ↑ ↓ Home End move, Enter picks, Backspace removes
497
+ the last chip, Escape closes (before a surrounding Modal), Tab closes and moves on.
498
+
499
+ ## Card
500
+
501
+ ```tsx
502
+ <Card variant="outline" | "elevated" | "filled" | "ghost" padding="md">
503
+ <CardMedia src={cover} ratio="16/9" />
504
+ <CardHeader title="Website refresh" description="Due 30 September" action={<Button …/>} />
505
+ <CardContent>…</CardContent>
506
+ <CardFooter divided justify="between">…</CardFooter>
507
+ </Card>
508
+
509
+ // Whole card clickable; other buttons inside still work
510
+ <Card interactive><CardHeader><CardTitle><CardLink href="/posts/1">Title</CardLink></CardTitle></CardHeader></Card>
511
+
512
+ // Choices with real radios / checkboxes
513
+ <ChoiceCardGroup value={plan} onValueChange={setPlan} name="plan">
514
+ <ChoiceCard value="team" title="Team" description="…" meta="₱990 / month" />
515
+ </ChoiceCardGroup>
516
+ ```
517
+
518
+ `Card`: `variant`, `tone` (`default` `primary` `danger`), `orientation` (`vertical` `horizontal`,
519
+ wrap text in `CardBody`), `interactive`, `padding` (`none` `sm` `md` `lg`), `asChild`.
520
+ `CardHeader`: `title`, `description`, `action`, `icon`, `titleLevel`. `CardFooter`: `divided`,
521
+ `justify`. `CardMedia`: `src`, `alt`, `ratio`, `inset`. `ChoiceCardGroup`: `type`, `value` /
522
+ `defaultValue` / `onValueChange`, `name`, `columns`, `disabled`, `invalid`, `required`.
523
+ `ChoiceCard`: `value`, `title`, `description`, `icon`, `meta`, `badge`, `disabled`.
524
+ Token: `--radius-card`.
525
+
526
+ ## Dashboard widgets
527
+
528
+ Dependency-free SVG widgets for KPI dashboards — one look, one palette (`--ui-chart-1…6`),
529
+ responsive, keyboard- and screen-reader-friendly.
530
+
531
+ ```tsx
532
+ <DashboardGrid columns={4}>
533
+ <KpiCard label="Revenue" value="₱318,400" change={8.2} sparkline={monthly} />
534
+ <WidgetCard data-span="3" title="Revenue" action={<PeriodPicker />} loading={isLoading}>
535
+ <AreaChart data={rows} index="month" series={[{ key: "invoiced" }, { key: "collected" }]}
536
+ referenceLine={{ value: 280000, label: "Target" }} aria-label="Revenue by month" />
537
+ </WidgetCard>
538
+ </DashboardGrid>
539
+ ```
540
+
541
+ - `KpiCard`: `label`, `value`, `change`, `changeLabel`, `invertTrend`, `icon`, `sparkline`,
542
+ `sparklineType`, `progress`, `href`, `loading`
543
+ - `LineChart` / `AreaChart` / `BarChart`: `data`, `index`, `series` (`key`, `label`, `color`),
544
+ `height`, `formatValue`, `formatIndex`, `showLegend`, `showGrid`, `referenceLine`; line: `curve`,
545
+ `dots`; bar: `stacked`. Crosshair tooltip, legend toggles, ← → to read points
546
+ - `DonutChart` (`data`, `size`, `thickness`, `centerLabel`, `legend`), `Gauge` (`value`, `max`,
547
+ `target`, `bands`), `BarList` (`items`, `limit`), `CalendarHeatmap` (`data`, `weeks`),
548
+ `Sparkline` (`data`, `type`)
549
+ - `WidgetCard`: `title`, `description`, `action`, `value`, `loading`, `empty`, `error`,
550
+ `onRetry`, `footer`, `live`; `DashboardGrid`: `columns`, `animate`, children use
551
+ `data-span="2|3|4|full"`; `LiveIndicator`: `updatedAt`, `paused`, `reconnecting`
552
+
553
+ **Animate on first load (optional):** `<DashboardGrid animate>` — widgets rise in one after another,
554
+ KPI numbers count up (pass `value` as a number with `formatValue`), charts draw in, donuts and
555
+ gauges sweep. Per-widget `animate` overrides it. Skipped with reduced motion.
556
+
557
+ **Live data:** just pass new data. Numbers roll and glow (green good / red bad), lines and bars
558
+ morph, donut and gauge sweep, BarList rows slide into their new order (`transition={false}` on a
559
+ chart turns it off). Works with polling, Laravel Echo (Reverb/Pusher) or Server-Sent Events.
560
+
561
+ ## Dropdown menu
562
+
563
+ ```tsx
564
+ <DropdownMenu>
565
+ <DropdownMenuTrigger asChild><Button trailingIcon="bi bi-chevron-down">Actions</Button></DropdownMenuTrigger>
566
+ <DropdownMenuContent align="start">
567
+ <DropdownMenuItem icon="bi bi-pencil" shortcut="⌘E" onSelect={edit}>Edit</DropdownMenuItem>
568
+ <DropdownMenuItem asChild><Link href="/settings">Settings</Link></DropdownMenuItem>
569
+ <DropdownMenuSeparator />
570
+ <DropdownMenuItem destructive onSelect={remove}>Delete</DropdownMenuItem>
571
+ </DropdownMenuContent>
572
+ </DropdownMenu>
573
+
574
+ <SplitButton onClick={save} menu={<DropdownMenuItem onSelect={saveDraft}>Save as draft</DropdownMenuItem>}>Save</SplitButton>
575
+ ```
576
+
577
+ `DropdownMenu`: `open` / `defaultOpen` / `onOpenChange`. `DropdownMenuContent`: `align`
578
+ (`start` `end`), `side` (`bottom` `top`, flips automatically), `matchTriggerWidth`.
579
+ `DropdownMenuItem`: `onSelect` (call `event.preventDefault()` to keep the menu open), `icon`,
580
+ `shortcut`, `description`, `destructive`, `disabled`, `inset`, `textValue`, `asChild`. Also
581
+ `DropdownMenuCheckboxItem` (`checked`, `onCheckedChange`), `DropdownMenuRadioGroup` +
582
+ `DropdownMenuRadioItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuGroup`.
583
+ `SplitButton`: Button props + `menu`, `menuLabel`, `align`.
584
+
585
+ ## Editor (rich text)
586
+
587
+ A basic WYSIWYG editor built on [Tiptap](https://tiptap.dev). It has its own entry point so
588
+ apps that don't use it don't load it:
589
+
590
+ ```tsx
591
+ import { Editor } from "@astherix/ui/editor";
592
+
593
+ <Field label="Release notes">
594
+ <Editor value={html} onChange={setHtml} />
595
+ </Field>
596
+
597
+ <Editor toolbar={["bold", "italic", "link", "|", "bulletList"]} maxLength={280} />
598
+ <Editor name="body" /> {/* posts the HTML with the form */}
599
+ <Editor readOnly value={post.body} />
600
+ ```
601
+
602
+ Props: `value` / `defaultValue` / `onChange` (HTML; empty editor gives `""`), `placeholder`,
603
+ `toolbar` (tools: `paragraph heading2 heading3 bold italic underline strike code link
604
+ bulletList orderedList blockquote horizontalRule undo redo`, `"|"` for a divider),
605
+ `maxLength`, `showCount`, `minHeight`, `maxHeight`, `rounded`, `invalid`, `disabled`,
606
+ `readOnly`, `name`, `autoFocus`, `onReady(editor)` for the Tiptap instance.
607
+
608
+ Show saved HTML with the same styles: `<div className="ui-prose" dangerouslySetInnerHTML={…} />`.
609
+ Always sanitize HTML on the server (e.g. `stevebauman/purify` in Laravel).
610
+
611
+ ## IconButton
612
+
613
+ The icon itself is the button. Press: the icon squishes and a ripple spreads; toggles swap to a
614
+ filled icon, pop and burst.
615
+
616
+ ```tsx
617
+ <IconButton icon="bi bi-heart" pressedIcon="bi bi-heart-fill" tone="danger" label="Like"
618
+ pressed={liked} onPressedChange={setLiked} />
619
+ <IconButton icon="bi bi-bell" label="Notifications" badge={3} />
620
+ <IconButton icon="bi bi-download" label="Download INV-1047" onClick={download} /> {/* Promise → spinner */}
621
+ ```
622
+
623
+ Props: `icon`, `label` (required; spoken and tooltip), `variant` (`plain` `soft` `solid`), `tone`
624
+ (Switch palette or any CSS color), `size` (`xs`–`xl`; small ones get a 44px tap area on touch),
625
+ `shape` (`circle` `square`), `pressed` / `defaultPressed` / `onPressedChange`, `pressedIcon`,
626
+ `burst`, `badge` (number or dot), `loading`, `href`, `tooltip`, `disabled`.
627
+
628
+ ## Modal
629
+
630
+ ```tsx
631
+ import { Modal, ModalTrigger, ModalContent, ModalHeader, ModalTitle,
632
+ ModalDescription, ModalBody, ModalFooter, ModalClose, useModal } from "@astherix/ui";
633
+
634
+ // Open with a button
635
+ <Modal>
636
+ <ModalTrigger asChild><Button>Rename project</Button></ModalTrigger>
637
+ <ModalContent size="sm">
638
+ <ModalHeader>
639
+ <ModalTitle>Rename project</ModalTitle>
640
+ <ModalDescription>The new name shows up for everyone.</ModalDescription>
641
+ </ModalHeader>
642
+ <ModalBody>…</ModalBody>
643
+ <ModalFooter>
644
+ <ModalClose asChild><Button variant="ghost">Cancel</Button></ModalClose>
645
+ <Button onClick={save}>Save name</Button>
646
+ </ModalFooter>
647
+ </ModalContent>
648
+ </Modal>
649
+
650
+ // Open from code
651
+ const session = useModal(); // { isOpen, open, close, toggle, modalProps }
652
+ <Modal {...session.modalProps}>…</Modal>
653
+ session.open();
654
+ ```
655
+
656
+ | Component / prop | Values | Default |
657
+ | -------------------------------- | ---------------------------------------- | ------- |
658
+ | `Modal` `open` / `onOpenChange` | controlled state | |
659
+ | `Modal` `defaultOpen` | boolean | `false` |
660
+ | `ModalContent` `size` | `sm` `md` `lg` `xl` `full` | `md` |
661
+ | `ModalContent` `backdrop` | `default` `dark` `blur` `solid` | `default` |
662
+ | `ModalContent` `dismissible` | Escape, backdrop and × close it | `true` |
663
+ | `ModalContent` `showCloseButton` | boolean | `true` |
664
+ | `ModalContent` `closeLabel` | label for × | `"Close"` |
665
+
666
+ Built on the native `<dialog>`: focus moves in on open (honours `autoFocus`) and returns
667
+ to the opener on close, Tab stays inside, the page behind is inert and doesn't scroll.
668
+ Theme tokens: `--radius-modal`, `--ui-shadow-modal`, `--ui-backdrop`, `--ui-backdrop-dark`,
669
+ `--ui-backdrop-blur`, `--ui-backdrop-blur-radius`, `--ui-backdrop-solid`.
670
+
671
+ ## Table (DataTable)
672
+
673
+ ```tsx
674
+ const columns: DataTableColumn<Invoice>[] = [
675
+ { key: "no", header: "Invoice", sortable: true, primary: true },
676
+ { key: "client", header: "Client", sortable: true, cell: (r) => r.client },
677
+ { key: "issued", header: "Issued", hideOnMobile: true },
678
+ { key: "amount", header: "Amount", sortable: true, align: "end", cell: (r) => peso(r.amount) },
679
+ ];
680
+
681
+ <DataTable caption="Invoices" data={rows} columns={columns} rowKey="id"
682
+ searchable selectable bulkActions={(rows, clear) => …} rowActions={(row) => <RowMenu row={row} />} />
683
+
684
+ // Server-side (e.g. a Laravel paginator)
685
+ <DataTable manual data={page.data} total={page.total} loading={loading} onQueryChange={fetchPage} … />
686
+ ```
687
+
688
+ Columns: `key`, `header`, `accessor`, `cell`, `sortable`, `sortFn`, `align`, `width`, `primary`
689
+ (card title on mobile), `hideOnMobile`, `searchable`, `className`.
690
+ Table: `caption` (required, accessible name), `showCaption`, `searchable`, `searchPlaceholder`,
691
+ `pageSize` (0 = all), `pageSizeOptions`, `defaultSort`, `selectable`, `selected` /
692
+ `onSelectedChange`, `bulkActions`, `rowActions`, `onRowClick`, `toolbar`, `loading`,
693
+ `refreshIndicator` (`border` `bar` `shimmer` `none`), `onRefresh` (adds a refresh button; runs
694
+ until its Promise settles), `status` (`true` for automatic messages, your own text, or
695
+ `(state) => message`), `statusTone`, `error` (shown in the status line with Retry),
696
+ `lastUpdated`, `loadingOverlay` (`true` or a message), `overlayPosition` (`top` `center`),
697
+ `overlayIndicator`, `onCancelLoading`, `emptyState`, `density` (`comfortable` `compact`), `striped`, `mobile` (`cards` `scroll`),
698
+ `maxHeight`, `manual`, `total`, `onQueryChange`.
699
+ Narrow containers (under 40rem) switch to cards via a container query, so it adapts inside
700
+ sidebars and cards as well as on phones.
701
+
702
+ ## Sidebar
703
+
704
+ ```tsx
705
+ <SidebarProvider side="left" linkComponent={Link} persistKey="sidebar">
706
+ <Sidebar>
707
+ <SidebarHeader><Logo /></SidebarHeader>
708
+ <SidebarContent>
709
+ <SidebarGroup label="Workspace">
710
+ <SidebarNav items={nav} activeHref={pathname} />
711
+ </SidebarGroup>
712
+ </SidebarContent>
713
+ <SidebarFooter><AccountMenu /></SidebarFooter>
714
+ </Sidebar>
715
+ <SidebarInset>
716
+ <header><SidebarTrigger /></header>
717
+ …
718
+ </SidebarInset>
719
+ </SidebarProvider>
720
+ ```
721
+
722
+ Nav items: `{ label, href?, icon?, badge?, children?, defaultOpen?, disabled?, onSelect?, external?, id? }`
723
+ — `children` nest to any depth. Wide: full sidebar ↔ icon rail (submenus open as a flyout);
724
+ narrow: off-canvas drawer. Chosen by the layout's own width.
725
+ `SidebarProvider`: `side`, `defaultCollapsed`, `collapsed` / `onCollapsedChange`, `drawerBelow` (768),
726
+ `railBelow` (1024), `persistKey`, `shortcut` ("b" → Ctrl/⌘+B), `linkComponent`, `onNavigate`, `contained`.
727
+ `Sidebar`: `width`, `railWidth`. `SidebarNav`: `items`, `activeHref`, `matchNested`. `SidebarGroup`:
728
+ `label`, `action`. `useSidebar()` → `{ mode, collapsed, drawerOpen, toggle, setCollapsed, setDrawerOpen, side }`.
729
+
730
+ ## Tabs
731
+
732
+ ```tsx
733
+ <Tabs defaultValue="overview" orientation="horizontal" variant="line">
734
+ <TabList aria-label="Project">
735
+ <Tab value="overview">Overview</Tab>
736
+ <Tab value="billing" disabled>Billing</Tab>
737
+ </TabList>
738
+ <TabPanel value="overview">…</TabPanel>
739
+ </Tabs>
740
+
741
+ // Editable: drag or Alt+arrows to move, double-click/F2 to rename, + to add, ×/Delete to close
742
+ <TabList onReorder={(ids) => …} onRename={(id, name) => …} onAdd={() => newId} onClose={(id) => …} renameOnAdd>
743
+ ```
744
+
745
+ `Tabs`: `value` / `defaultValue` / `onValueChange`, `orientation` (`horizontal` `vertical`),
746
+ `variant` (`line` `enclosed` `pills`), `size` (`sm` `md`), `activation` (`automatic` `manual`).
747
+ `TabList`: `onReorder`, `onRename`, `onAdd` (return the new value to select it), `addLabel`,
748
+ `renameOnAdd`, `onClose`, `closeLabel`. `Tab`: `value`, `label`, `disabled`, `icon`, `badge`,
749
+ `renamable`, `closable`. `TabPanel`: `value`, `keepMounted`.
750
+
751
+ ## Timeline
752
+
753
+ ```tsx
754
+ <Timeline aria-label="Invoice activity" variant="feed" | "progress" layout="left" | "alternate">
755
+ <TimelineGroup date={new Date()}> {/* "Today" */}
756
+ <TimelineItem icon="bi bi-check-lg" tone="success" title="Payment received" time={at} />
757
+ <TimelineItem avatar={<Avatar … />} title="Maria commented" time={at} card>…</TimelineItem>
758
+ <TimelineCollapse>{quietItems}</TimelineCollapse>
759
+ </TimelineGroup>
760
+ </Timeline>
761
+
762
+ <Roadmap aria-label="Roadmap" items={[{ label: "Q3 2026", title: "Mobile app", status: "current" }]} />
763
+ ```
764
+
765
+ `Timeline`: `layout`, `variant`, `size` (`sm` `md`), `animated` (the thread draws itself on
766
+ scroll where supported). `TimelineGroup`: `label` or `date`. `TimelineItem`: `title`, `time`
767
+ (shown as "2 hours ago") or `timeLabel`, `icon`, `avatar`, `tone`, `status` (`past` `current`
768
+ `upcoming`), `meta`, `card`, `last`. `TimelineCollapse`: `label`, `defaultOpen`.
769
+ `Roadmap`: `items` (`label`, `title`, `description`, `status` `done`/`current`/`upcoming`,
770
+ `footer`), `itemWidth`. Helpers: `formatTimelineTime`, `formatTimelineDay`.
771
+
772
+ ## Typography
773
+
774
+ ```tsx
775
+ <Heading level={1} size="display">Get paid faster</Heading>
776
+ <Lead>Create, send and track invoices from one place.</Lead>
777
+ <Text size="sm" tone="muted" numeric>₱48,200.00</Text>
778
+ <Link href="https://laravel.com/docs">Laravel docs</Link> {/* external: ↗, new tab */}
779
+ <Code>APP_ENV</Code> <Kbd keys={["⌘", "K"]} /> <Mark>match</Mark>
780
+ <Blockquote author="Maria Santos" source="Owner">…</Blockquote>
781
+ <List variant="check"><ListItem>Payment links</ListItem></List>
782
+ <Prose html={sanitizedHtml} />
783
+ <StatGroup divided><Stat label="Revenue" value="₱1.24M" change="12.5%" trend="up" /></StatGroup>
784
+ ```
785
+
786
+ `Heading`: `level` (tag), `size` (`display` `h1`–`h6`), `tone`, `weight`, `align`, `truncate`,
787
+ `lineClamp`. `Text`: `as`, `size` (`xl` `lg` `md` `sm` `xs`), `tone` (`default` `muted` `subtle`
788
+ `primary` `success` `warning` `danger` `info` `inherit`), `weight`, `align`, `numeric`,
789
+ `truncate`, `lineClamp`. `Link`: `variant` (`inline` `subtle` `standalone`), `external`,
790
+ `asChild`. `Prose`: `size`, `html`. `Stat`: `label`, `value`, `change`, `trend`,
791
+ `invertTrend`, `description`, `size`. Set `--font-heading` for a separate heading font.
792
+
793
+ ## Layout (Stack, Grid, Container…)
794
+
795
+ Small pieces for arranging things. Every size prop takes a plain value or an object keyed by
796
+ breakpoint, so a layout changes with the screen without you writing media queries.
797
+
798
+ ```tsx
799
+ <Stack direction={{ base: "column", md: "row" }} gap={3} justify="between" align={{ md: "center" }}>…</Stack>
800
+ <Stack direction="row" gap={2} wrap divider>…</Stack>
801
+
802
+ <Grid columns={{ base: 1, sm: 2, lg: 4 }} gap={4}>…</Grid>
803
+ <Grid minChildWidth="16rem" gap={4}>…</Grid> {/* reacts to its own width */}
804
+ <GridItem span={{ base: 1, md: 3 }} rowSpan={2}>…</GridItem>
805
+
806
+ <Container size="xl" padding={{ base: 4, md: 8 }}>…</Container>
807
+ <Center minHeight="60vh">…</Center>
808
+ <Stack direction="row"><Logo /><Spacer /><Account /></Stack>
809
+ <Divider dashed spacing={4} /> <Divider>or</Divider>
810
+ <AspectRatio ratio="16/9"><img src={cover} alt="" /></AspectRatio>
811
+ ```
812
+
813
+ `Stack`: `direction`, `gap`, `align`, `justify`, `wrap`, `divider`, `as`. `Grid`: `columns` or
814
+ `minChildWidth`, `gap`, `align`, `as`. `GridItem`: `span` (never exceeds the column count, so
815
+ nothing overflows on a phone), `rowSpan`. `Container`: `size` (`sm` `md` `lg` `xl` `2xl` `prose`
816
+ `full`, or any length), `padding`, `align`. `gap` and `padding` count in spacing steps, so they
817
+ follow the theme's density. Breakpoints follow the window, like Tailwind's own — for a layout that
818
+ reacts to the space it's actually in, use `minChildWidth` or `wrap`.
819
+
820
+ ## Hero
821
+
822
+ The top of a landing page, in separate pieces so it can be quiet or loud.
823
+
824
+ ```tsx
825
+ <Hero background="aurora" size="lg">
826
+ <HeroEyebrow href="/changelog" icon="bi bi-stars">New · Recurring invoices</HeroEyebrow>
827
+ <HeroTitle gradient>Get paid without chasing anyone</HeroTitle>
828
+ <HeroSubtitle>Send an invoice in thirty seconds and let the reminders go out on their own.</HeroSubtitle>
829
+ <HeroActions>
830
+ <Button size="lg">Start free</Button>
831
+ <Button size="lg" variant="secondary">See a demo</Button>
832
+ </HeroActions>
833
+ <HeroStats stats={[{ value: "12,400", label: "invoices sent" }, { value: "₱48M", label: "collected" }]} />
834
+ <HeroLogos logos={["Northwind", "Blue Harbor", "Luzon Freight"]} marquee />
835
+ </Hero>
836
+
837
+ <Hero background="image" image="/img/warehouse.jpg" overlay={0.6} size="screen" align="start"
838
+ media={<HeroMedia frame url="app.example.com/invoices" tilt float><img src={shot} alt="" /></HeroMedia>}>
839
+ …
840
+ </Hero>
841
+ ```
842
+
843
+ `Hero`: `background` (`none` `aurora` `grid` `dots` `gradient` `image`), `image`, `overlay`, `dark`,
844
+ `align`, `size` (`sm` `md` `lg` `screen` — `screen` uses `100svh`), `media` (two columns from medium
845
+ screens), `colors`, `as`. `HeroTitle`: `as` (`h1` default), `gradient`, `size`, `colors`.
846
+ `HeroEyebrow`: `href` (makes it a link), `icon`. `HeroStats`: `stats`. `HeroLogos`: `logos`,
847
+ `label`, `marquee`, `speed` (pauses on hover). `HeroMedia`: `frame` (browser chrome), `url`,
848
+ `tilt`, `float`. Aurora, float and the marquee all stop under `prefers-reduced-motion`.
849
+
850
+ ## Theme config (ui.theme.json)
851
+
852
+ Describe your brand once; every component follows.
853
+
854
+ ```json
855
+ {
856
+ "$schema": "./node_modules/@astherix/ui/ui.theme.schema.json",
857
+ "colors": { "primary": "#047857", "secondary": "#0e7490", "tertiary": "#b45309" },
858
+ "fonts": { "sans": "\"DM Sans\", ui-sans-serif, system-ui, sans-serif", "googleFonts": ["DM Sans:wght@400..700"] },
859
+ "radius": "sm",
860
+ "density": "compact",
861
+ "shadows": "subtle"
862
+ }
863
+ ```
864
+
865
+ Settings: `colors` (`primary` `secondary` `tertiary` `success` `warning` `danger` `info` as a hex or
866
+ `{ light, dark, foreground, darkForeground }`; `background` `surface` `foreground` `muted` `border`),
867
+ `fonts` (`sans` `heading` `mono` `googleFonts`), `radius` (`none` `sm` `md` `lg` or exact values),
868
+ `density` (`compact` `comfortable` `spacious` or a multiplier), `shadows` (`none` `subtle` `default`
869
+ `strong`), `baseFontSize`. Hover, pressed-edge, readable text and dark-mode shades are derived
870
+ from each color.
871
+
872
+ **Build time** (recommended):
873
+
874
+ ```bash
875
+ npx jm-ui init # starter ui.theme.json
876
+ npx jm-ui theme ui.theme.json --out resources/css/ui-theme.css [--watch]
877
+ ```
878
+
879
+ ```css
880
+ @import "tailwindcss";
881
+ @import "@astherix/ui/theme.css";
882
+ @import "./ui-theme.css";
883
+ ```
884
+
885
+ **Runtime**: `<ThemeProvider theme={json} selector?>` (e.g. a theme per customer).
886
+ **From code**: `import { createThemeCss, validateTheme, defineTheme } from "@astherix/ui/theme"`.
887
+
888
+ ## Theming
889
+
890
+ Components only use semantic variables (`--ui-primary`, `--ui-border`, …). Override them in
891
+ your app to re-brand without rebuilding:
892
+
893
+ ```css
894
+ :root { --ui-primary: #6d28d9; --ui-primary-hover: #5b21b6; --ui-primary-edge: #3b0764; }
895
+ ```
896
+
897
+ ## Animated components
898
+
899
+ ### Skeleton
900
+
901
+ ```tsx
902
+ <SkeletonSwap loading={isLoading} skeleton={<SkeletonCard media footer />} label="Loading the invoice">
903
+ <Invoice … />
904
+ </SkeletonSwap>
905
+
906
+ <SkeletonList rows={5} /> <SkeletonTable rows={5} columns={4} /> <SkeletonChart variant="bars" />
907
+ <SkeletonText lines={3} /> <SkeletonAvatar size="lg" /> <SkeletonButton /> <SkeletonImage ratio="4/3" />
908
+ <Skeleton width={140} height={28} radius="999px" animation="pulse" delay={120} />
909
+ ```
910
+
911
+ `Skeleton`: `shape` (`rect` `circle` `text` `pill` `line`), `lines`, `lastLineWidth`, `width`,
912
+ `height`, `radius`, `animation` (`shimmer` `pulse` `none`), `delay` (stagger). Presets ripple down
913
+ stacks of rows. `SkeletonSwap`: `loading`, `skeleton`, `label`, `delay` (180ms before showing —
914
+ quick loads never flash one), `minDuration` (500ms minimum once shown), `fade`;
915
+ `useDelayedLoading(loading, { delay, minDuration })` for your own markup. Decorative shapes are
916
+ hidden from screen readers; reduced motion turns the shimmer into a slow fade.
917
+
918
+ ### SpotlightText
919
+
920
+ Text lit by a moving spotlight — the lit part glows, the rest sits in shadow.
921
+
922
+ ```tsx
923
+ <SpotlightText as="h1" className="text-5xl font-semibold">Get paid faster.</SpotlightText>
924
+ <SpotlightText mode="sweep" tint="linear-gradient(90deg, #fde68a, #f59e0b)">★ Most popular</SpotlightText>
925
+ <SpotlightText mode="reveal" radius={90}>Psst — use code EARLYBIRD.</SpotlightText>
926
+ ```
927
+
928
+ `mode` (`follow` — follows the pointer or finger, drifts when idle; `sweep` — a beam passes every
929
+ few seconds; `reveal` — dark until lit), `speed` (`slow` `normal` `fast` or ms per pass; `fast`
930
+ suits status messages like "Syncing…"), `as`, `tint` (color or gradient; default the text's own
931
+ color), `radius`, `dim`, `glow`, `smoothing`, `duration` / `pause` (exact sweep pace). Real, selectable text for
932
+ screen readers; pauses off-screen; fully lit and still with reduced motion.
933
+