@sckyzo/cosmos-react 0.3.0 → 0.4.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
@@ -125,6 +125,12 @@ chat messages stay a plain `<a>`, and a provider inside another one replaces it
125
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
126
  - `SegmentedControl` options take a `badge` (a count in a small pill, part of the radio's accessible name).
127
127
  - `PageHeader icon={Plug} iconIntent="brand"`: a decorative icon tile left of the title.
128
+ - `PageHeader breadcrumb={…} description="…"`: the breadcrumb sits above the title and the description under it; both can be passed together. Below `sm` the `actions` wrap under the meta, at the start of the line, and the icon tile stays beside the text column.
129
+ - `ErrorState onRetry={reload} retryLabel="Reload" action={<Button>Back to tickets</Button>}`: `retryLabel` renames the retry button (default "Try again"); `action` is a way out that is not a retry, next to it (the retry first), or alone without `onRetry`.
130
+ - `EmptyState` `children`: rendered under the description and before the `action`, start-aligned and up to 28rem wide: a short numbered list of setup steps, for instance.
131
+ - `FormRow`: label and description at the start, the control at the end. Below `sm` they stack, the label and its description above the control, which takes the row's width (a `Select` with `fullWidth={false}` stays at its own width, at the start).
132
+ - `StickyScrollNav sections={[{ id, label }]}` (a long page with anchors, such as a settings page): below `sm` the row of buttons folds into a native select, named "Jump to section" (`jumpLabel`), that shows the section being read and scrolls to the one picked, below the nav like the buttons do. From `sm` up nothing changes. `VerticalNavigation` is a list of links to pages, not anchors, and keeps its look.
133
+ - `KpiCard value="€12.4M" exactValue="€12,418,906.40"`: the exact figure behind an abbreviated value. A tooltip shows it on hover and focus, and it is read as part of the card's name ("…€12.4M, exact value €12,418,906.40"). On a plain card the value becomes a tab stop to reach the tooltip; a link or button card shows it when the card takes keyboard focus, and stays one tab stop.
128
134
  - `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
135
  - `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
136
  - `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.
@@ -186,8 +192,8 @@ checkbox, the action column, a link or button in another cell) never counts as a
186
192
  row click. `RowActionsMenu` is the ⋮ button and menu of a row, for
187
193
  `renderRowAction`: give it `Menu.Item`s, a destructive one with
188
194
  `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
195
+ fades, and `Table`'s `stickyFirstColumn` pins its first column (`DataTable` pins
196
+ columns with `pin`, below). `TableIdentityCell`, `TableIdCell`, `TableDescriptionCell` and
191
197
  `TableAmountCell` are the cells tables keep rebuilding.
192
198
 
193
199
  `DataTable`'s column filters can be controlled: `filters`, `defaultFilters` and
@@ -201,11 +207,137 @@ filters bar; the counts come from you, the table never computes them.
201
207
  instead of scrolling: the checkbox, the column marked `mobile: 'title'` and the
202
208
  row action on the first line, then a label/value pair per other column
203
209
  (`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.
210
+ gets a "Sort by" select for the sortable columns and, when a column is
211
+ filterable, a "Filters" button with the number of values filtered: it opens a
212
+ bottom sheet with the filter of each filterable column, "Clear all" and "Show n
213
+ rows", and sets the same `filters` as the column headers, controlled or not. The
214
+ default, `scroll`, keeps the table.
215
+
216
+ With `selectable`, a selection puts a tinted bar on one row: the count, "Clear"
217
+ and the bulk actions on the start side, the search and the toolbar on the end
218
+ side. `bulkActionItems` takes the actions as data (`{ key, label, icon,
219
+ intent, disabled, onClick(selectedRows) }`) so the bar can adapt: the first is a
220
+ button and, under 1152px of bar, the others fold into an "Actions" menu; the
221
+ toolbar buttons go icon-only and the search narrows under 1360px; under 640px
222
+ the bar wraps to two lines; with `mobileLayout="cards"` on a phone each action is
223
+ an icon button named by its `label`. `bulkActions(selectedRows)` still renders
224
+ any node after them, as given at every width. While rows are selected the
225
+ toolbar's Export follows them ("Export 3" downloads those rows, in the table's
226
+ sort order, and `onExport` gets their keys as `selection`); with none selected it
227
+ exports the filtered rows.
228
+
229
+ `emptyAction` puts a call to action under the empty state ("Create the first
230
+ order"). When a search or filters hide every row it gives way to a "Clear
231
+ filters" button, which clears both.
232
+
233
+ `DataTable` rows can expand: `renderExpandedRow(row)` adds a first column of
234
+ chevron buttons (`aria-expanded`, `aria-controls` the detail row while it is
235
+ open, named "Show details for <row key>") and draws what it returns in a row
236
+ below, spanning every column. The open rows are a set of row keys, like the
237
+ selection: `expandedRows`, `defaultExpandedRows` and
238
+ `onExpandedRowsChange(expanded)`. A toggle never selects a row nor counts as a
239
+ click on it. Limit: with `virtualize` nothing expands (its rows have one fixed
240
+ height), and the prop is ignored. With `mobileLayout="cards"` the chevron sits on
241
+ the card's title line and the content opens inside the card, under its fields.
242
+
243
+ `pin: 'start' | 'end'` on a column keeps it in place while the table scrolls
244
+ sideways, several per side: list the `start` ones first and the `end` ones last
245
+ in `columns`. A pinned cell sticks after the widths of the pinned columns before
246
+ it, measured, so percentage widths, a resize and the text size keep it right.
247
+ The selection and toggle columns are pinned with the start columns, the action
248
+ column with the end ones. A shadow marks an edge only while content scrolls
249
+ under it, and the scroll fade leaves a pinned side alone. `pin` replaces
250
+ `DataTable`'s `stickyFirstColumn` (removed): put `pin: 'start'` on the first
251
+ column. With `rowHref`, the link covers its own column when that is pinned and
252
+ a click on the rest of the row follows it from a handler. The cards of
253
+ `mobileLayout="cards"` ignore `pin`.
254
+
255
+ ```tsx
256
+ <DataTable
257
+ rows={hosts}
258
+ rowKey={(r) => r.host}
259
+ selectable
260
+ renderExpandedRow={(r) => <HostDetail host={r} />}
261
+ columns={[
262
+ { key: 'host', label: 'Host', pin: 'start', render: (r) => r.host },
263
+ { key: 'rack', label: 'Rack', render: (r) => r.rack },
264
+ // …
265
+ {
266
+ key: 'status',
267
+ label: 'Status',
268
+ pin: 'end',
269
+ render: (r) => <StatusBadge status={r.status} />,
270
+ },
271
+ ]}
272
+ />
273
+ ```
274
+
275
+ Inline editing: `edit` on a column is `{ initialValue(row), render(editor) }`,
276
+ and `render` draws an existing form field from the editor: `editor.value` (the
277
+ draft, a string: parse it yourself), `setValue`, `commit`, `cancel`, `saving`
278
+ and `fieldProps` (the field's name, "Rack for node-a02", its error tie, `disabled`
279
+ while saving and a red border on `aria-invalid`), which an `Input` or a `Select`
280
+ takes as it is. `editTrigger` picks how an editable cell opens, one per table:
281
+
282
+ - `'cell'` (default): the cell's content is a button that fills it; a click,
283
+ Enter or F2 opens the editor in place, focused. Keep that content to text and
284
+ badges, with no link or button in it.
285
+ - `'button'`: a pencil button in each editable cell ("Edit Rack for node-a02"),
286
+ shown on the hovered or focused row and always on a screen with no hover; the
287
+ editor has Save and Cancel buttons beside the field.
288
+ - `'row'`: an Edit button in the row's action column turns every editable cell
289
+ of the row into its field, with Cancel and Save in place of the button.
290
+
291
+ In `'cell'` and `'button'`, Enter or blur saves and Escape cancels, putting the
292
+ focus back on the cell's trigger. `onCellEdit(row, columnKey, value)` is called
293
+ only when the draft changed, and may return a promise: the cell is `aria-busy`
294
+ with a spinner until it settles. If it rejects (or throws), the editor opens
295
+ again with what was typed, and the error's message shows under the cell, tied to
296
+ the field by `aria-describedby`. In `'row'`, Save (or Enter in a field) calls
297
+ `onRowEdit(row, changes)` once, `changes` being column `key` to draft for the
298
+ cells that changed only; a promise makes the row `aria-busy` and the fields
299
+ disabled, and a rejection keeps the fields open with the error in a row under
300
+ the row. Escape or Cancel drops the changes. Only one cell or row edits at a
301
+ time, and nothing opens while a save is pending. The table never changes
302
+ `rows`: update them from the handler. Limits: editing is off with `virtualize`
303
+ (a fixed-height cell would clip an editor and its error) and in the cards of
304
+ `mobileLayout="cards"`.
305
+
306
+ ```tsx
307
+ <DataTable
308
+ rows={hosts}
309
+ rowKey={(r) => r.host}
310
+ editTrigger="cell"
311
+ onCellEdit={async (row, key, value) => {
312
+ await api.patchHost(row.host, { [key]: value }); // a rejection shows under the cell
313
+ setHosts((all) => all.map((h) => (h.host === row.host ? { ...h, [key]: value } : h)));
314
+ }}
315
+ columns={[
316
+ { key: 'host', label: 'Host', render: (r) => r.host },
317
+ {
318
+ key: 'rack',
319
+ label: 'Rack',
320
+ render: (r) => r.rack,
321
+ edit: {
322
+ initialValue: (r) => r.rack,
323
+ render: (e) => (
324
+ <Input
325
+ {...e.fieldProps}
326
+ size="sm"
327
+ value={e.value}
328
+ onChange={(ev) => e.setValue(ev.target.value)}
329
+ />
330
+ ),
331
+ },
332
+ },
333
+ ]}
334
+ />
335
+ ```
206
336
 
207
337
  `DataTable`'s `density` is controllable like the rest: `density`, `defaultDensity`
208
- and `onDensityChange(density)`. As with `pageSize`, a `density` passed without
338
+ and `onDensityChange(density)`: `compact`, `default` or `comfortable`. Without
339
+ `density` and `defaultDensity` the table follows the page's `data-density`; set,
340
+ it is a local override of the page. As with `pageSize`, a `density` passed without
209
341
  `onDensityChange` is a controlled value that never moves, so a saved view
210
342
  reports its density and the table keeps the one you pass.
211
343
 
@@ -222,7 +354,8 @@ mark (U+FEFF), so that spreadsheets read accents. Fields follow RFC 4180
222
354
  header or string value that starts with `=`, `+`, `-`, `@`, a tab or a carriage
223
355
  return gets a `'` prefix so a spreadsheet never runs it as a formula; a
224
356
  `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.
357
+ sort, columns }` (the visible column keys, in order, plus `selection`, the
358
+ selected keys, while rows are selected) and builds no file.
226
359
  `toCsv(rows, columns)` gives the same text, without the byte order mark, for an
227
360
  export outside a table (prepend U+FEFF for a file meant for spreadsheets).
228
361
 
@@ -332,13 +465,13 @@ scrolling. Its limits:
332
465
 
333
466
  - The table needs a height (`className="max-h-96"`): its own area is what
334
467
  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
468
+ - Every row has one fixed height, from the density: 41px when `compact`, 49px,
469
+ or 61px when `comfortable`, at the default text size, and growing with the text size
337
470
  setting. Content taller than that is clipped, so keep a cell to a line of text
338
471
  or a control, as in a standard row.
339
472
  - It applies to the table layout only: with `mobileLayout="cards"` below `sm`
340
473
  the cards keep the pagination.
341
- - It is not for expandable rows.
474
+ - It is not for expandable rows or inline editing: both are off with it.
342
475
  - On paper only the drawn rows print.
343
476
 
344
477
  ```tsx
@@ -352,11 +485,12 @@ scrolling. Its limits:
352
485
  />
353
486
  ```
354
487
 
355
- `Table` takes `density` (`default` or `comfortable`, as on `DataTable`),
488
+ `Table` takes `density` (`compact`, `default` or `comfortable`, as on `DataTable`; the
489
+ page's by default),
356
490
  `striped`, `stickyHeader` (give the wrapper a height with
357
491
  `containerClassName`, e.g. `max-h-80`) and `stickyFirstColumn`. While data
358
492
  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
493
+ (`start`, `center` or `end`; `numeric` aligns to the end, with tabular figures). `TableHeaderCell` sets `scope` to `col` in the
360
494
  head and `row` elsewhere. The wrapper scrolls horizontally, so a wide table
361
495
  stays inside the page on small screens.
362
496
 
@@ -487,6 +621,144 @@ the locale nor the zone, and keep the room of their text until then.
487
621
  Every one of them takes `now`, which freezes it at that moment and sets no timer: use it for
488
622
  stories, screenshots and tests.
489
623
 
624
+ ## Health checks
625
+
626
+ `HealthSummary`, `HealthCheckList` and `HealthMatrix` show whether a system and its parts are
627
+ healthy, and why not. They build on `HealthStatus` (`OK`, `WARN`, `CRIT`, `UNKNOWN`, `INFO`) and on
628
+ the dots and badges of the same folder.
629
+
630
+ A check is plain data. A group holds checks and, below them, other groups: one level per dependency
631
+ reads as a tree. A group has no status of its own, it is the worst status under it.
632
+
633
+ ```tsx
634
+ const checks: HealthCheck[] = [
635
+ {
636
+ id: 'queue',
637
+ name: 'Job queue',
638
+ status: 'WARN',
639
+ target: 'redis://queue:6379/0',
640
+ message: '312 jobs waiting (limit 200)',
641
+ latency: 4, // milliseconds
642
+ checkedAt: '2026-01-15T09:59:30Z', // a Date, a timestamp or an ISO string
643
+ output: 'queue=default depth=312 oldest=94s', // shown in a code block, on demand
644
+ },
645
+ ];
646
+ const groups: HealthCheckGroup[] = [{ id: 'queue', name: 'Queue', checks, groups: [] }];
647
+
648
+ <HealthSummary checks={flattenChecks(groups)} refresh={{ onRefresh, autoRefreshMs, onIntervalChange }} />
649
+ <HealthCheckList groups={groups} sort="severity" />
650
+ <HealthMatrix ariaLabel="Services by node" columns={nodes} rows={services} />
651
+ ```
652
+
653
+ Everything that sorts or rolls up uses one **severity order**, worst first: `CRIT`, `WARN`,
654
+ `UNKNOWN`, `INFO`, `OK`. A check nobody could run weighs more than one that passes. `SEVERITY_ORDER`,
655
+ `compareSeverity` and `worstStatus` are exported, as are `flattenChecks` and `groupWorst`. A set with
656
+ nothing in it is `UNKNOWN`.
657
+
658
+ - `HealthSummary` takes the worst status of its `checks`, and `status` overrides it when you weigh the
659
+ checks differently (the counts still come from `checks`). The headlines are "All systems
660
+ operational", "Degraded performance", "Major outage", "Status unknown" and "Operational, with
661
+ notices"; `labels` replaces any of them. It also shows the count per status, the last check
662
+ (`RelativeTime`, from the latest `checkedAt`, or `checkedAt`) and, with `refresh` (the props of
663
+ `RefreshButton`), a refresh action. `variant="banner"` is full width and tinted by the status,
664
+ `variant="compact"` is one line for a card or a topbar.
665
+ - `HealthCheckList` draws rows with the status in words, the name, target, message, latency and last
666
+ check. A check with an `output` has a toggle on its name. Pass `checks`, `groups` or both. A group
667
+ header shows the worst status and the counts under it and collapses
668
+ (`collapsedGroups` / `defaultCollapsedGroups` / `onCollapsedGroupsChange`, by group id).
669
+ `sort="severity"` puts the failing checks, and the groups holding them, first. `variant="list"` is
670
+ rows, `variant="grid"` one small card per check, for a wall screen.
671
+ - `HealthMatrix` is one cell per service (`rows`) and per environment, node or region (`columns`),
672
+ with sticky headers, in a `role="grid"` that takes one tab stop: arrow keys move between cells,
673
+ Home and End go to the ends of the row. A cell opens its detail in a popover (`message`, `latency`,
674
+ `checkedAt`, and a `detail` node of your own) with Enter, Space or a click; Escape closes it and the
675
+ focus returns to the cell. Each cell is named like "API on par-2b: Warning", and a column with no
676
+ entry is an empty cell. `variant="dots"` is dense, and every dot has a shape as well as a colour: OK a
677
+ filled circle, warning a diamond, critical a square, unknown a hollow ring, info a triangle. The
678
+ legend above the grid shows the same shapes. `variant="pills"` writes the status in each cell. The
679
+ grid scrolls in its own box when it is wider than the screen; `maxHeight` also scrolls it
680
+ vertically. `rowHeader` names the first header cell ("Service" by default; a cluster says "Rack").
681
+ `attentionStrip` adds a "Needs attention" strip above the grid: one chip per row that holds a
682
+ warning or a critical cell, with its counts in words ("2 Critical, 1 Warning"), the worst rows
683
+ first. Picking a chip scrolls its row into view, in the grid's own box when `maxHeight` is set,
684
+ and moves the focus to its worst cell; on a phone the strip scrolls sideways.
685
+
686
+ `HealthSummary`, `HealthMatrix` and `MonitorRow` (see Uptime monitoring) take `stale`, the time of the
687
+ last data, for a source that has stopped answering. The status colours turn grey, the component says
688
+ "Stale · 4 minutes ago" and the stale state is in the accessible text (the headline, each matrix
689
+ cell, the monitor's state chip). The statuses shown stay those of the last data.
690
+
691
+ Every one takes `now`, which freezes the relative times, for stories and tests. One check shown as a
692
+ card, and a dependency tree, need no component of their own: see the stories of the Monitoring group.
693
+
694
+ ## Uptime monitoring
695
+
696
+ `HeartbeatBar`, `UptimeBars`, `UptimeStat` and `MonitorRow` draw what a monitor found: its recent
697
+ checks, its uptime over days and periods, and one monitor as a line or a tile. A monitor checks a
698
+ target every N seconds; these show the result, and the host brings the data.
699
+
700
+ A **beat** is one check, and a **day** is one day of history. `time` and `date` take a `Date`, a
701
+ timestamp in milliseconds or an ISO 8601 string (a bare `YYYY-MM-DD` names that calendar day for every
702
+ reader, whatever their time zone).
703
+
704
+ ```tsx
705
+ const beats: Beat[] = [
706
+ { time: '2026-01-15T06:41:10Z', status: 'up', latency: 182 },
707
+ { time: '2026-01-15T06:42:10Z', status: 'down', latency: 5003, message: 'Timeout after 5 s' },
708
+ ]; // oldest first; status: 'up' | 'down' | 'degraded' | 'maintenance' | 'pending'
709
+ const days: UptimeDay[] = [
710
+ { date: '2026-01-14', uptime: 0.9995, incidents: 1 }, // 0 to 1; null is a day with no data
711
+ { date: '2026-01-15', uptime: null },
712
+ ];
713
+
714
+ <HeartbeatBar beats={beats} />
715
+ <UptimeBars days={days} title="API" legend />
716
+ <UptimeStat periods={[{ label: '30 days', uptime: 0.9991 }]} responseTime={182} certificate={{ daysLeft: 12 }} />
717
+ <MonitorRow name="API" type="HTTP" beats={beats} uptime={0.9992} />
718
+ ```
719
+
720
+ **Thresholds.** An uptime is coloured by one shared rule: 99.9 % and up is green, 99 % up to 99.9 %
721
+ is amber, below 99 % is red, and `null` is grey ("No data"). Every component that colours by uptime
722
+ (`UptimeBars`, `UptimeStat`, `MonitorRow`) takes `thresholds`, a partial `{ good, warning }` in shares
723
+ from 0 to 1 (`{ good: 0.9999, warning: 0.999 }`), and the legend of `UptimeBars` follows it. A
724
+ `warning` limit above `good` is lowered to it. `DEFAULT_THRESHOLDS`, `resolveThresholds` and
725
+ `uptimeLevel` are exported, to colour your own figures the same way. The status colours are the
726
+ theme's (`--color-status-ok`, `-warn`, `-crit`), so a theme or an accent repaints them.
727
+
728
+ - `HeartbeatBar` draws one mark per beat, the newest at the end. `variant` is `bars` (default),
729
+ `blocks` or `dots`; `size` is `md` (32px, default) or `sm` (24px, the height inside a `MonitorRow`).
730
+ It holds as many beats as its width allows and drops the oldest, so a 375px screen still shows the
731
+ latest history: give it the width of its container. A `down` beat differs by shape as well as
732
+ colour (a taller bar, a block crossed by a diagonal, a square dot) and every beat is named in words
733
+ (status, time, latency, message). The bar is one tab stop on the newest beat; the arrow keys, Home
734
+ and End move between beats, and hovering or focusing one shows its tooltip (Escape hides it).
735
+ `legend` writes the key of the statuses under the bar.
736
+ - `UptimeBars` draws one bar per day over `period` 30, 60 or 90 days (default 90), coloured by the
737
+ thresholds. The header gives `title` and the uptime of the period (the mean of the days with data,
738
+ or `uptime` when you have the exact figure), the captions read "90 days ago" and "Today". When the
739
+ width cannot hold every day, the oldest are dropped and the caption and the figure describe what is
740
+ drawn. Days are named ("Jan 15, 2026: 99.95% uptime, 1 incident"), reached with the same keyboard
741
+ model as `HeartbeatBar`, and `legend` writes the thresholds. `compact` is the form for a card: lower
742
+ bars, no header, captions or legend.
743
+ - `UptimeStat` has two variants. `row` (default) puts the uptime of each of your `periods` side by
744
+ side (`{ label: '24 hours', uptime }`: you name the periods), then `responseTime` and the
745
+ `certificate`: days left, a warning (an icon, and the amber colour) under `warnBelow` days, 14 by
746
+ default. `card` is one figure for a dashboard tile: the first period, and the downtime it stands for
747
+ ("39 min down") when the period has a `duration` in milliseconds, with `days` drawn as compact bars.
748
+ It is a variant of its own, not a `KpiCard` preset.
749
+ - `MonitorRow` shows the state (the status of the last beat, `status` to force it, `paused` to
750
+ pause it), the name (a link with `href`) and `type` (any string: `HTTP`, `TCP`, `DNS`, `ping`,
751
+ `keyword`), a 24px `HeartbeatBar`, the uptime over 24 hours, and the current latency (default: the
752
+ last beat's). `variant="row"` is a line of a list, which the host draws: it stacks on a narrow
753
+ screen. `variant="card"` is a tile for a grid. `stale` (with `now` to freeze its text) greys the
754
+ chip, the beats and the uptime and adds a "Stale · 4 minutes ago" tag under the name.
755
+
756
+ The response time (a `LineAreaChart` from `@sckyzo/cosmos-charts`, its `referenceBands` shading the
757
+ down periods; the stories draw a plain SVG, since this package has no chart dependency), the timeline
758
+ of an incident (`Timeline`) and a maintenance notice (`AlertBanner`) need no component of their own:
759
+ see the stories of the Monitoring group, and the two example pages (a public status page and a
760
+ monitors dashboard).
761
+
490
762
  ## Auth layouts
491
763
 
492
764
  `AuthLayout` wraps the sign-in, sign-up, reset and two-step forms. `variant` picks the page:
@@ -656,17 +928,229 @@ only http(s) URLs become links.
656
928
  Cancel and Escape return the focus to the button that opened it. Delete calls back at once:
657
929
  confirm in the host if needed.
658
930
  - 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.
931
+ `2`) behind a "Show 3 more replies" button. A reply has no Reply of its own. For a ticket, where
932
+ the latest exchanges matter, `keepReplies="last"` keeps the last `maxReplies` visible and folds
933
+ the older ones behind a button above them ("Show 3 earlier replies", `labels.showEarlierReplies`).
660
934
  - `variant="internal"` tints a note and labels it "Internal note" with a lock, so it reads as one
661
935
  without the color.
662
936
  - `CommentComposer` is a `Textarea` with `autoResize`: Enter adds a line, Ctrl or Cmd+Enter and the
663
937
  submit button send, Escape cancels when `onCancel` is given. Submit is disabled while the text
664
938
  is empty or `submitting`. `value` / `defaultValue` / `onChange` work as on other fields.
939
+ - `modes` gives the composer a segmented control above the field, for what is being written
940
+ (`{ value: 'reply', label: 'Reply to customer' }`, `{ value: 'note', label: 'Internal note',
941
+ internal: true }`). `onSubmit(text, mode)` receives the selected mode's `value`; without `modes`
942
+ it gets the text alone, as before. `mode` / `defaultMode` / `onModeChange` work as on other
943
+ fields, and a mode may carry its own `placeholder` and `submitLabel`. An `internal` mode tints
944
+ the field and the submit button like `variant="internal"` tints a comment, with a lock and the
945
+ line `internalNotice` ("Internal note: only your team sees this."), announced when the mode
946
+ changes. `modeLabel` names the control.
665
947
  - `time` is a node, as in `Timeline`: a `RelativeTime`, a `<time>` or your own text.
666
948
  - Every text can be translated: `labels` on `CommentThread` reaches all its comments (a
667
949
  comment's own `labels` win), `emptyTitle` and `label` on the thread, and the composer's own
668
950
  `label`, `placeholder`, `submitLabel` and `cancelLabel`.
669
951
 
952
+ ## Log views
953
+
954
+ `LogViewer`, `LogToolbar`, `LogLevelBadge` and `LogEntryDetail` read a log: the live tail, its
955
+ controls, a level label and one record in full. The host owns the data: it fetches the snapshot,
956
+ streams the new lines (SSE, WebSocket), trims the buffer and decides what "clear" and "download"
957
+ do. The components draw what they are given.
958
+
959
+ ```tsx
960
+ const [lines, setLines] = useState<LogLine[]>([]); // oldest first, ids unique and stable
961
+ const [follow, setFollow] = useState(true);
962
+ const [levels, setLevels] = useState<readonly LogLevelKey[]>(['debug', 'info', 'warn', 'error']);
963
+ const [search, setSearch] = useState('');
964
+ const [active, setActive] = useState(0);
965
+ const [matches, setMatches] = useState(0);
966
+
967
+ <LogToolbar
968
+ live={follow}
969
+ onLiveChange={setFollow}
970
+ levels={['debug', 'info', 'warn', 'error']}
971
+ levelCounts={countLogLevels(lines)}
972
+ activeLevels={levels}
973
+ onActiveLevelsChange={setLevels}
974
+ search={search}
975
+ onSearchChange={(text) => {
976
+ setSearch(text);
977
+ setActive(0); // a new search starts at its first match
978
+ }}
979
+ matchCount={matches}
980
+ activeMatch={active}
981
+ onActiveMatchChange={setActive}
982
+ onClear={() => setLines([])}
983
+ />
984
+ <LogViewer
985
+ aria-label="Backend logs"
986
+ lines={lines}
987
+ follow={follow}
988
+ onFollowChange={setFollow}
989
+ activeLevels={levels}
990
+ search={search}
991
+ activeMatch={active}
992
+ onMatchCountChange={setMatches}
993
+ />
994
+ ```
995
+
996
+ **Data model.** A `LogLine` is `{ id, time, level?, source?, message, fields?, stack? }`. `time` is
997
+ a `Date`, a timestamp in milliseconds or an ISO string, shown with milliseconds. `level` is
998
+ `trace`, `debug`, `info`, `warn`, `error` or `fatal` in any case; anything else (`warning`,
999
+ `notice`, nothing) is `unknown`. `lines` is always oldest first, whatever the `order` it is drawn
1000
+ in, and each `id` must stay the same from one render to the next: the viewer uses it to key rows
1001
+ and to count the lines that arrived while it was paused.
1002
+
1003
+ **`LogViewer`**
1004
+
1005
+ - `tone`: `terminal` (default) is a dark panel in every theme, as a console is; `surface` follows
1006
+ the theme, for a card on a dashboard. `density`: `compact` (default) is one line per record,
1007
+ `comfortable` puts the message under its time, level and source. `columns` hides parts of the
1008
+ row (`['time']`), `lineNumbers` numbers the lines in the order of `lines`.
1009
+ - `follow` / `defaultFollow` / `onFollowChange`: it stays at the newest line while lines arrive.
1010
+ Scrolling away from the end pauses it and shows "Jump to latest (12 new)"; the button, or
1011
+ scrolling back to the end, resumes it; a filter that shortens the list does not. `order` puts
1012
+ the newest line last (`newest-last`, the default, a tail) or first.
1013
+ - The row follows the width of the viewer, not the screen's: the viewer is a container. Under
1014
+ 28rem the source column is hidden; without `wrap` the row stays on one line and the viewer
1015
+ scrolls sideways inside itself, with `wrap` the message goes on a line of its own under the
1016
+ time and the level. Give the viewer a width (it fills a block parent; in a flex row, a `flex-1`
1017
+ or a width class), since a container takes none from its content.
1018
+ - `activeLevels` and `activeSources` filter the lines (left out or, for sources, empty, shows
1019
+ all). `search` highlights a case-insensitive term in the messages and sources, `searchRegex`
1020
+ reads it as a pattern (one that does not compile finds nothing). `activeMatch` is the active
1021
+ match, counted from 0 among the lines that match; it is drawn stronger and scrolled into view
1022
+ when it changes. `onMatchCountChange` reports how many lines match. Reset `activeMatch` to 0
1023
+ when the search changes, as the example does; `LogToolbar` steps it with previous and next
1024
+ (and Enter / Shift+Enter), wrapping around the ends.
1025
+ - A row with `fields` or a `stack` has a button that expands it in place
1026
+ (`expandedIds` / `defaultExpandedIds` / `onExpandedIdsChange`). `onOpenLine` adds an "Open
1027
+ details" button to the expanded row, to open a `LogEntryDetail` in a drawer.
1028
+ - Messages, sources, fields and stacks are text, never HTML. A bidirectional control character
1029
+ stays inside its own message.
1030
+
1031
+ **`LogToolbar`.** Each control is there when its props are given: Live / Paused
1032
+ (`onLiveChange`), level toggles with counts (`levels`), search (`onSearchChange`) with a regex
1033
+ toggle (`onSearchRegexChange`) and previous / next (`matchCount` and `onActiveMatchChange`), a
1034
+ source filter on `MultiSelect` (`sources`), `onWrapChange`, `onOrderChange`, `onDownload` and
1035
+ `onClear`. From `sm` up it is one bar. Below, Live / Paused and the search stay in the bar and
1036
+ the levels, sources and view options move into a "Filters" popover; its button shows how many
1037
+ filters are active (levels narrowed, sources chosen). The state pairs (`live`, `activeLevels`,
1038
+ `search`, `searchRegex`, `activeSources`, `wrap`, `order`) work controlled or not, as on other
1039
+ fields. It holds none of the log: pass the same filters to the viewer.
1040
+
1041
+ **`LogLevelBadge`** is the level as a word in its colour (`ERROR`, `WARN`), on the `surface` tone
1042
+ by default or the `terminal` one. Use it in a table cell, a card or a detail.
1043
+
1044
+ **`LogEntryDetail`** shows one record in full for a drawer or a side panel: level, time, source
1045
+ and message, the fields as key / value pairs with a copy button each, the stack trace, the raw
1046
+ JSON, and "Show context", the lines around it (a `context` prop, or fetched through
1047
+ `onShowContext`). `onFilterValue` and `onExcludeValue` add "Filter on this value" and "Exclude
1048
+ this value" buttons to each field. A structured table is a `DataTable` with a `LogLevelBadge`
1049
+ column and, once rows can expand, a `LogEntryDetail` in them.
1050
+
1051
+ **Accessibility.** The viewer is `role="log"` with `aria-live="off"`: a fast stream read aloud
1052
+ line by line cannot be followed, so new lines are not announced. `announceErrors` reads the
1053
+ error and fatal lines that arrive (three at most, then the count of the rest) in a polite live
1054
+ region of its own. The scroller takes a tab stop, so the arrow keys scroll it, and every level is
1055
+ a word, not only a colour. Each level colour holds 4.5:1 on both tones in the four themes.
1056
+
1057
+ **Limits.**
1058
+
1059
+ - The default mode is windowed: only the rows in view are drawn, so tens of thousands of lines
1060
+ stay smooth. The row height is measured, so it follows the text size, and a row that scrolls out
1061
+ of view and back is redrawn: a button on it loses the focus when it leaves the window.
1062
+ - An expanded row takes a fixed room of up to 12 rows (a row per field and per stack line); a
1063
+ longer detail scrolls inside it.
1064
+ - `wrap` lines have no fixed height, so they cannot be windowed: all of them are drawn (the
1065
+ browser skips the ones out of view with `content-visibility`). Keep the buffer to a few
1066
+ thousand lines with `wrap` on, as rackscope does with 2,000. Newest first and paused, wrapped
1067
+ lines do not stay put when new ones arrive.
1068
+ - Without `wrap` a long line scrolls sideways: the scroll width is that of the widest row drawn.
1069
+ - The labels ("Jump to latest", "Filters"…) are English.
1070
+ - Not here: the log volume chart (stacked `BarChart` of `@sckyzo/cosmos-charts`, as the lines per
1071
+ level over time) and a structured logs table. The output of a job is `JobLog`, below.
1072
+
1073
+ ## Job logs
1074
+
1075
+ `JobLog` is the output of a CI job or a build: collapsible steps, each with its status and
1076
+ duration, and the lines of each step numbered and coloured. It is read differently from a live
1077
+ tail, hence a component of its own; it shares the tones, the clock format and the windowing of
1078
+ `LogViewer`. The host owns the data and the clock: it passes the steps, their durations (`JobLog`
1079
+ runs no timer) and the new lines as they come.
1080
+
1081
+ ```tsx
1082
+ const steps: JobLogStep[] = [
1083
+ { id: 'install', name: 'Install dependencies', status: 'passed', duration: 41_000, lines: [...] },
1084
+ {
1085
+ id: 'run-tests',
1086
+ name: 'Run tests',
1087
+ status: 'failed',
1088
+ duration: 72_000,
1089
+ startedAt: '2026-01-15T09:30:44Z',
1090
+ // A string, or { text, timestamp } when the line has a time.
1091
+ lines: ['$ pnpm test', '\x1b[31m✗ sortRows keeps empty values last\x1b[0m', ...],
1092
+ },
1093
+ { id: 'build', name: 'Build', status: 'skipped', lines: [] },
1094
+ ];
1095
+
1096
+ <JobLog title="Build #1287" meta="main · 4f2c1a9" status="failed" duration={123_000} steps={steps} />
1097
+ ```
1098
+
1099
+ - **Steps.** `status` is `queued`, `running`, `passed`, `failed` or `skipped`. `duration` is in
1100
+ milliseconds; `startedAt` is the origin of the elapsed times (it defaults to the first
1101
+ timestamp of the step). `id` is unique and stays the same from one render to the next. The
1102
+ optional header (`title`, `status`, `meta`, `duration`) is drawn when any of them is given.
1103
+ - **Open steps.** The failed and the running steps start open, the others closed, and a step that
1104
+ starts running later opens. `expandedSteps` / `defaultExpandedSteps` / `onExpandedStepsChange`
1105
+ work as on other controls. The header of an open step sticks to the top of the log while its
1106
+ lines scroll, and each step is windowed on its own, so a step of tens of thousands of lines
1107
+ stays smooth.
1108
+ - **Line numbers.** They restart at 1 in each step and are links: `href="#<idPrefix><step id>-L<n>"`.
1109
+ A click calls `onCurrentLineChange({ step, line })` and does not touch `location`; a click with a
1110
+ modifier key is the browser's own, so "open in a new tab" works. `currentLine` /
1111
+ `defaultCurrentLine` is the line in focus: when it changes its step opens, the line scrolls into
1112
+ view and takes `aria-current="location"`. One line at a time. To make the links shareable, sync
1113
+ it with the URL hash in the host: read `parseJobLogHash(location.hash, idPrefix)` into
1114
+ `currentLine` on load and on `hashchange`, and write `jobLogLineHref(idPrefix, line)` back from
1115
+ `onCurrentLineChange` (the `Monitoring/JobLog` "Synced with the URL hash" story does).
1116
+ - **Toolbar.** Search across all the steps (a match in a closed step opens it) with the count and
1117
+ previous / next, a timestamps toggle, wrap, expand or collapse all, and download as plain text
1118
+ (ANSI codes stripped, `fileName`). `controls` picks which are drawn (`['search']`; `[]` leaves
1119
+ the toolbar out). Below `md` of the log's own width, as a container, the search stays and the
1120
+ rest moves into a "More options" menu. `timestamps`, `wrap` and `search` each come with a
1121
+ `default*` and an `on*Change`.
1122
+ - **Timestamps.** Off by default. `timestampFormat="clock"` (default) is the time of each line, as
1123
+ `LogViewer` writes it (`locale`, `timeZone`); `"elapsed"` is the time since its step started,
1124
+ `+0:04.2`.
1125
+ - **Following.** While a step runs the log stays at the end, pauses when the reader scrolls up,
1126
+ and has a footer button: Pause, then "Jump to latest (12 new)". `follow` / `defaultFollow` /
1127
+ `onFollowChange`. Moving to a line (a `currentLine`, a search match) leaves the end too.
1128
+ - **Tone.** `terminal` (default) is a dark panel in every theme, `surface` follows the theme.
1129
+
1130
+ **ANSI.** Colour codes are read into styled spans, never HTML. Kept: the 16 colours and their
1131
+ bright variants as a foreground and as a background, bold, dim, italic, underline and the resets.
1132
+ A 256-colour or truecolor code lands on the nearest of the 16. Every other escape sequence (cursor
1133
+ moves, window titles, links) and control character is dropped, and a carriage return keeps only the
1134
+ last segment of the line, so a progress bar reads as its last state. Black and white are mapped to
1135
+ what reads on the tone (a black text on the terminal panel is the muted gray). Each colour holds
1136
+ 4.5:1 on both tones in the four themes. On a background colour, the text takes the colour that
1137
+ reads on that background and its own is dropped (`31;47` is dark text on a light fill), and dim
1138
+ mutes only the default text: a coloured text keeps its colour.
1139
+
1140
+ **Accessibility.** The log is a named `region` with a tab stop. A step header is a button with
1141
+ `aria-expanded` and `aria-controls`, its status an image with a text name (never a colour alone),
1142
+ and the lines of an open step a named group. A line number is a link named "Line 6".
1143
+
1144
+ **Limits.**
1145
+
1146
+ - The windowing needs a row of a fixed height, so it is off with `wrap`: all the lines of an open
1147
+ step are then drawn (the browser skips the ones out of view). Keep a step to a few thousand lines
1148
+ with `wrap` on. A line link that scrolls out of the window loses the focus.
1149
+ - Without `wrap`, the lines of a step scroll sideways inside the step, under its header.
1150
+ - A search compiles the plain text of every line of every step: for logs of hundreds of thousands
1151
+ of lines, filter on the host.
1152
+ - The labels ("Jump to latest", "More options"…) are English.
1153
+
670
1154
  ## Slider
671
1155
 
672
1156
  `Slider` is a position on a scale, or a range of it. One `value` (with `defaultValue` and
@@ -729,10 +1213,11 @@ the Sidebar does with `collapseControl="sidebar"`.
729
1213
  Below `lg` the sidebar leaves the layout and the Topbar's hamburger opens it as a 288px
730
1214
  drawer over a scrim (`defaultSidebarOpen`, or `sidebarOpen` and `onSidebarOpenChange`
731
1215
  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
1216
+ closes on the scrim, Escape, a swipe toward its edge, a click on a `Sidebar.Item` and when the
733
1217
  viewport grows past `lg`. Pass the product logo as the Topbar's `brand` to keep it in
734
1218
  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
1219
+ `swipeToClose`: toward its edge on `side="start"` and `side="end"` (the left and right, swapped
1220
+ under `dir="rtl"`), downward on `side="bottom"`, the
736
1221
  bottom sheet (full width, as tall as its content up to 85% of the viewport, a grab `handle`
737
1222
  you can turn off with `handle={false}`, and a `Drawer.Body` that scrolls past that height).
738
1223
 
@@ -750,7 +1235,7 @@ drawer, and it does not touch the collapsed state: `onCollapsedChange` is not ca
750
1235
  toggles keep their state.
751
1236
 
752
1237
  `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
1238
+ your own, the Topbar's `startActions`, the Sidebar's `footer`) with no props: it
754
1239
  reads the Shell's state, and every toggle stays in sync. Outside a Shell, pass it
755
1240
  `collapsed` and `onCollapsedChange`.
756
1241
 
@@ -763,6 +1248,78 @@ reads the Shell's state, and every toggle stays in sync. Outside a Shell, pass i
763
1248
  </Shell>
764
1249
  ```
765
1250
 
1251
+ ### Search in the Topbar
1252
+
1253
+ `CommandPaletteTrigger` is a button drawn like a search field (a magnifier, a placeholder and the
1254
+ hotkey as a key hint, `⌘K` on Apple platforms and `Ctrl K` elsewhere) that opens a
1255
+ `CommandPalette`. It is not an input: the palette holds the field, so a screen reader announces a
1256
+ button that opens a dialog. Put it in the Topbar's `center` slot. Below `sm` it is a square icon
1257
+ button, and the placeholder stays as its accessible name. `size` takes the form fields' `sm`, `md`
1258
+ and `lg`.
1259
+
1260
+ The palette owns the hotkey (`hotkey`, `mod+k` by default) and the open state; the button
1261
+ registers no listener of its own. Open the palette from its `onClick`, and pass the same hotkey
1262
+ as `shortcut` so the hint is the right one (`shortcut={null}` for a palette without a hotkey).
1263
+
1264
+ ```tsx
1265
+ const [open, setOpen] = useState(false);
1266
+
1267
+ <Topbar
1268
+ pageTitle="Projects"
1269
+ center={<CommandPaletteTrigger placeholder="Search projects" onClick={() => setOpen(true)} />}
1270
+ />
1271
+ <CommandPalette open={open} onOpenChange={setOpen}>
1272
+ <CommandPalette.Input placeholder="Search…" />
1273
+ <CommandPalette.List>{/* items */}</CommandPalette.List>
1274
+ </CommandPalette>
1275
+ ```
1276
+
1277
+ ### Layout modes
1278
+
1279
+ `Sidebar variant="compact"` is a fixed 88px rail with the label under each icon, on two lines
1280
+ at most (a tooltip carries the full name of a label that is cut). A `Sidebar.SubMenu` opens in a
1281
+ flyout, as in the collapsed rail. It has no collapsed state, so `collapsed`, `expandOnHover` and
1282
+ the bottom button do nothing: inside a Shell, set `collapseControl="sidebar"` so the Topbar shows
1283
+ no hamburger that would do nothing. Below `lg` the drawer shows the default sidebar.
1284
+
1285
+ `Shell container="boxed"` centres the topbar, the sidebar and the page in a column of at most
1286
+ 1440px, with the page background on both sides. `fluid`, the default, spans the window.
1287
+
1288
+ A top navigation is a composition: a `Shell` with no `sidebar` and a `Navbar` in `topbar`. Its
1289
+ `Navbar.Items` take `Navbar.Item` and `Navbar.Menu`, a dropdown group of links (`Menu.Item` with
1290
+ an `href`, a `Menu.Group`, a `Menu.Separator`) that follows the menu button pattern: Enter, Space
1291
+ and the arrows open it, the arrows move between the links, Escape closes it and gives the focus
1292
+ back to the trigger, Tab leaves it. Mark the group `active` while one of its links is the current
1293
+ page, and that link with `aria-current="page"`. Items that do not fit move, from the last, into a
1294
+ "More" menu (`moreLabel` renames it) and come back when there is room. Only `Navbar.Item` and
1295
+ `Navbar.Menu` placed directly in `Navbar.Items` move; `Navbar.Items` inside a `Navbar.Brand` takes
1296
+ the room the bar leaves, and anywhere else it needs a bounded width (`min-w-0 flex-1`) to measure
1297
+ against. Below `md` the items leave the bar and `Navbar.MobileMenuButton` opens your own menu.
1298
+
1299
+ ```tsx
1300
+ <Shell
1301
+ container="boxed"
1302
+ topbar={
1303
+ <Navbar>
1304
+ <Navbar.Brand>
1305
+ <Navbar.MobileMenuButton />
1306
+ <Navbar.Items>
1307
+ <Navbar.Item href="/" active>
1308
+ Dashboard
1309
+ </Navbar.Item>
1310
+ <Navbar.Menu label="Settings">
1311
+ <Menu.Item href="/profile">Profile</Menu.Item>
1312
+ <Menu.Item href="/team">Team</Menu.Item>
1313
+ </Navbar.Menu>
1314
+ </Navbar.Items>
1315
+ </Navbar.Brand>
1316
+ </Navbar>
1317
+ }
1318
+ >
1319
+ {/* page */}
1320
+ </Shell>
1321
+ ```
1322
+
766
1323
  ## Split panes
767
1324
 
768
1325
  `SplitView` puts two or three `SplitView.Pane` side by side in one bordered frame (mail,
@@ -784,10 +1341,294 @@ place.
784
1341
  </SplitView>
785
1342
  ```
786
1343
 
1344
+ ## Code
1345
+
1346
+ `CodeBlock` shows a snippet with a copy button. It needs no highlighter, and takes a few variants:
1347
+ `lineNumbers`, `highlightLines={[3, '5-7']}`, `filename` (a header with the name, the language and
1348
+ the copy button), `wrap`, `maxHeight` (the block scrolls inside, takes a Tab stop and scrolls with the
1349
+ arrow keys), `diff` (the first column of each line is its sign, `+`, `-` or a space; changes are
1350
+ tinted and marked, the sign column is not copied) and `variant="terminal"` (a line starting with
1351
+ `$` and a space is a command, the others are its output; only the commands are copied). A block that a long line
1352
+ makes scroll sideways is a Tab stop too. The copy button copies the code, never the line numbers, the
1353
+ signs or the prompts.
1354
+
1355
+ To colour it, use the `/code` entry. It needs [Shiki](https://shiki.style), an optional peer
1356
+ dependency that only the apps using this entry install:
1357
+
1358
+ ```bash
1359
+ pnpm add shiki
1360
+ ```
1361
+
1362
+ ```tsx
1363
+ import { CodeBlock } from '@sckyzo/cosmos-react/code';
1364
+
1365
+ <CodeBlock language="yaml" filename="deployment.yaml" code={manifest} lineNumbers />;
1366
+ ```
1367
+
1368
+ The colours are the `--color-code-*` tokens of the theme, so they follow the theme, accent and text
1369
+ size. The JavaScript regex engine is used (no WebAssembly), and each language is its own chunk,
1370
+ loaded when a block shows it: until then, and for a language Cosmos has no grammar for, the block
1371
+ shows plain text. The languages are shell (`bash`, `sh`, `zsh`), `shellsession`, `yaml`, `json`,
1372
+ `toml`, `ini`, `dockerfile`, `sql`, `typescript`, `javascript`, `tsx`, `jsx`, `python`, `go`, `html`,
1373
+ `xml`, `css`, `diff`, `nginx`, `hcl`, `terraform`, `rust`, `java`, `php`, `markdown` and `promql`.
1374
+ A `highlight` prop still replaces the built-in highlighter. The main entry never imports Shiki:
1375
+ an app that does not use `/code` ships none of it. Imported without `shiki` installed, a block
1376
+ throws an error that names the package.
1377
+
1378
+ ## Editor
1379
+
1380
+ `RichTextEditor` is a formatted text field: bold, italic, strike, headings, bulleted and numbered
1381
+ lists, quotes, code, links, undo and redo. It lives in the `/editor` entry and is built on
1382
+ [Tiptap](https://tiptap.dev) (MIT), whose packages are optional peer dependencies that only the apps
1383
+ using this entry install:
1384
+
1385
+ ```bash
1386
+ pnpm add @tiptap/react @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extensions
1387
+ ```
1388
+
1389
+ ```tsx
1390
+ import { RichTextEditor } from '@sckyzo/cosmos-react/editor';
1391
+
1392
+ <RichTextEditor
1393
+ label="Reply"
1394
+ placeholder="Write a reply…"
1395
+ value={html}
1396
+ onChange={setHtml}
1397
+ maxLength={2000}
1398
+ />;
1399
+ ```
1400
+
1401
+ `value`, `defaultValue` and `onChange` carry HTML by default, or the ProseMirror JSON document with
1402
+ `format="json"`; an empty HTML document is `''`. `variant` picks the chrome: `toolbar` (default,
1403
+ buttons on top; `toolbar={['bold', 'italic', 'link']}` picks them), `bubble` (a small toolbar over
1404
+ the selection; Alt+F10 moves the focus into it, Escape gives it back) or `minimal` (no toolbar, the
1405
+ shortcuts and Markdown-like input rules only: `##` and a space, `-` and a space, `**bold**`).
1406
+ `labels` replaces every text the editor draws, tooltips included. `readOnly`, `disabled`, `rows`,
1407
+ `autoFocus` and `name` (a hidden input, for a native form) work as on a Textarea, and so do `label`,
1408
+ `description`, `error` and `required`: the label and the error are linked to the editable text.
1409
+ `maxLength` counts the text characters and stops typing and pasting at the limit.
1410
+
1411
+ Links accept `http(s):`, `mailto:` and relative URLs only: a
1412
+ `javascript:` link is refused whether it is typed in the link field, typed as Markdown, pasted or
1413
+ passed in `value`. The schema holds paragraphs, headings 2 and 3, lists, quotes, hard breaks and the
1414
+ marks above; images, tables and anything else are dropped on paste. **That is the editor's
1415
+ guarantee, not yours: sanitise the stored HTML again on the server before you render it elsewhere.**
1416
+ The main entry never imports Tiptap: an app that does not use `/editor` ships none of it.
1417
+
1418
+ ## Map
1419
+
1420
+ `MapView` draws places on a map: pins in the theme's colours with an icon or a number, a popup per
1421
+ pin, groups of close pins as a counted circle, zoom buttons and a fit to the markers. It lives in
1422
+ the `/map` entry and needs [MapLibre GL JS](https://maplibre.org) (BSD-3), an optional peer
1423
+ dependency that only the apps using this entry install:
1424
+
1425
+ ```bash
1426
+ pnpm add maplibre-gl
1427
+ ```
1428
+
1429
+ MapLibre draws in a web worker. Since `maplibre-gl` 6, an app built with a bundler tells it where
1430
+ the worker is, once, before the first map; without that the map stays empty. With Vite:
1431
+
1432
+ ```ts
1433
+ import { setWorkerUrl } from 'maplibre-gl';
1434
+ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
1435
+
1436
+ setWorkerUrl(workerUrl);
1437
+ ```
1438
+
1439
+ MapLibre's [installation guide](https://maplibre.org/maplibre-gl-js/docs/#installation) gives the
1440
+ line for webpack, Rollup, esbuild and Next.js, and the extra setting for server rendering.
1441
+ `maplibre-gl` 5 needs none of this.
1442
+
1443
+ Load MapLibre's stylesheet once in the app, and import the component from `/map`:
1444
+
1445
+ ```tsx
1446
+ import { useState } from 'react';
1447
+ import 'maplibre-gl/dist/maplibre-gl.css';
1448
+ import { MapMarkerList, MapView, type MapMarker } from '@sckyzo/cosmos-react/map';
1449
+
1450
+ const stores: MapMarker[] = [
1451
+ { id: 'harbour', label: 'Harbour', position: [4.81, 45.71], description: '12 quay street' },
1452
+ { id: 'station', label: 'Station', position: [4.9, 45.75], intent: 'success', icon: 3 },
1453
+ ];
1454
+
1455
+ export const Stores = () => {
1456
+ const [selected, setSelected] = useState<string | null>(null);
1457
+ return (
1458
+ <>
1459
+ <MapMarkerList markers={stores} selectedId={selected} onSelect={setSelected} />
1460
+ <MapView
1461
+ markers={stores}
1462
+ style="https://tiles.example.com/styles/light.json"
1463
+ darkStyle="https://tiles.example.com/styles/dark.json"
1464
+ ariaLabel="Stores"
1465
+ selectedId={selected}
1466
+ onSelectedIdChange={setSelected}
1467
+ />
1468
+ </>
1469
+ );
1470
+ };
1471
+ ```
1472
+
1473
+ - **The base map is yours.** Cosmos ships no tiles: a tile server has its own terms and sees every
1474
+ visitor. `style` is a MapLibre style (a URL or an object) and its attribution stays visible.
1475
+ `darkStyle` is optional: under the dark themes (`.dark`, `.deep-blue`) the map switches to it,
1476
+ and without it keeps `style`. Pass constants, not a new object on each render. A markers array
1477
+ is the same: define it once, or the pins are redrawn each time.
1478
+ - **Pins, popups and buttons are React and HTML**, coloured by `intent` with the theme's classes,
1479
+ so a theme, accent or text size switch repaints them with no code. A title or description with
1480
+ markup shows as text.
1481
+ - **`variant="static"`** is a fixed view for a card or a detail page: no gesture, no zoom buttons,
1482
+ no focus stop, no popup, and the pins are pictures.
1483
+ - **Keyboard and screen readers.** The interactive map is one focus stop with the name given by
1484
+ `ariaLabel`: the arrow keys pan, `+` and `-` zoom. A map alone cannot be used with a screen
1485
+ reader, so show the same `markers` as a `MapMarkerList` next to it: choosing a row opens the
1486
+ popup on the map. On a static map, the list is the only way to the places.
1487
+ - `cluster` (default on) groups close pins; a click on a group zooms in to split it. `fit` frames
1488
+ the markers on the first draw and when the set of markers changes; otherwise `initialCenter` and
1489
+ `initialZoom` give the view. `onViewChange` reports each pan or zoom.
1490
+ - A browser without WebGL shows `unavailableMessage` in place of the map. The main entry never
1491
+ imports MapLibre: an app that does not use `/map` ships none of it. Imported without
1492
+ `maplibre-gl` installed, a map throws an error that names the package.
1493
+
1494
+ ## Right to left
1495
+
1496
+ Set the native `dir` attribute on `<html>`, or on any ancestor of the components, and
1497
+ they follow it. There is no prop and no `data-` attribute:
1498
+
1499
+ ```html
1500
+ <html lang="ar" dir="rtl"></html>
1501
+ ```
1502
+
1503
+ What mirrors is what follows the reading direction: the side an icon, a ribbon, a floating
1504
+ panel or a status dot sits on, the padding and margins, the corners of a button group, a
1505
+ chevron that points forward, the `start` and `end` alignments of panels. A prop that names
1506
+ a side of the line takes `start` or `end`, never `left` or `right` (`Ribbon`'s
1507
+ `position="top-end"`, `BackToTopFab`'s `position="bottom-end"`, `iconPosition="start"`,
1508
+ `align="end"`), so the side swaps under `dir="rtl"`. `top` and `bottom` stay as they are.
1509
+ Left to right renders as it always did.
1510
+
1511
+ Text that comes from you and sits inside a label or a sentence (a name, a value, a key) is
1512
+ wrapped in `<bdi>`, so a Latin name in an Arabic page, or the reverse, keeps its own order
1513
+ and does not drag the punctuation around it. The package's own copy stays in English, as it
1514
+ is everywhere else. A Latin text cut short by an ellipsis in a right-to-left page (a long file
1515
+ name in a narrow tile) shows its end rather than its beginning: the line ends on the left, where
1516
+ Latin text starts.
1517
+
1518
+ What you put in a slot (a table cell, a card's title, a list item, a toast's message) is drawn
1519
+ as you pass it. A Latin value that starts with a number reorders in a right-to-left line, "248 W"
1520
+ showing as "W 248": wrap it in `<bdi>` yourself.
1521
+
1522
+ ```tsx
1523
+ <TableCell>
1524
+ <bdi>248 W</bdi>
1525
+ </TableCell>
1526
+ ```
1527
+
1528
+ A text field (`Input`, `SearchInput`, `Textarea`, the inputs of `Combobox`, `MultiSelect` and
1529
+ `TagInput`) follows the direction of what is typed in it: Arabic aligns and flows right to
1530
+ left, a URL left to right, each paragraph of a `Textarea` on its own. It does that with
1531
+ `unicode-bidi: plaintext` rather than `dir="auto"`, which would also turn the field's own
1532
+ direction around for a Latin text and leave the room reserved for a trailing icon or button
1533
+ on the wrong side. A `PasswordInput` does the same once its value is shown; hidden, it keeps
1534
+ the page's direction, as HTML leaves password fields out of `dir="auto"`, so how it aligns
1535
+ says nothing of what is typed. A value that is a code reads left to right in any page: the cells of
1536
+ `OtpInput`, the country button and number of `PhoneInput`, and the swatch and hex of
1537
+ `ColorInput` are `dir="ltr"` islands. `Slider` and `Calendar` hand the direction to Radix and
1538
+ react-day-picker, which lay out the handles and read the arrow keys from a `dir` prop and not
1539
+ from the page.
1540
+
1541
+ Tailwind's `rtl:` variant (redefined by the theme to follow the nearest `dir`) is the tool
1542
+ for what has no logical form, such as `rtl:-scale-x-100` on an arrow. In your own markup,
1543
+ prefer the logical utilities (`ms-`, `pe-`, `text-start`, `border-s`, `rounded-e`, `inset-s-`)
1544
+ to the physical ones.
1545
+
1546
+ A component draws the icon you pass it as it is. An icon that points along the line (a
1547
+ forward arrow, a back chevron, undo) turns around through its own class:
1548
+
1549
+ ```tsx
1550
+ import { clsx } from 'clsx';
1551
+ import { ArrowRight, type LucideProps } from 'lucide-react';
1552
+
1553
+ const Forward = ({ className, ...props }: LucideProps) => (
1554
+ <ArrowRight {...props} className={clsx(className, 'rtl:-scale-x-100')} />
1555
+ );
1556
+
1557
+ <Button icon={Forward} iconPosition="end">
1558
+ Continue
1559
+ </Button>;
1560
+ ```
1561
+
1562
+ The overlays name their sides `start` and `end`, which follow the reading direction: left and
1563
+ right under `dir="ltr"`, right and left under `dir="rtl"`. That is `Drawer`'s `side`
1564
+ (`start` | `end` | `bottom`, default `end`), the `align` of `Menu`, `Dropdown`, `UserMenu` and
1565
+ `NotificationsPanel` (`start` | `end`), and the `position` of `Tooltip` and `Popover.Content`
1566
+ (`top` | `bottom` | `start` | `end`). A panel that opens next to a trigger takes the direction of
1567
+ that trigger, even though it renders at the end of `<body>`, and flips to the other side as before
1568
+ when the screen would cut it. A `Drawer`, a `Modal`, a `ConfirmationModal` and a `CommandPalette` are
1569
+ not next to anything: they read the `dir` of `<html>`, so set it there rather than on a wrapper.
1570
+ `Toaster` stacks its toasts at the bottom corner at the end of the line (bottom left under
1571
+ `dir="rtl"`, from its `dir` prop or `<html>`); sonner's own `position` is a corner of the screen,
1572
+ so one you pass is kept as it is. A keyboard shortcut (the `shortcut` of a `Menu.Item`,
1573
+ `CommandPalette.Item` or `Tooltip`, the keys of a `HelpOverlay.Shortcut`) keeps the order the keys
1574
+ are pressed in.
1575
+
1576
+ Two things stay as they are on purpose. A chevron that points down, or turns over when a
1577
+ panel opens (`Accordion`), and a trend arrow that points up or down, are not turned around.
1578
+ And the arrow keys that move a card across a `KanbanBoard` follow where the columns are, not
1579
+ the reading direction: with the first column on the right, ArrowLeft takes the card to the
1580
+ next one.
1581
+
1582
+ The page layout follows the line too. A `Shell` puts its `Sidebar` at the start (on the right
1583
+ under `dir="rtl"`, with its edge, its collapse toggle and the drawer of the phone layout on that
1584
+ side) and its `secondary` panel at the end; `SplitView` runs its panes from the start. `Tabs`
1585
+ and the settings pickers swap ArrowLeft and ArrowRight with the direction, and a scrolling strip
1586
+ fades the side with more to see. The pinned columns of `DataTable` (`pin: 'start'` | `'end'`)
1587
+ and `Table`'s `stickyFirstColumn` stick to the start or end edge, with their shadow on the side
1588
+ that faces the columns scrolling under them. In a `Chat`, a person's own messages sit at the end
1589
+ of the line and the others' at the start, and what was typed in a message or the composer reads in its own script. The `hotkeys` of a
1590
+ `RecordPager` are literal keys and do not follow the direction: give Previous the arrow that
1591
+ points toward it where the page reads right to left.
1592
+
1593
+ Output keeps its order. Code (`CodeBlock`), logs (`LogViewer`, `JobLog`, and the message, fields
1594
+ and stack of `LogEntryDetail`) and keyboard keys (`Kbd`) read left to right in any page: they are
1595
+ `dir="ltr"` islands, so a number gutter stays at the left of its lines and a log scrolls from the
1596
+ left. What surrounds them follows the page: the title bar and copy button of a code block, the
1597
+ toolbar and the "Jump to latest" button of a log, the step headers of a `JobLog` (their chevron
1598
+ turns around). Time keeps its order too, as the axis of a chart does: `HeartbeatBar` and
1599
+ `UptimeBars` draw their oldest mark on the left and their newest on the right in both directions,
1600
+ with "N days ago" at the left and "Today" at the right, and ArrowLeft moves to the older mark. A
1601
+ percentage, a latency or a count the components write (`99.95%`, `182 ms`, `3 of 12`) is wrapped in
1602
+ `<bdi>`, so it reads in order inside an Arabic line.
1603
+
1604
+ `Carousel` hands the direction to Embla, which lays the slides out from the end of the line: the
1605
+ first slide is at the start, the next one comes in from the end, a swipe follows, and the arrows
1606
+ and their chevrons sit on their sides. The play triangle of a `VideoEmbed` points right in both
1607
+ directions, as the time of a video runs from the left. `MapView` does not mirror the map; its zoom
1608
+ buttons and the popup of a pin follow the page. Arabic and Hebrew labels in the tiles of a map
1609
+ need MapLibre's RTL text plugin (`setRTLTextPlugin` in `maplibre-gl`), which a host sets once
1610
+ for the page: Cosmos adds no dependency for it. In `RichTextEditor`, each block follows what is
1611
+ typed in it (the same `unicode-bidi: plaintext` as a `Textarea`), lists are indented and quotes
1612
+ edged on the start side, the arrow keys along the toolbar swap, and the link field reads left to
1613
+ right.
1614
+
1615
+ The pages follow the same rules. The back arrow of `ResetPasswordForm` and `TwoStepVerificationForm`
1616
+ turns around, the address or number a form says it sent the code to is wrapped in `<bdi>`, and
1617
+ `AuthLayout`'s `asidePosition` (`start` | `end`) puts the panel at the start or the end of the line.
1618
+
1619
+ Every group of the Storybook sidebar is converted: **Atoms**, **Actions**, **Forms**, **Data**,
1620
+ **Lists**, **Feedback**, **Overlays**, **Navigation**, **Layout**, **Chat**, **Tables**,
1621
+ **Settings**, **Monitoring**, **Media**, **Status**, **Typography**, **Foundations** and
1622
+ **Examples**. A check in the repository refuses a physical inline utility (`ml-`, `pr-`, `left-`,
1623
+ `text-left`…) in any source file of the package, and every story is rendered right to left in CI.
1624
+ In Storybook, the **Direction** menu of the toolbar (`&globals=direction:rtl`) renders any story
1625
+ right to left.
1626
+
787
1627
  ## Themes and accents
788
1628
 
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
1629
+ The theme is a class on `<html>`; the accent, the font, the text size, the
1630
+ density and the corner radius are the `data-accent`, `data-font`,
1631
+ `data-text-size`, `data-density` and `data-radius` attributes, see the
791
1632
  [theme README](../theme#themes--accents). `AppearanceSettings` renders a picker
792
1633
  for each, and `applyAppearance` sets them on the document:
793
1634
 
@@ -801,6 +1642,7 @@ function Appearance() {
801
1642
  accent: 'blue',
802
1643
  font: 'outfit',
803
1644
  textSize: 'md',
1645
+ density: 'default',
804
1646
  });
805
1647
  useEffect(() => applyAppearance(appearance), [appearance]);
806
1648
  const update = (patch: Partial<CosmosAppearance>) => setAppearance((a) => ({ ...a, ...patch }));
@@ -811,17 +1653,18 @@ function Appearance() {
811
1653
  onAccentChange={(accent) => update({ accent })}
812
1654
  onFontChange={(font) => update({ font })}
813
1655
  onTextSizeChange={(textSize) => update({ textSize })}
1656
+ onDensityChange={(density) => update({ density })}
814
1657
  />
815
1658
  );
816
1659
  }
817
1660
  ```
818
1661
 
819
- The font and text size groups show only when their handlers are set. Pass
1662
+ The font, text size and density groups show only when their handlers are set. Pass
820
1663
  `fonts` to offer only the fonts whose CSS the app imports
821
1664
  (`@sckyzo/cosmos-theme/fonts/<name>`).
822
1665
 
823
1666
  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`
1667
+ panel behind an icon button, for example in `Topbar`'s `endActions`. Set `onReset`
825
1668
  to add a Reset button under the groups.
826
1669
 
827
1670
  To offer a **System** choice that follows the device's `prefers-color-scheme`,
@@ -844,9 +1687,63 @@ useEffect(() => {
844
1687
 
845
1688
  Saving the choice (local storage, user profile) is left to the app.
846
1689
 
1690
+ `applyAppearance` also takes a `direction` (`'ltr'` or `'rtl'`): it sets `dir="rtl"` on
1691
+ `<html>`, and removes `dir` for `ltr`, like the other defaults.
1692
+
847
1693
  Charts live in a separate package, [`@sckyzo/cosmos-charts`](../charts), so apps
848
1694
  that don't render charts don't ship a chart library.
849
1695
 
1696
+ ### Customizer
1697
+
1698
+ `Customizer` is a panel on the end side (a `Drawer`) that puts the choices above in one place,
1699
+ plus the shell's layout, sidebar mode and container, with optional presets, a Reset and a
1700
+ "Copy config" that copies the value as JSON. It only **holds** the choice: it is controlled
1701
+ (`value`, `onChange`, with the whole new value on each click) and applies nothing itself. The
1702
+ host applies it on every change:
1703
+
1704
+ ```tsx
1705
+ const [value, setValue] = useState<CustomizerValue>(CUSTOMIZER_DEFAULT_VALUE);
1706
+ const [open, setOpen] = useState(false);
1707
+ useEffect(() => applyAppearance(value), [value]);
1708
+ const { layout, sidebar, container } = { ...CUSTOMIZER_DEFAULT_VALUE, ...value };
1709
+
1710
+ <Shell
1711
+ container={container}
1712
+ sidebarCollapsed={sidebar === 'collapsed' || sidebar === 'hover'}
1713
+ topbar={layout === 'horizontal' ? <Navbar>…</Navbar> : <Topbar />}
1714
+ sidebar={
1715
+ layout === 'horizontal' ? undefined : (
1716
+ <Sidebar
1717
+ variant={sidebar === 'compact' ? 'compact' : 'default'}
1718
+ expandOnHover={sidebar === 'hover' ? 'overlay' : false}
1719
+ >
1720
+ …
1721
+ </Sidebar>
1722
+ )
1723
+ }
1724
+ >
1725
+ …
1726
+ </Shell>
1727
+ <Customizer open={open} onClose={() => setOpen(false)} value={value} onChange={setValue} />
1728
+ ```
1729
+
1730
+ | Value | What the host does |
1731
+ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
1732
+ | `theme`, `accent`, `font`, `textSize`, `density`, `radius`, `direction` | `applyAppearance(value)` |
1733
+ | `container` | `Shell`'s `container` |
1734
+ | `sidebar: 'collapsed'` | `Shell`'s `sidebarCollapsed` |
1735
+ | `sidebar: 'hover'` | collapsed, plus `Sidebar`'s `expandOnHover="overlay"` |
1736
+ | `sidebar: 'compact'` | `Sidebar`'s `variant="compact"` (and `collapseControl="sidebar"` on the `Shell`) |
1737
+ | `layout: 'horizontal'` | a `Navbar` in `Shell`'s `topbar` in place of the `Sidebar` |
1738
+
1739
+ Keep the `Customizer` outside what `layout` swaps, so the panel keeps its focus as the shell
1740
+ re-renders. Cosmos ships no presets: pass `presets` (`{ id, label, value }`, the value being
1741
+ a part of the whole) to show the section. `sections` lists the sections to show (a host with a
1742
+ fixed layout leaves out `layout`, `sidebar` and `container`), `defaultValue` is what Reset
1743
+ restores, and `themes`, `accents`, `fonts`, `textSizes`, `densities` and `radii` narrow the choices as
1744
+ they do on `AppearanceSettings`. The `Settings/Customizer` stories wire all of it, with local
1745
+ storage.
1746
+
850
1747
  ## License
851
1748
 
852
1749
  MIT — see [LICENSE](../../LICENSE).