@sckyzo/cosmos-react 0.1.0 → 0.3.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 CHANGED
@@ -39,8 +39,16 @@ In your main CSS file:
39
39
 
40
40
  /* Optional: the Outfit + JetBrains Mono webfonts. */
41
41
  @import '@sckyzo/cosmos-theme/fonts';
42
+
43
+ /* Only if you use Calendar, DatePicker or a component built on them. */
44
+ @import '@sckyzo/cosmos-react/calendar.css';
42
45
  ```
43
46
 
47
+ `Calendar` and `DatePicker` wrap [react-day-picker](https://daypicker.dev), whose
48
+ structural styles are not bundled in the JS, so the package stays loadable with
49
+ `require` in Node and Jest. `calendar.css` is the explicit export that loads them;
50
+ without it the calendar grid renders unstyled. Import it once, after Tailwind.
51
+
44
52
  If you also use `@sckyzo/cosmos-charts`, add a second `@source` line for its
45
53
  `dist` the same way.
46
54
 
@@ -64,11 +72,724 @@ function Example() {
64
72
  }
65
73
  ```
66
74
 
75
+ ## Refs
76
+
77
+ `ref` is a regular prop (React 19), so no component needs `forwardRef`. Fields,
78
+ buttons and custom controls point it at the element you would focus, for
79
+ instance to put the focus on the first invalid field:
80
+
81
+ ```tsx
82
+ import { useRef } from 'react';
83
+ import { Input } from '@sckyzo/cosmos-react';
84
+
85
+ const emailRef = useRef<HTMLInputElement>(null);
86
+
87
+ <Input ref={emailRef} label="Email" />;
88
+ // on a validation error: emailRef.current?.focus()
89
+ ```
90
+
91
+ The `ref` prop of each component says what it points to: the native `<input>`
92
+ for text, number and search fields, the switch `<button>` for `Toggle`, the first
93
+ cell for `OtpInput`, the trigger for `DatePicker`, and the segment you would Tab
94
+ to for `SegmentedControl`, in either appearance (the selected one, else the first).
95
+
96
+ ## Links and your router
97
+
98
+ A component with an `href` (`Sidebar.Item`, `Breadcrumb`, `Menu.Item`, `Link`, `Tag`, a `Row`, a
99
+ `DataTable` row…) draws a plain `<a>`, so a click loads the page again. Mount `LinkProvider` once, at
100
+ the root of the app, to send those links through your router instead: it renders the component you
101
+ give in place of the `<a>`, so a click navigates in place, and middle-click and "Open in a new tab"
102
+ keep working, the element still being a real link. `cosmos-react` imports no router; the adapter is
103
+ yours.
104
+
105
+ ```tsx
106
+ import { Link } from 'react-router';
107
+ import { LinkProvider, type LinkComponentProps } from '@sckyzo/cosmos-react';
108
+
109
+ // Define it once, at module level, and hand on every prop (`ref`, `className`, `onClick`, `aria-*`).
110
+ const AppLink = ({ href, ...props }: LinkComponentProps) => <Link to={href} {...props} />;
111
+
112
+ <LinkProvider component={AppLink}>{app}</LinkProvider>;
113
+ ```
114
+
115
+ Next.js's `Link` takes `href` as it is: `<LinkProvider component={NextLink}>`. Without a provider
116
+ nothing changes. A link with `target="_blank"`, a link with no `href` or a bare `#`, and the links in
117
+ chat messages stay a plain `<a>`, and a provider inside another one replaces it for its subtree.
118
+
119
+ ## Variants worth knowing
120
+
121
+ - `Tabs variant="pills"`: rounded triggers on a tinted track, the active one filled with the accent, horizontal or vertical.
122
+ - `Tabs variant="raised"`: the same neutral gray track, but the active tab is raised on it in white (a lighter gray in the dark themes) with no accent, horizontal or vertical.
123
+ - `Toggle size="sm"`: a 36×20 track; the label stays in the same `<label>`.
124
+ - `Textarea autoResize maxRows={6}`: `rows` is the minimum, the field grows with its content up to `maxRows`, then scrolls.
125
+ - `Input`, `Select` and `Textarea` `success="Username is available."`: the message under a field whose value is valid, with a green border (and a check icon on `Input` and `Select`). It is linked as the description and announced politely; `error` wins when both are set. `OtpInput success`: a green border on every cell, the message is yours.
126
+ - `SegmentedControl` options take a `badge` (a count in a small pill, part of the radio's accessible name).
127
+ - `PageHeader icon={Plug} iconIntent="brand"`: a decorative icon tile left of the title.
128
+ - `Rating value={4.5} allowHalf readOnly showValue count="(128 reviews)"`: stars on the warning hue with an outlined empty star, announced as `role="img"` ("Rated 4.5 out of 5"). Without `readOnly` it is a `radiogroup` (one tab stop, arrows, Home/End, hover preview) with `defaultValue`/`value` and `onChange(value)`; `0` is "no rating".
129
+ - `Carousel.Thumbnails`: a scrolling row of buttons under the slides, one child per slide, named "Go to slide N" with `aria-current` on the current one and scrolled into view as it changes.
130
+ - `ProfileHeader`: the top of a profile page. A `cover` (a gradient of the accent's tints by default), an `avatar` overlapping its bottom edge, the `name` with `badges`, a `headline` and `meta`, `actions`, `stats` read as a `<dl>`, and a `tabs` slot for a `Tabs.List` under a border. Below `sm` it stacks and the actions share the width.
131
+ - `ImageGrid layout="masonry"`: CSS columns, each picture keeping its aspect with its caption under it. Items flow down the first column, then the next; give the images `width` and `height` so the columns don't rebalance as they load.
132
+ - `ConfirmationModal intent="danger" requireText="my-project"`: a red tile and a solid action, disabled until the exact (case-sensitive) text is typed; `requireTextLabel` replaces the label.
133
+ - `SecretValue label="API key" value={key} visibleSuffix={4}`: a stored secret, masked until revealed. While masked the real value is not in the DOM (20 bullets plus the suffix, at most half of the value); the Copy button copies the real value without revealing it. `autoHideAfter` (ms) masks it again.
134
+ - `Link intent="danger"`: a `colored` link in `success`, `warning` or `danger` in place of the accent; the other variants have no color of their own and ignore it.
135
+ - `ProgressBar intent="neutral"`: a gray fill for a gauge that means nothing (space used, a quota). Segments and `ProgressRing` take it too.
136
+ - `Badge size="lg"` and `shape="rounded"`: a larger badge, and `rounded-md` corners in place of the pill.
137
+ - `Carousel.Indicators position="inside"`: the dots at the bottom center of the slide, 12px from the edge, on a dark pill. Keep the default when `Carousel.Controls` is `outside` too: its row sits under the slide.
138
+ - `Ribbon shape="pill"`: a fully rounded label set inside the card, 12px from the corner of its `position`.
139
+
140
+ ## Tables
141
+
142
+ `DataTable` sorts, filters, searches and paginates its rows. Give it a height
143
+ (`className="max-h-96"`) and the rows scroll under the header, in an area the
144
+ keyboard can focus and scroll while it overflows; `aria-label` or
145
+ `aria-labelledby` names the table, and that area as a region. For a short
146
+ read-only table, compose the table primitives instead: `Table`, `TableCaption`,
147
+ `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` and
148
+ `TableCell` render the native elements with the Cosmos styling and no logic.
149
+
150
+ ```tsx
151
+ import {
152
+ Table,
153
+ TableBody,
154
+ TableCaption,
155
+ TableCell,
156
+ TableHead,
157
+ TableHeaderCell,
158
+ TableRow,
159
+ } from '@sckyzo/cosmos-react';
160
+
161
+ <Table striped>
162
+ <TableCaption>Nodes</TableCaption>
163
+ <TableHead>
164
+ <TableRow>
165
+ <TableHeaderCell>Name</TableHeaderCell>
166
+ <TableHeaderCell numeric>CPU</TableHeaderCell>
167
+ </TableRow>
168
+ </TableHead>
169
+ <TableBody>
170
+ <TableRow>
171
+ <TableCell>node-101</TableCell>
172
+ <TableCell numeric>42%</TableCell>
173
+ </TableRow>
174
+ </TableBody>
175
+ </Table>;
176
+ ```
177
+
178
+ `DataTable` shows a muted two-way icon on every sortable column, keeps its
179
+ header (and the width of its selection and action columns) while it loads, is
180
+ empty or fails, and takes `loadingRows` for the number of placeholder rows. Its
181
+ rows can act: `onRowClick(row)` makes them focusable (Enter and Space) and
182
+ clickable, `rowHref(row)` makes each one a link (a real `<a>` in the first
183
+ column, drawn over the row, so middle click and "Open in a new tab" work; keep
184
+ buttons and links out of that column). A click on a control inside the row (the
185
+ checkbox, the action column, a link or button in another cell) never counts as a
186
+ row click. `RowActionsMenu` is the ⋮ button and menu of a row, for
187
+ `renderRowAction`: give it `Menu.Item`s, a destructive one with
188
+ `intent="danger"`. On a narrow screen the edge of a table that scrolls sideways
189
+ fades, and `stickyFirstColumn` pins its first column (after the checkbox on
190
+ `DataTable`). `TableIdentityCell`, `TableIdCell`, `TableDescriptionCell` and
191
+ `TableAmountCell` are the cells tables keep rebuilding.
192
+
193
+ `DataTable`'s column filters can be controlled: `filters`, `defaultFilters` and
194
+ `onFiltersChange(filters)` hold a record of column `key` to the set of selected
195
+ values. `filterTabs` (`{ columnKey, options: [{ value, label, count }], allLabel,
196
+ allCount }`) puts underlined status tabs at the top of the card, each setting
197
+ that column's filter ("All" clears it) and staying in sync with the active
198
+ filters bar; the counts come from you, the table never computes them.
199
+
200
+ `DataTable` takes `mobileLayout="cards"` to show each row as a block below `sm`
201
+ instead of scrolling: the checkbox, the column marked `mobile: 'title'` and the
202
+ row action on the first line, then a label/value pair per other column
203
+ (`mobileLabel` renames a label, `mobile: 'hidden'` drops a column). The toolbar
204
+ gets a "Sort by" select for the sortable columns. The default, `scroll`, keeps
205
+ the table.
206
+
207
+ `DataTable`'s `density` is controllable like the rest: `density`, `defaultDensity`
208
+ and `onDensityChange(density)`. As with `pageSize`, a `density` passed without
209
+ `onDensityChange` is a controlled value that never moves, so a saved view
210
+ reports its density and the table keeps the one you pass.
211
+
212
+ `exportable` adds an Export button before Columns (the toolbar shows even
213
+ without a search field). It downloads a CSV of every page of the rows that the
214
+ search, filters and sort keep, in the visible columns and their order; the
215
+ selection and action columns stay out. A header is the column's `label` when it
216
+ is a string or a number, else `mobileLabel` when that is a string, else the
217
+ `key`. A cell takes `exportValue(row)`, else `sortValue`, `searchValue` or
218
+ `filterValue` (the first the column has); `null` is an empty cell. The file is
219
+ named by `exportFileName` (default `export.csv`) and is UTF-8 with a byte order
220
+ mark (U+FEFF), so that spreadsheets read accents. Fields follow RFC 4180
221
+ (quoted when they hold `"`, `,` or a line break, records end with CRLF), and a
222
+ header or string value that starts with `=`, `+`, `-`, `@`, a tab or a carriage
223
+ return gets a `'` prefix so a spreadsheet never runs it as a formula; a
224
+ `number` is left alone. To export on the server, `onExport(query)` receives `{ search, filters,
225
+ sort, columns }` (the visible column keys, in order) and builds no file.
226
+ `toCsv(rows, columns)` gives the same text, without the byte order mark, for an
227
+ export outside a table (prepend U+FEFF for a file meant for spreadsheets).
228
+
229
+ ```tsx
230
+ <DataTable
231
+ rows={rows}
232
+ columns={[
233
+ { key: 'name', label: 'Name', render: (r) => r.name, sortValue: (r) => r.name },
234
+ {
235
+ key: 'cpu',
236
+ label: 'CPU %',
237
+ render: (r) => <Gauge value={r.cpu} />,
238
+ exportValue: (r) => r.cpu,
239
+ },
240
+ ]}
241
+ rowKey={(r) => r.id}
242
+ exportable
243
+ exportFileName="nodes.csv"
244
+ />
245
+ ```
246
+
247
+ On paper a `DataTable` drops its toolbar, bulk-action bar, filter tabs, pager
248
+ and the selection and action columns, prints every row of the page instead of
249
+ scrolling, repeats the header on each page and keeps a row on one page. There is
250
+ no Print button: use the browser's. The theme is not switched, so a dark theme
251
+ prints its own colours: pick a light one for print (or `@media print` in your CSS).
252
+
253
+ Saved views: `views` (or `defaultViews`) shows a Views button in the toolbar.
254
+ A view is plain JSON, `{ id, name, search, filters, sort, columnVisibility,
255
+ pageSize, density }` (`filters` maps a column key to an array of values, `sort`
256
+ is `{ key, direction }` or `null`, `pageSize` is a number or `'all'` for "Show
257
+ all"), so you can keep the list anywhere: the table never stores it. Pass
258
+ `views` and `onViewsChange(views)`, or `defaultViews` and listen to
259
+ `onViewsChange`; `activeView` / `defaultActiveView` / `onActiveViewChange(id)`
260
+ hold the `id` of the applied view, or `null`. The menu applies a view, saves the
261
+ current state as a new one ("Save as…"), renames the active one or deletes it
262
+ (after a confirmation); a name must be filled and not used by another view.
263
+ Applying a view sets each part through the table's own state, so a part you
264
+ control reports its own `on*Change`, and the table goes back to the first page.
265
+ The button shows the active view's name, with "(modified)" once the table no
266
+ longer matches it. The `DataTableView`, `DataTableSort` and `DataTableExportQuery`
267
+ types are exported.
268
+
269
+ ```tsx
270
+ const [views, setViews] = useState<DataTableView[]>(() => load());
271
+ <DataTable
272
+ rows={rows}
273
+ columns={columns}
274
+ rowKey={(r) => r.id}
275
+ views={views}
276
+ onViewsChange={(next) => {
277
+ setViews(next);
278
+ save(next);
279
+ }}
280
+ />;
281
+ ```
282
+
283
+ Server mode: `manual` turns the in-memory search, filters, sort and pagination
284
+ off. `rows` is the page you fetched, drawn in its order, and the controls only
285
+ report: `onSearchChange`, `onFiltersChange`, `onSortChange`, `onPageChange` and
286
+ `onPageSizeChange` (a change of search, filters, sort or page size goes back to
287
+ page 1 first). `rowCount` is the total across every page, for the pager and its
288
+ "Showing X to Y of Z"; without it the pager counts `rows.length`. A filter
289
+ dropdown lists the values found in `rows`, which in `manual` are one page, so a
290
+ filterable column takes `filterOptions` (`['Paid', 'Pending']`, in the order to
291
+ show) instead; it works without `manual` too. "Show all" is not offered, and
292
+ the built-in CSV export only holds the loaded rows: pass `onExport` to export on
293
+ the server.
294
+
295
+ The sort and the page are controlled with or without `manual`: `sort`,
296
+ `defaultSort` and `onSortChange(sort)` hold a `DataTableSort` (`{ key,
297
+ direction }` or `null`), and `page`, `defaultPage` and `onPageChange(page)` are
298
+ 1-indexed like `Pagination`. A controlled table shows a new value once you pass
299
+ it back, and a change that resets the page calls `onPageChange(1)` (not when it
300
+ is on page 1 already).
301
+
302
+ `loading` with rows on screen keeps them, dimmed and inert (nothing in them can
303
+ be clicked meanwhile), under a centred spinner, with the pager still there and
304
+ `aria-busy` on the table; with no rows yet it draws the `loadingRows`
305
+ placeholders as before. Use it for every request, the first or the next page.
306
+
307
+ ```tsx
308
+ const [sort, setSort] = useState<DataTableSort>(null);
309
+ const [page, setPage] = useState(1);
310
+ const { data, isFetching } = useOrders({ sort, page });
311
+ <DataTable
312
+ manual
313
+ rows={data?.rows ?? []}
314
+ rowCount={data?.total}
315
+ loading={isFetching}
316
+ columns={columns}
317
+ rowKey={(o) => o.id}
318
+ sort={sort}
319
+ onSortChange={setSort}
320
+ page={page}
321
+ onPageChange={setPage}
322
+ />;
323
+ ```
324
+
325
+ `virtualize` draws only the rows in view and a few around them, for thousands of
326
+ rows on one page (a large `pageSize`, or "Show all"). Two `aria-hidden` spacer
327
+ rows stand for the rest, so the scroll bar measures the whole list, and the
328
+ header stays sticky. The table says where the window is (`aria-rowcount`, and
329
+ `aria-rowindex` on each drawn row), and the row that holds the keyboard focus
330
+ stays drawn while it is scrolled out of view, so focus and selection survive
331
+ scrolling. Its limits:
332
+
333
+ - The table needs a height (`className="max-h-96"`): its own area is what
334
+ scrolls, and without a height every row is in view.
335
+ - Every row has one fixed height, from `density`: 49px, or 61px when
336
+ `comfortable`, at the default text size, and growing with the text size
337
+ setting. Content taller than that is clipped, so keep a cell to a line of text
338
+ or a control, as in a standard row.
339
+ - It applies to the table layout only: with `mobileLayout="cards"` below `sm`
340
+ the cards keep the pagination.
341
+ - It is not for expandable rows.
342
+ - On paper only the drawn rows print.
343
+
344
+ ```tsx
345
+ <DataTable
346
+ rows={tenThousandRows}
347
+ columns={columns}
348
+ rowKey={(r) => r.id}
349
+ pageSize={10_000}
350
+ virtualize
351
+ className="max-h-96"
352
+ />
353
+ ```
354
+
355
+ `Table` takes `density` (`default` or `comfortable`, as on `DataTable`),
356
+ `striped`, `stickyHeader` (give the wrapper a height with
357
+ `containerClassName`, e.g. `max-h-80`) and `stickyFirstColumn`. While data
358
+ loads, `TableSkeletonRows` fills the `TableBody` under the header row. Cells take `align` and `numeric`
359
+ (right-aligned, tabular figures). `TableHeaderCell` sets `scope` to `col` in the
360
+ head and `row` elsewhere. The wrapper scrolls horizontally, so a wide table
361
+ stays inside the page on small screens.
362
+
363
+ ## Audit diff
364
+
365
+ `AuditDiff` shows what an audit log entry changed: `changes` is a list of
366
+ `{ field, label?, before?, after? }`. The default `table` variant is a real
367
+ table (Field, Before, After) that stacks each change as a block below `sm`; the
368
+ old value is struck through on a red tint and the new one sits on a green tint.
369
+ `variant="inline"` is one line per field (`Role: Viewer → Admin`), for an entry
370
+ of an `ActivityFeed` or a `Timeline`.
371
+
372
+ ```tsx
373
+ import { AuditDiff } from '@sckyzo/cosmos-react';
374
+
375
+ <AuditDiff
376
+ variant="inline"
377
+ changes={[
378
+ { field: 'role', label: 'Role', before: 'Viewer', after: 'Admin' },
379
+ { field: 'phone', label: 'Phone', after: '+33 1 23 45 67 89' },
380
+ ]}
381
+ />;
382
+ ```
383
+
384
+ Values are rendered as text (markup in one shows as written): strings as they
385
+ are, numbers and booleans with `String`, objects and arrays as compact JSON
386
+ (there is no nested diff). `null`, `undefined` and `''` mean the field was
387
+ empty on that side and show "—", which a screen reader reads as "empty".
388
+ `formatValue(value, change, side)` draws a value yourself. When both sides are
389
+ strings of a few words or more, the cells mark the words that moved with `<del>`
390
+ and `<ins>`, each with a visually hidden "removed:" / "added:"; past about 500
391
+ tokens on each side the whole values are shown instead. `labels` rewords every
392
+ text the component writes (`field`, `before`, `after`, `empty`, `removed`,
393
+ `added`), for another language.
394
+
395
+ ## Progress
396
+
397
+ `ProgressBar` shows one value (`value`, `max`, `intent`) or a list of parts
398
+ (`segments`), never both. Each segment has a `value`, a `label` and an `intent`
399
+ you pick yourself; the parts sit 2px apart, and their sum is held to `max`, so
400
+ show the overflow yourself ("112%"). `legend` adds a row of swatch, label and value
401
+ under the bar, with what is left on the track. `aria-valuetext` lists the parts
402
+ ("Documents 40, Images 25, Free 35"); `valueLabel` puts a unit on a part.
403
+
404
+ ```tsx
405
+ <ProgressBar
406
+ aria-label="Storage"
407
+ legend
408
+ segments={[
409
+ { label: 'Documents', value: 40, valueLabel: '40 GB' },
410
+ { label: 'Images', value: 25, intent: 'success', valueLabel: '25 GB' },
411
+ ]}
412
+ free={{ valueLabel: '35 GB' }}
413
+ />
414
+ ```
415
+
416
+ `ProgressRing` is the same meter as an SVG ring (no dependency): `value`, `max`,
417
+ `intent`, `size` (`sm` 32px, `md` 56px, `lg` 96px, or a number of pixels), `thickness`,
418
+ and `children` for the centre (a value, an icon). It is a `progressbar` too, so name it
419
+ with `aria-label` or `aria-labelledby`; the centre is not read a second time. `indeterminate`
420
+ spins a quarter arc, except under reduced motion.
421
+
422
+ ```tsx
423
+ <ProgressRing aria-label="Course progress" value={68} intent="success">
424
+ 68%
425
+ </ProgressRing>
426
+ ```
427
+
428
+ ## Kanban board
429
+
430
+ `KanbanBoard` shows columns of cards that move between columns: a sprint board, a pipeline, tasks by
431
+ status. Cards are plain data (`id`, `title`, an optional `description`, and `badges` and `footer`
432
+ slots for `Tag`s and an `AvatarGroup`), columns are `{ id, title, cards }`. It follows the usual
433
+ controlled/uncontrolled pair (`value`, `defaultValue`, `onChange(columns)`), and owns no data: the
434
+ host saves the move. `onCardMove` carries just the move, after `onChange`, which is what a server
435
+ needs.
436
+
437
+ ```tsx
438
+ <KanbanBoard
439
+ ariaLabel="Sprint board"
440
+ defaultValue={columns}
441
+ onChange={setColumns}
442
+ onCardMove={({ cardId, fromColumnId, toColumnId, index }) => save(cardId, toColumnId, index)}
443
+ onCardOpen={(card) => setOpen(card)}
444
+ onAddCard={(columnId) => openForm(columnId)}
445
+ />
446
+ ```
447
+
448
+ A card is dragged by its grip, with a pointer or a finger. From the keyboard, focus the grip, press
449
+ Space or Enter to pick the card up, the arrow keys to move it between positions and columns, Space
450
+ or Enter to drop it, Escape to put it back; a live region announces each step. Every card also has
451
+ an actions menu ("Move to" the other columns, "Move up", "Move down"), so the board works without
452
+ dragging. `onCardOpen` makes the whole card a button, `onAddCard` adds an "Add card" button to each
453
+ column, `renderCard` replaces what is inside a card (the grip and the actions menu stay), and
454
+ `loading` shows skeleton columns. A column takes an `intent` for the dot before its title and an
455
+ `empty` node for when it has no cards. Below `sm` the columns scroll sideways, one column wide.
456
+
457
+ Drag and drop is built on [`@dnd-kit/react`](https://dndkit.com), pinned to an exact version since it
458
+ is pre-1.0. It is a regular dependency of the package, not a peer.
459
+
460
+ ## Dates, clocks and countdowns
461
+
462
+ `DateTime`, `RelativeTime`, `Clock` and `Countdown` write a moment the same way everywhere, with
463
+ `Intl` and in the user's language. They render in the browser only, since the server knows neither
464
+ the locale nor the zone, and keep the room of their text until then.
465
+
466
+ ```tsx
467
+ <DateTime value={order.createdAt} format="datetime" timeZone="Europe/Paris" />
468
+ <RelativeTime value={event.at} tooltip />
469
+ <Clock variant="card" timeZone="Asia/Tokyo" label="Tokyo" showDate />
470
+ <Countdown target="2026-03-01T09:00:00Z" onComplete={() => reload()} />
471
+ ```
472
+
473
+ - `DateTime` takes a `format` (`date`, `time`, `datetime`, `long`, `full`, or `Intl.DateTimeFormat`
474
+ options), a `locale` and a `timeZone`, and draws a `<time dateTime>`. `tooltip` adds the full date
475
+ and zone, and makes the `<time>` a tab stop so a keyboard reaches it.
476
+ - `RelativeTime` reads "5 minutes ago", "yesterday", "in 2 days". Every instance shares one timer and
477
+ wakes only when its text changes: each second in the first minute, each minute in the first hour,
478
+ and rarely after, so a long list costs nothing.
479
+ - `Clock` has the variants `inline`, `card` and `display`, and takes `hour12`, `showSeconds`,
480
+ `showDate` and a `label` for the zone. It ticks on the second boundary. A world clock is a grid of
481
+ `Clock`s.
482
+ - `Countdown` has the variants `tiles`, `inline` and `compact`. `onComplete` is called once, at zero,
483
+ and the countdown stops there. `labels`, `shortLabels`, `label` and `completedLabel` translate its
484
+ text.
485
+
486
+ `Clock` and `Countdown` are `role="timer"`, which a screen reader does not announce as it changes.
487
+ Every one of them takes `now`, which freezes it at that moment and sets no timer: use it for
488
+ stories, screenshots and tests.
489
+
490
+ ## Auth layouts
491
+
492
+ `AuthLayout` wraps the sign-in, sign-up, reset and two-step forms. `variant` picks the page:
493
+ `card` (default) is a centred card; `split` puts the form beside a brand panel; `cover`
494
+ centres the card over a full-bleed image.
495
+
496
+ ```tsx
497
+ <AuthLayout variant="split" logo={Activity} title="Cosmos Cloud" subtitle="Monitor every rack.">
498
+ <SignInForm />
499
+ </AuthLayout>
500
+ <AuthLayout variant="cover" cover={{ src: '/login.jpg' }} logo={Activity} title="Cosmos Cloud">
501
+ <SignInForm />
502
+ </AuthLayout>
503
+ ```
504
+
505
+ In `split`, `aside` replaces the default panel (an accent surface with the `logo`, `title` and
506
+ `subtitle`; `null` for none), `asidePosition` (`start` | `end`, default `end`) picks its side. The
507
+ panel shows from `lg` up; below, the brand block sits above the form. In `cover`, `cover.src` is
508
+ drawn under a dark scrim (`cover.alt` is empty unless set), and without it the card sits on a
509
+ gradient of the accent. In every variant the built-in title is a paragraph: the forms carry the
510
+ page's `h1`.
511
+
512
+ ## Record pager
513
+
514
+ `RecordPager` moves through the records of a list from a detail page: "12 of 340", Previous, Next
515
+ and an optional "Back to list". `Pagination` stays for pages of a list; the order and filters of the
516
+ list are the host's to keep (URL or state).
517
+
518
+ ```tsx
519
+ <RecordPager
520
+ position={12}
521
+ total={340}
522
+ noun="order"
523
+ previousHref="/orders/20430"
524
+ nextHref="/orders/20432"
525
+ backHref="/orders"
526
+ hotkeys={{ previous: '[', next: ']' }}
527
+ />
528
+ ```
529
+
530
+ - Links when `previousHref` / `nextHref` are given (through `LinkProvider`), buttons with
531
+ `onPrevious` / `onNext` otherwise. A side with nowhere to go, or at the first or last record, is a
532
+ disabled button, never a link.
533
+ - `noun` builds the labels ("Previous order"); `labels` (`previous`, `next`, `back`, `count`)
534
+ replaces any of them, for another language.
535
+ - `variant="compact"` draws icon buttons (still named) and the count, for a `PageHeader`'s `actions`.
536
+ - Below `sm`, the default variant shows the icons only too, so a long label never wraps; the label
537
+ stays the button's name.
538
+ - Shortcuts are off unless `hotkeys` is given. They listen on the document, do nothing while a
539
+ field is being typed in, and click the control, so a link goes through your router. List them
540
+ yourself in a `HelpOverlay`.
541
+
542
+ ## Files and uploads
543
+
544
+ `FileTile` is one file or folder as a `<li>`: `layout="tile"` (the default) is the card of a grid, with a
545
+ 128px preview, `layout="row"` the line of a list. `kind` (`folder`, `image`, `video`, `audio`, `pdf`,
546
+ `document`, `spreadsheet`, `archive`, `code`, `other`) picks the icon and its tint; a `thumbnail` takes
547
+ the icon's place and falls back to it if it fails to load. The name is the only link (`href`) or button
548
+ (`onOpen`) of the tile; the star (`starred` / `defaultStarred` / `onStarredChange`, named "Star {name}")
549
+ and `actions`, typically a `Menu`, are siblings of it. `shared` adds a "Shared" badge and `selected`
550
+ draws the tile as picked.
551
+
552
+ ```tsx
553
+ <GridList cols={4}>
554
+ <FileTile
555
+ name="Q3 report.pdf"
556
+ kind="pdf"
557
+ meta="2.4 MB · Edited yesterday"
558
+ defaultStarred
559
+ onOpen={() => open(file)}
560
+ actions={<FileMenu file={file} />}
561
+ />
562
+ </GridList>
563
+ ```
564
+
565
+ `UploadQueue` lists uploads in progress, failed and done in one card. It does no network I/O: the app
566
+ owns the transfer, keeps `items` (`id`, `name`, `size`, `status`, `progress`, `error`) up to date and
567
+ answers `onRetry(id)` and `onRemove(id)`. A row says its status in text first, and a polite live region
568
+ announces the rows that finish or fail. Pair it with a `Dropzone` set to `hideFileList`:
569
+ `onFilesAdded(files)` gets only the files just accepted, so the app can start uploading them, and
570
+ `onReject(rejections)` keeps the valid files of a drop and reports the others one by one with their
571
+ reason, where without it one invalid file rejects the whole drop.
572
+
573
+ ```tsx
574
+ <Dropzone multiple hideFileList accept=".pdf,.png" maxSize={25 * 1024 * 1024}
575
+ onFilesAdded={(files) => files.forEach(upload)}
576
+ onReject={(rejections) => report(rejections)} />
577
+ <UploadQueue items={uploads} onRetry={retry} onRemove={cancelOrDismiss} />
578
+ ```
579
+
580
+ ## Chat
581
+
582
+ Building blocks for a conversation view: `ChatThread` (a `role="log"` live
583
+ region that stays scrolled to the newest message), `ChatMessage`,
584
+ `ChatDaySeparator`, `ChatTypingIndicator`, `ChatComposer` (Enter sends,
585
+ Shift+Enter adds a line) and `ChatConversationList` for a two-pane layout.
586
+ Message text is always rendered as text, never HTML; http(s) URLs become links.
587
+
588
+ `Chatbot` is a floating widget built on them: a launcher at the bottom right
589
+ opens a panel (full-screen under 480px). It has no network code, the app owns
590
+ the messages and answers `onSend`:
591
+
592
+ ```tsx
593
+ <Chatbot
594
+ messages={messages}
595
+ onSend={(text) => ask(text)}
596
+ typing={waiting}
597
+ suggestions={['Show my alerts']}
598
+ error={failed ? 'The assistant could not be reached.' : undefined}
599
+ onRetry={retry}
600
+ />
601
+ ```
602
+
603
+ A bot message with `streaming: true` shows a caret while its text grows.
604
+
605
+ ### Rich replies (AI assistant, generators)
606
+
607
+ The same blocks build an assistant page; the app owns the conversation and renders any
608
+ Markdown itself.
609
+
610
+ - `ChatMessage` takes `children` in place of `text` (rendered nodes: a `CodeBlock`, a list, an
611
+ `ImageGrid`). `bubble={false}` drops the bubble of an incoming reply so it takes the whole
612
+ column, with the avatar and name on top. While `streaming` the message is `aria-busy`.
613
+ - `actions={<ChatMessageActions text={reply} onRegenerate={…} feedback={…} onFeedbackChange={…} />}`
614
+ adds Copy reply, Regenerate and Good/Bad response under the reply (`aria-pressed`, also
615
+ `defaultFeedback`). With a pointer they show on hover and focus; on touch they always show.
616
+ - `ChatSuggestions` is the row of chips `Chatbot` uses (`suggestions`, `onSelect`).
617
+ - `ChatComposer` takes `footer` (a line under the field: attach, a model `Select`, a hint) and
618
+ `generating` + `onStop`: the send button becomes Stop (`stopLabel`), and Enter does not send.
619
+ - `ChatToolCall` (`name`, `status` of `running | done | failed`, `duration`, `input`, `output`)
620
+ is a disclosure row for what the assistant did; `ChatSources` (`sources`, `label`) lists where
621
+ an answer comes from. Only http(s) sources become links.
622
+ - `Clipboard plain` is the borderless copy button of that row.
623
+
624
+ ## Comments
625
+
626
+ `CommentThread`, `Comment` and `CommentComposer` build the discussion under a record (a ticket, a
627
+ task, an order). The host owns the data: the components take callbacks and keep only which
628
+ replies are unfolded and which composer is open. Body text is rendered as text, never HTML, and
629
+ only http(s) URLs become links.
630
+
631
+ ```tsx
632
+ <CommentThread composer={<CommentComposer onSubmit={(text) => add(text)} />}>
633
+ {comments.map((c) => (
634
+ <Comment
635
+ key={c.id}
636
+ author={c.author}
637
+ role={c.role}
638
+ time={<time dateTime={c.createdAt}>{format(c.createdAt)}</time>}
639
+ edited={c.edited}
640
+ variant={c.internal ? 'internal' : 'default'}
641
+ text={c.text}
642
+ onReply={(text) => reply(c.id, text)}
643
+ onEdit={c.mine ? (text) => edit(c.id, text) : undefined}
644
+ onDelete={c.mine ? () => remove(c.id) : undefined}
645
+ replies={c.replies.map((r) => (
646
+ <Comment key={r.id} author={r.author} text={r.text} />
647
+ ))}
648
+ />
649
+ ))}
650
+ </CommentThread>
651
+ ```
652
+
653
+ - An action shows only when its callback is given, so a host lets people edit only their own
654
+ comments. Reply and Edit open a `CommentComposer` in place (the edit one starts with the
655
+ comment's text; `editText` when the body is `children`) and call back with the trimmed text;
656
+ Cancel and Escape return the focus to the button that opened it. Delete calls back at once:
657
+ confirm in the host if needed.
658
+ - Replies are one level, `Comment`s passed as `replies`, folded beyond `maxReplies` (default
659
+ `2`) behind a "Show 3 more replies" button. A reply has no Reply of its own.
660
+ - `variant="internal"` tints a note and labels it "Internal note" with a lock, so it reads as one
661
+ without the color.
662
+ - `CommentComposer` is a `Textarea` with `autoResize`: Enter adds a line, Ctrl or Cmd+Enter and the
663
+ submit button send, Escape cancels when `onCancel` is given. Submit is disabled while the text
664
+ is empty or `submitting`. `value` / `defaultValue` / `onChange` work as on other fields.
665
+ - `time` is a node, as in `Timeline`: a `RelativeTime`, a `<time>` or your own text.
666
+ - Every text can be translated: `labels` on `CommentThread` reaches all its comments (a
667
+ comment's own `labels` win), `emptyTitle` and `label` on the thread, and the composer's own
668
+ `label`, `placeholder`, `submitLabel` and `cancelLabel`.
669
+
670
+ ## Slider
671
+
672
+ `Slider` is a position on a scale, or a range of it. One `value` (with `defaultValue` and
673
+ `onChange(value)`): a number draws one handle, a pair `[minimum, maximum]` draws two, which can meet
674
+ but never cross. `min`, `max` and `step` default to 0, 100 and 1. `label` sits on the left of the
675
+ header, the current value on the right, and `min` and `max` under the track; `formatValue(n)` writes
676
+ all of them and the value a screen reader announces. `label` also names the handles ("Price minimum"
677
+ and "Price maximum" for a range): without a visible label, give `aria-label`. `marks` draws a tick at
678
+ every `step`, `intent` colors the fill (`brand`, `success`, `warning` or `danger`).
679
+
680
+ ```tsx
681
+ <Slider
682
+ label="Price"
683
+ defaultValue={[200, 800]}
684
+ max={1000}
685
+ step={10}
686
+ formatValue={(n) => `$${n}`}
687
+ onChange={(range) => setPrice(range)}
688
+ />
689
+ ```
690
+
691
+ ## Colour and phone fields
692
+
693
+ `ColorInput` picks a colour from named swatches (a radio group, arrows move between them) or a hex
694
+ field with a preview square. The value is the lowercase `#rrggbb`: the field accepts `#abc`, `abc`
695
+ and `#aabbcc`, and only a valid hex reaches `onChange(hex)`. Leaving the field with an invalid draft
696
+ shows an error and keeps the value. `presets` replaces the eight default swatches (`[]` for the hex
697
+ field alone); each is a `{ value, label }`, and the label is what a screen reader announces.
698
+
699
+ `PhoneInput` joins a country list and the number field in one border. The value is the E.164 number
700
+ (`+33612345678`) or `''`: `onChange(value)` gets `''` until the number is valid, which the light
701
+ check decides (digits only, 4 or more, 15 at most with the country code). The list searches by
702
+ name, ISO code or calling code; names come from `Intl.DisplayNames` in the page language.
703
+ `defaultCountry` (an ISO 3166-1 code, `US` by default) sets the country until a number says
704
+ otherwise, and `countries` limits the list. Typing `+` and a known calling code switches the
705
+ country, picking a country keeps the digits. A calling code shared by several countries keeps the
706
+ current one if it has it, else takes the code's main country. The package carries no phone metadata:
707
+ `format(country, national)` shapes the digits as they are typed and `validate(value)` returns the
708
+ message for a number it rejects, so a host can plug `libphonenumber-js` in.
709
+
710
+ ```tsx
711
+ <ColorInput label="Event colour" value={colour} onChange={setColour} />
712
+
713
+ <PhoneInput
714
+ label="Phone number"
715
+ defaultCountry="FR"
716
+ value={phone}
717
+ onChange={setPhone}
718
+ validate={(v) => (isValidPhoneNumber(v) ? undefined : 'Enter a valid phone number.')}
719
+ />
720
+ ```
721
+
722
+ ## Application shell
723
+
724
+ `Shell` lays out a `Topbar`, a `Sidebar` and the page, and owns the sidebar's
725
+ collapsed state: `defaultSidebarCollapsed`, or `sidebarCollapsed` and
726
+ `onCollapsedChange` to control it. The Topbar shows the toggle, or the bottom of
727
+ the Sidebar does with `collapseControl="sidebar"`.
728
+
729
+ Below `lg` the sidebar leaves the layout and the Topbar's hamburger opens it as a 288px
730
+ drawer over a scrim (`defaultSidebarOpen`, or `sidebarOpen` and `onSidebarOpenChange`
731
+ to control it). It is always expanded there, with a close button in `Sidebar.Brand`, and it
732
+ closes on the scrim, Escape, a swipe to the left, a click on a `Sidebar.Item` and when the
733
+ viewport grows past `lg`. Pass the product logo as the Topbar's `brand` to keep it in
734
+ view while the sidebar is off screen. Any `Drawer` can close on a swipe too, with
735
+ `swipeToClose`: toward its edge on `left` and `right`, downward on `side="bottom"`, the
736
+ bottom sheet (full width, as tall as its content up to 85% of the viewport, a grab `handle`
737
+ you can turn off with `handle={false}`, and a `Drawer.Body` that scrolls past that height).
738
+
739
+ The expanded `Sidebar` follows its content: from `width` (264px, its minimum) it grows to
740
+ fit its widest visible row, the deep levels of a tree included, up to `maxWidth` (320px). A
741
+ label that needs more ends in an ellipsis and its row shows the full label in a tooltip. Set
742
+ `maxWidth` to the same value as `width` for a fixed width. The collapsed rail (`collapsedWidth`)
743
+ and the drawer keep their own widths.
744
+
745
+ A collapsed `Sidebar` can open while the pointer rests on it, with `expandOnHover`:
746
+ `"overlay"` opens it above the page after 150 ms, with a shadow, and the page does not move;
747
+ `"push"` widens it and the page makes room. Keyboard focus entering the sidebar does the same,
748
+ and Escape closes it. It only applies from `lg` up, on a pointer that can hover, never in the
749
+ drawer, and it does not touch the collapsed state: `onCollapsedChange` is not called and the
750
+ toggles keep their state.
751
+
752
+ `SidebarToggle` is that toggle, to place anywhere else in the Shell (a header of
753
+ your own, the Topbar's `leftActions`, the Sidebar's `footer`) with no props: it
754
+ reads the Shell's state, and every toggle stays in sync. Outside a Shell, pass it
755
+ `collapsed` and `onCollapsedChange`.
756
+
757
+ ```tsx
758
+ <Shell
759
+ topbar={<Topbar pageTitle="Servers" />}
760
+ sidebar={<Sidebar footer={<SidebarToggle variant="panel" />}>{/* items */}</Sidebar>}
761
+ >
762
+ {/* page */}
763
+ </Shell>
764
+ ```
765
+
766
+ ## Split panes
767
+
768
+ `SplitView` puts two or three `SplitView.Pane` side by side in one bordered frame (mail,
769
+ chat, a file manager). It fills the height of its parent and each pane scrolls on its own;
770
+ `width` is a Tailwind class (`w-56`), and a pane without one takes the remaining space. Below
771
+ `collapseBelow` (`md`, `lg` by default, `xl`) only the active pane shows. Control it with
772
+ `activePane` and `onActivePaneChange`, or start it with `defaultActivePane` and switch from
773
+ inside with `useSplitView()`; the pane that appears takes focus. A Back button is yours to
774
+ place.
775
+
776
+ ```tsx
777
+ <SplitView activePane={pane} onActivePaneChange={setPane}>
778
+ <SplitView.Pane id="list" label="Messages" width="w-80">
779
+ {/* list */}
780
+ </SplitView.Pane>
781
+ <SplitView.Pane id="detail" label="Message">
782
+ {/* detail */}
783
+ </SplitView.Pane>
784
+ </SplitView>
785
+ ```
786
+
67
787
  ## Themes and accents
68
788
 
69
- The theme is a class on `<html>` and the accent a `data-accent` attribute, see
70
- the [theme README](../theme#themes--accents). `AppearanceSettings` renders a
71
- picker for each, and `applyAppearance` sets them on the document:
789
+ The theme is a class on `<html>`; the accent, the font and the text size are
790
+ the `data-accent`, `data-font` and `data-text-size` attributes, see the
791
+ [theme README](../theme#themes--accents). `AppearanceSettings` renders a picker
792
+ for each, and `applyAppearance` sets them on the document:
72
793
 
73
794
  ```tsx
74
795
  import { useEffect, useState } from 'react';
@@ -78,26 +799,53 @@ function Appearance() {
78
799
  const [appearance, setAppearance] = useState<CosmosAppearance>({
79
800
  theme: 'light',
80
801
  accent: 'blue',
802
+ font: 'outfit',
803
+ textSize: 'md',
81
804
  });
82
805
  useEffect(() => applyAppearance(appearance), [appearance]);
806
+ const update = (patch: Partial<CosmosAppearance>) => setAppearance((a) => ({ ...a, ...patch }));
83
807
  return (
84
808
  <AppearanceSettings
85
- theme={appearance.theme}
86
- accent={appearance.accent}
87
- onThemeChange={(theme) => setAppearance((a) => ({ ...a, theme }))}
88
- onAccentChange={(accent) => setAppearance((a) => ({ ...a, accent }))}
809
+ {...appearance}
810
+ onThemeChange={(theme) => update({ theme })}
811
+ onAccentChange={(accent) => update({ accent })}
812
+ onFontChange={(font) => update({ font })}
813
+ onTextSizeChange={(textSize) => update({ textSize })}
89
814
  />
90
815
  );
91
816
  }
92
817
  ```
93
818
 
94
- For a top bar, `AppearanceMenu` takes the same props and puts the two pickers
95
- in a panel behind an icon button, for example in `Topbar`'s `rightActions`.
819
+ The font and text size groups show only when their handlers are set. Pass
820
+ `fonts` to offer only the fonts whose CSS the app imports
821
+ (`@sckyzo/cosmos-theme/fonts/<name>`).
822
+
823
+ For a top bar, `AppearanceMenu` takes the same props and puts the pickers in a
824
+ panel behind an icon button, for example in `Topbar`'s `rightActions`. Set `onReset`
825
+ to add a Reset button under the groups.
826
+
827
+ To offer a **System** choice that follows the device's `prefers-color-scheme`,
828
+ add `COSMOS_SYSTEM_THEME` to the themes and apply again when the device switches
829
+ (it resolves to `light` or `dark`; set `systemThemes` for another pair). Nothing
830
+ changes for an app that doesn't opt in (`ThemeToggleTri` takes a `system` prop for the same
831
+ choice at the end of its cycle):
832
+
833
+ ```tsx
834
+ const [appearance, setAppearance] = useState<CosmosAppearance>({ theme: 'system', accent: 'blue' });
835
+ useEffect(() => {
836
+ applyAppearance(appearance);
837
+ return appearance.theme === 'system'
838
+ ? watchSystemTheme(() => applyAppearance(appearance))
839
+ : undefined;
840
+ }, [appearance]);
841
+ // …
842
+ <AppearanceSettings {...appearance} themes={[COSMOS_SYSTEM_THEME, ...COSMOS_THEMES]} … />
843
+ ```
96
844
 
97
845
  Saving the choice (local storage, user profile) is left to the app.
98
846
 
99
847
  Charts live in a separate package, [`@sckyzo/cosmos-charts`](../charts), so apps
100
- that don't render charts don't ship the ApexCharts bundle.
848
+ that don't render charts don't ship a chart library.
101
849
 
102
850
  ## License
103
851