@tristan2828/ui-foundation 4.61.1 → 4.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/conventions/docs/cell-patterns.md +16 -11
  2. package/conventions/docs/design-language.md +132 -23
  3. package/dist/components/app/app-shell.js +28 -2
  4. package/dist/components/app/board.js +1 -1
  5. package/dist/components/app/data-table.js +1 -1
  6. package/dist/components/app/foundation-providers.d.ts +14 -1
  7. package/dist/components/app/foundation-providers.js +4 -3
  8. package/dist/components/app/markdown.d.ts +7 -2
  9. package/dist/components/app/markdown.js +15 -20
  10. package/dist/components/app/milkdown-editor.d.ts +1 -1
  11. package/dist/components/app/milkdown-editor.js +58 -17
  12. package/dist/components/app/module-card.js +1 -1
  13. package/dist/components/app/palette-provider.d.ts +14 -0
  14. package/dist/components/app/palette-provider.js +35 -0
  15. package/dist/components/app/prose-look.d.ts +7 -0
  16. package/dist/components/app/rich-text-editor.d.ts +8 -1
  17. package/dist/components/app/rich-text-editor.js +6 -0
  18. package/dist/components/app/rich-text-format-items.js +1 -1
  19. package/dist/components/app/rich-text-node-views.d.ts +9 -3
  20. package/dist/components/app/rich-text-node-views.js +12 -8
  21. package/dist/components/app/rich-text-table-grips.d.ts +31 -0
  22. package/dist/components/app/rich-text-table-grips.js +216 -0
  23. package/dist/components/app/rich-text-table.d.ts +19 -2
  24. package/dist/components/app/rich-text-table.js +232 -1
  25. package/dist/components/app/rich-text-toolbar.d.ts +4 -9
  26. package/dist/components/app/rich-text-toolbar.js +9 -11
  27. package/dist/components/app/rich-text-widgets.js +2 -2
  28. package/dist/components/app/widget-card.js +2 -2
  29. package/dist/components/ui/badge.d.ts +1 -1
  30. package/dist/components/ui/badge.js +5 -2
  31. package/dist/components/ui/button.js +10 -10
  32. package/dist/components/ui/card.js +3 -3
  33. package/dist/components/ui/checkbox.js +1 -1
  34. package/dist/components/ui/combobox.js +1 -1
  35. package/dist/components/ui/dialog.js +1 -1
  36. package/dist/components/ui/input-group.js +1 -1
  37. package/dist/components/ui/input.js +1 -1
  38. package/dist/components/ui/progress.d.ts +7 -0
  39. package/dist/components/ui/progress.js +19 -0
  40. package/dist/components/ui/select.js +1 -1
  41. package/dist/components/ui/sheet.js +1 -1
  42. package/dist/components/ui/slider.d.ts +3 -0
  43. package/dist/components/ui/slider.js +12 -0
  44. package/dist/components/ui/textarea.js +1 -1
  45. package/dist/hooks/use-palette.d.ts +14 -0
  46. package/dist/hooks/use-palette.js +7 -0
  47. package/dist/index.d.ts +3 -1
  48. package/dist/index.js +2 -0
  49. package/dist/lib/markdown-table.d.ts +28 -0
  50. package/dist/lib/markdown-table.js +106 -0
  51. package/dist/lib/palettes.d.ts +21 -0
  52. package/dist/lib/palettes.js +74 -0
  53. package/package.json +1 -1
  54. package/styles/index.css +152 -0
  55. package/styles/theme.css +321 -11
  56. package/vite/index.d.ts +7 -1
  57. package/vite/pwa.js +21 -7
  58. package/dist/components/app/rich-text-table-menu.d.ts +0 -20
  59. package/dist/components/app/rich-text-table-menu.js +0 -53
@@ -26,15 +26,15 @@
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
28
28
  - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
29
- - [18. Editable value](#18-editable-value) · lines 762–1221
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1037–1122
31
- - [In a table's rows](#in-a-tables-rows) · lines 1124–1209
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1211–1221
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1223–1289
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1291–1355
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1357–1425
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1427–1455
37
- - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1457–1533
29
+ - [18. Editable value](#18-editable-value) · lines 762–1226
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1042–1127
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1129–1214
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1216–1226
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1228–1294
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1296–1360
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1362–1430
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1432–1460
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1462–1538
38
38
 
39
39
  <!-- /index -->
40
40
 
@@ -1002,8 +1002,13 @@ const price = editInPlace({
1002
1002
  is GitHub's `> [!NOTE]` (note, tip, important, warning, caution), its
1003
1003
  icon a menu of kinds. In a table, Tab in the last cell adds a row,
1004
1004
  Enter goes down a column (and out from the last row), bars under and
1005
- beside it add a row or a column, and the toolbar's Table menu inserts
1006
- rows and columns, aligns a column and deletes. Selected words can be
1005
+ beside it add a row or a column, and a grip on the hovered column and
1006
+ row opens its menu: insert, move and delete, align, sort (rewriting the
1007
+ rows), a header column (its first cells bold) and cell colours (a
1008
+ cell's whole text in a highlight); a right-click or Shift+F10 in a cell
1009
+ opens the cell's. Columns fit their words. `tableLook="boxed"` (on the
1010
+ editor and on `<Markdown>`) gives a roomier boxed table with zebra rows
1011
+ instead of the compact grid. Selected words can be
1007
1012
  highlighted in four colours (amber, yellow, green, red) from the
1008
1013
  toolbars' Highlight menu or with "/highlight", and un-highlighted;
1009
1014
  they're saved as `==words==` (`=={green}words==` for the others), plain
@@ -3,25 +3,28 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [Semantic tones](#semantic-tones) · lines 34–62
7
- - [The three badge styles](#the-three-badge-styles) · lines 64–81
8
- - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 83–112
9
- - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 114–127
10
- - [Logos and brand marks](#logos-and-brand-marks) · lines 129–173
11
- - [Categorical colour](#categorical-colour) · lines 175–245
12
- - [Typography](#typography) · lines 247–338
13
- - [Written text (notes)](#written-text-notes) · lines 278–338
14
- - [Density](#density) · lines 340–397
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 399–513
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 515–584
17
- - [Notices](#notices) · lines 586–614
18
- - [A value over time](#a-value-over-time) · lines 616–694
19
- - [Several values over time](#several-values-over-time) · lines 657–694
20
- - [A list as a board](#a-list-as-a-board) · lines 696–747
21
- - [A list as cards](#a-list-as-cards) · lines 749–795
22
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 797–819
23
- - [Picking at random](#picking-at-random) · lines 821–852
24
- - [Filling in what's missing](#filling-in-whats-missing) · lines 854–921
6
+ - [Palettes](#palettes) · lines 37–95
7
+ - [Glow](#glow) · lines 97–115
8
+ - [Semantic tones](#semantic-tones) · lines 117–145
9
+ - [The three badge styles](#the-three-badge-styles) · lines 147–164
10
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 166–195
11
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 197–210
12
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 212–256
13
+ - [Categorical colour](#categorical-colour) · lines 258–328
14
+ - [Shapes and sizes](#shapes-and-sizes) · lines 330–351
15
+ - [Typography](#typography) · lines 353–447
16
+ - [Written text (notes)](#written-text-notes) · lines 387–447
17
+ - [Density](#density) · lines 449–506
18
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 508–622
19
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 624–693
20
+ - [Notices](#notices) · lines 695–723
21
+ - [A value over time](#a-value-over-time) · lines 725–803
22
+ - [Several values over time](#several-values-over-time) · lines 766–803
23
+ - [A list as a board](#a-list-as-a-board) · lines 805–856
24
+ - [A list as cards](#a-list-as-cards) · lines 858–904
25
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 906–928
26
+ - [Picking at random](#picking-at-random) · lines 930–961
27
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 963–1030
25
28
 
26
29
  <!-- /index -->
27
30
 
@@ -31,6 +34,86 @@ Tokens and styles here **grow freely**, in the foundation package, so every
31
34
  app gets them: they do not wait for a second app to need them. New
32
35
  composites don't grow that way; they are built against a real app's screen.
33
36
 
37
+ ## Palettes
38
+
39
+ A palette is an app's own dark look: its surfaces, text, accent and a
40
+ second colour, chosen once for every app the foundation serves. There
41
+ are four, in the order they were ranked:
42
+
43
+ | Name | `palette` | Accent | Second colour |
44
+ |---|---|---|---|
45
+ | Indigo and Cyan | `indigo-cyan` | indigo, the only accent that passes 4.5:1 on cards | cyan |
46
+ | Indigo and Rose | `indigo-rose` | indigo-violet | rose |
47
+ | Graphite and Periwinkle | `graphite-periwinkle` | periwinkle on neutral grey | lilac |
48
+ | Violet | `violet` | violet | magenta |
49
+
50
+ **How an app picks one.** `<FoundationProviders palette="indigo-cyan">`
51
+ names the app's default. Add `palettePicker` and the app shell's header
52
+ offers all four (with swatches); the one picked is remembered on that
53
+ device, and a new device starts on the default. `usePalette()` reads the
54
+ palette on screen. Without `palette`, nothing changes: light and dark
55
+ follow the system, with the toggle in the header.
56
+
57
+ **A palette is dark only.** With one, the theme is forced dark and the
58
+ shell has no light/dark toggle. The light theme stays for apps without a
59
+ palette. Printed notes still print light: the print override beats a
60
+ palette's block.
61
+
62
+ **Where it lives.** `styles/theme.css`: each palette's colours as
63
+ primitives (`--palette-<name>-*`, ratios beside them), and one block per
64
+ palette, `.dark[data-palette="<name>"]`, that sets only what differs from
65
+ `.dark`. `data-palette` goes on `<html>`, so popups portaled to `<body>`
66
+ wear it too. An installed app's colours follow it with
67
+ `pwa({ palette })`.
68
+
69
+ **Why some tokens aren't the guide's colour as written.**
70
+
71
+ - **The focus ring is the accent** (4.24 to 5.25:1 on the page), not the
72
+ guide's accent deep, which measured 2.30 to 2.91, under the 3:1 a focus
73
+ indicator needs.
74
+ - **Links are accent light** (`--link`, 8.5:1 or more). The accent fails
75
+ 4.5:1 as small text on Indigo and Rose and on Violet, and on every
76
+ palette's cards but Indigo and Cyan, so it is never small text: it is
77
+ the ring, the primary fill and the active item's tint. The `link`
78
+ Button variant is `text-link` for the same reason.
79
+ - **The status colours are text shades.** The guide's success, warning
80
+ and danger are `--success-text`, `--warning-text` and
81
+ `--destructive-text` (soft badges, icons, coloured words). Solid fills
82
+ keep the deeper shades with white text: white on the guide's success
83
+ green measures 1.80. Info keeps its blue.
84
+ - **Violet's text on its accent** is `#110823`, a shade darker than the
85
+ guide's `#130925`, which measured 4.496:1 (under 4.5).
86
+ - **Danger text** is `#df7a72`, a shade lighter than the guide's
87
+ `#dc726a`, which measured 4.37 on Graphite's raised surface and 4.46 on
88
+ an outline button's fill (an invalid image field's message).
89
+ - **Field borders** (`--input`, the guide's strong border) measure 2.5 to
90
+ 2.9 against the page, under 3:1. It is already the lightest grey each
91
+ palette has, so it is kept as designed; axe doesn't check borders.
92
+
93
+ The package's `e2e/fixtures-visual.spec.ts` runs every primitive,
94
+ composite and tone story through axe in each palette, and measures every
95
+ glyph and categorical slot against each palette's page and card.
96
+
97
+ ## Glow
98
+
99
+ Under a palette only, from `--primary` so each palette glows in its own
100
+ accent; an app without a palette never gets it. Everything else stays
101
+ flat: no glow at the top of the page.
102
+
103
+ - **The primary button** (Button's default variant) on hover, keyboard
104
+ focus and press: one soft layer of the accent (`--primary-glow`: 38px
105
+ blur, 2px spread, 38%), and on hover a shine sweeping across once
106
+ (650ms). Under reduced motion, no shine and no shrink. Secondary,
107
+ outline, ghost and destructive buttons never glow.
108
+ - **A hovered card**: its edge turns the accent with a soft halo
109
+ (`--edge-glow`), through the `glow-edge` utility. `ModuleCard`,
110
+ `WidgetCard` and `Board`'s cards carry it, and a `DataTable` card
111
+ layout's `Card`s; an app's own card takes the class.
112
+ - **A slider's thumb** (`ui/slider`): a small halo (`--thumb-glow`).
113
+
114
+ For anything else of an app's own, `palette:` is the variant
115
+ (`palette:hover:shadow-(--primary-glow)`).
116
+
34
117
  ## Semantic tones
35
118
 
36
119
  Four tones carry meaning: **success**, **warning**, **destructive** and
@@ -244,6 +327,29 @@ If you find yourself wanting a thirteenth, that is a sign the column should
244
327
  be showing a shape or a label rather than more colours: there is no more
245
328
  room that stays readable.
246
329
 
330
+ ## Shapes and sizes
331
+
332
+ The shared style guide's, the same in every app with or without a
333
+ palette:
334
+
335
+ - **Radius 9px** (`--radius-base`): buttons and cards on `--radius`,
336
+ fields on `--radius-md` (about 7px).
337
+ - **Buttons** 36px tall with 16px sides, semibold (`sm` 32px, `lg` 40px,
338
+ the icon sizes to match). A press shrinks the button to 96%, not under
339
+ reduced motion.
340
+ - **Fields** (input, select, input group, a multi-pick's box) 36px,
341
+ outlined. **Checkbox** 18px, a 5px radius, solid when checked, a small
342
+ press pop.
343
+ - **Cards** a hairline border with a soft shadow (`--card-shadow`: 30% in
344
+ dark, lighter on white), 16px padding, 14px between their parts. A
345
+ card's header, when it comes first, is a tinted bar flush with the top
346
+ (`--card-header`), as on every `WidgetCard` and `EntityView` section.
347
+ - **Badges** are pills; the default is a soft tint of the accent
348
+ (`variant="solid"` is the filled one). The tone styles are unchanged.
349
+ - **Behind a dialog or sheet**, the page is dimmed and blurred.
350
+ - **Motion** is quick: 120ms with the standard easing, for every
351
+ transition that doesn't name its own.
352
+
247
353
  ## Typography
248
354
 
249
355
  Six roles cover every screen. Each is one class that sets size, line
@@ -251,13 +357,16 @@ height and weight together, from tokens in the package's `styles/theme.css`:
251
357
 
252
358
  | Class | For | Size / weight |
253
359
  |---|---|---|
254
- | `type-page-title` | the screen's one `<h1>` (a table's title, a form's title) | 18px / 600 |
360
+ | `type-page-title` | the screen's one `<h1>` (a table's title, a form's title) | 28px / 600, line 1.2 |
255
361
  | `type-section-title` | a heading inside a screen (a form section, a card group) | 16px / 600 |
256
- | `type-body` | running text, table cells, form text, the pagination summary | 14px / 400 |
362
+ | `type-body` | running text, table cells, form text, the pagination summary | 14px / 400, line 1.5 |
257
363
  | `type-label` | column headers, field labels, a small heading over a group | 14px / 500 |
258
364
  | `type-caption` | counts, metadata, helper text under a field | 12px / 400 |
259
365
  | `type-numeric` | the one live number on a card (`ModuleCard`'s count), in tabular figures | 22px / 500 |
260
366
 
367
+ - **Headings draw in a little:** the page and section titles, and written
368
+ text's `#` and `##`, take -0.02em letter spacing. Labels stay normal
369
+ case at 14px medium (small caps at 12px read too small).
261
370
  - **Use a role instead of size and weight classes,** not as well as them.
262
371
  `type-page-title` replaces `text-lg font-semibold`. Combining a role with
263
372
  `text-sm`, `font-medium` or `leading-*` makes two classes set the same
@@ -351,8 +460,8 @@ set, not a `DataTable` prop:
351
460
 
352
461
  | `data-density` | Changes | Table cell padding (vertical) | Header height | For |
353
462
  |---|---|---|---|---|
354
- | `compact` | table rows, and a hub's cards (`WidgetCard`, `WidgetList` rows, `ModuleCard`) | 4px | 32px | wide tables people scan, many rows per screen; a dense hub |
355
- | *(unset)* | — | 8px | 40px | the default |
463
+ | *(unset)* | — | 4px | 32px | the default: compact rows, as the shared style guide has them |
464
+ | `compact` | a hub's cards (`WidgetCard`, `WidgetList` rows, `ModuleCard`); table rows stay the default's | 4px | 32px | a dense hub |
356
465
  | `comfortable` | table rows only | 12px | 48px | short tables, or rows people read rather than scan |
357
466
  | `large` | **everything**: type, spacing, control heights, icons, the sidebar, table rows | 12px | 56px | a screen read from a distance: a TV used as the desktop monitor, with a mouse and keyboard |
358
467
 
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronRightIcon, LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
2
+ import { ChevronRightIcon, LogOutIcon, MoonIcon, PaletteIcon, SunIcon } from 'lucide-react';
3
3
  import { useTheme } from 'next-themes';
4
4
  import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
@@ -11,8 +11,11 @@ import { ErrorState } from './error-state.js';
11
11
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
12
12
  import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
13
13
  import { useShellDocumentTitle } from '../../hooks/use-document-title.js';
14
+ import { usePalette } from '../../hooks/use-palette.js';
15
+ import { isPaletteName, PALETTE_LABELS, PALETTES } from '../../lib/palettes.js';
14
16
  import { cn } from 'cn';
15
17
  import { Button } from '../ui/button.js';
18
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
16
19
  import { Skeleton } from '../ui/skeleton.js';
17
20
  import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
18
21
  import { Toaster } from '../ui/sonner.js';
@@ -36,6 +39,29 @@ function CloseMobileSidebarOnNavigate() {
36
39
  }, [key, setOpenMobile]);
37
40
  return null;
38
41
  }
42
+ // The header's look control: an app with a palette is always dark, so it
43
+ // has no light/dark toggle, and with the picker on it gets the palette
44
+ // menu in its place (design-language.md "Palettes").
45
+ function LookControl() {
46
+ const { palette, picker } = usePalette();
47
+ if (!palette)
48
+ return _jsx(ThemeToggle, {});
49
+ return picker ? _jsx(PalettePicker, { palette: palette }) : null;
50
+ }
51
+ // Each palette's swatch is its own background with its accent and second
52
+ // colour on it, read from the palette primitives, so the menu shows all
53
+ // four whichever is on screen.
54
+ function PaletteSwatch({ name }) {
55
+ const colour = (part) => `var(--palette-${name}-${part})`;
56
+ return (_jsxs("span", { "aria-hidden": true, className: "flex h-4 w-7 shrink-0 items-center justify-center gap-0.5 rounded-sm border border-border", style: { background: colour('background') }, children: [_jsx("span", { className: "size-2 rounded-full", style: { background: colour('accent') } }), _jsx("span", { className: "size-2 rounded-full", style: { background: colour('second') } })] }));
57
+ }
58
+ function PalettePicker({ palette }) {
59
+ const { setPalette } = usePalette();
60
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": `Palette: ${PALETTE_LABELS[palette]}` }), children: _jsx(PaletteIcon, {}) }), _jsx(DropdownMenuContent, { align: "end", className: "w-60", children: _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Palette on this device" }), _jsx(DropdownMenuRadioGroup, { value: palette, onValueChange: (next) => {
61
+ if (isPaletteName(next))
62
+ setPalette(next);
63
+ }, children: PALETTES.map((name) => (_jsxs(DropdownMenuRadioItem, { value: name, children: [_jsx(PaletteSwatch, { name: name }), PALETTE_LABELS[name]] }, name))) })] }) })] }));
64
+ }
39
65
  function ThemeToggle() {
40
66
  const { resolvedTheme, setTheme } = useTheme();
41
67
  const isDark = resolvedTheme === 'dark';
@@ -160,5 +186,5 @@ export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarO
160
186
  // (AuthProvider), so this stays a deliberate logout: a
161
187
  // clean /login, never the previous user's page as `from`.
162
188
  logout().catch(() => { });
163
- }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: cn('flex-1 p-6', bottomBar?.length && BOTTOM_BAR_PADDING), children: _jsx(Outlet, {}) })] }), bottomBar && bottomBar.length > 0 && _jsx(BottomBar, { items: bottomBar, nav: nav, activeTo: activeTo })] }), _jsx(Toaster, { position: "top-center" })] }));
189
+ }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(LookControl, {})] }), _jsx("div", { className: cn('flex-1 p-6', bottomBar?.length && BOTTOM_BAR_PADDING), children: _jsx(Outlet, {}) })] }), bottomBar && bottomBar.length > 0 && _jsx(BottomBar, { items: bottomBar, nav: nav, activeTo: activeTo })] }), _jsx(Toaster, { position: "top-center" })] }));
164
190
  }
@@ -101,7 +101,7 @@ export function Board({ 'aria-label': ariaLabel, columns, items, getId, getColum
101
101
  event.dataTransfer.setData(DRAG_TYPE, String(getId(item)));
102
102
  event.dataTransfer.effectAllowed = 'move';
103
103
  }
104
- : undefined, className: cn('gap-2 px-3 py-3', onMove && 'cursor-grab active:cursor-grabbing'), children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: renderCard(item) }), onMove && (_jsx(MoveMenu, { id: String(getId(item)), name: getName(item), value: getColumn(item), targets: targets, onMove: (value) => move(String(getId(item)), value, 'menu') }))] }) }, getId(item)))), cards.length === 0 && (_jsx("p", { className: "px-1 py-6 text-center type-caption text-muted-foreground", children: column.empty ?? 'Nothing here' })), column.footer && _jsx("div", { className: "px-1 type-caption text-muted-foreground", children: column.footer })] }, column.value));
104
+ : undefined, className: cn('glow-edge gap-2 px-3 py-3', onMove && 'cursor-grab active:cursor-grabbing'), children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: renderCard(item) }), onMove && (_jsx(MoveMenu, { id: String(getId(item)), name: getName(item), value: getColumn(item), targets: targets, onMove: (value) => move(String(getId(item)), value, 'menu') }))] }) }, getId(item)))), cards.length === 0 && (_jsx("p", { className: "px-1 py-6 text-center type-caption text-muted-foreground", children: column.empty ?? 'Nothing here' })), column.footer && _jsx("div", { className: "px-1 type-caption text-muted-foreground", children: column.footer })] }, column.value));
105
105
  }), _jsx("span", { role: "status", className: "sr-only", children: announcement })] }));
106
106
  }
107
107
  function BoardColumnView({ column, count, headingLevel, onDrop, children, }) {
@@ -559,7 +559,7 @@ function GroupLabel({ run, icon, isFolded, onToggle, }) {
559
559
  return (_jsxs("button", { type: "button", "aria-expanded": !isFolded, onClick: onToggle, className: GROUP_TOGGLE_CLASS, children: [_jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('size-4 self-center text-muted-foreground transition-transform', isFolded && '-rotate-90') }), label, _jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: run.rows.length })] }));
560
560
  }
561
561
  function Cards({ layout, runs, rows, folded, onToggleFold, groupSummary, groupIcons, }) {
562
- const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0", children: layout.renderCard(row.original) }, row.id))) }));
562
+ const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0 *:data-[slot=card]:glow-edge", children: layout.renderCard(row.original) }, row.id))) }));
563
563
  return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: runs
564
564
  ? runs.map((run, runIndex) => {
565
565
  const isFolded = folded.has(run.key);
@@ -1,7 +1,8 @@
1
1
  import { type QueryClient } from '@tanstack/react-query';
2
2
  import { type ReactNode } from 'react';
3
+ import type { PaletteName } from '../../lib/palettes.js';
3
4
  import { type ErrorReportingOptions } from '../../error-reporting/reporter.js';
4
- export declare function FoundationProviders({ mockMode, queryClient, errorReporting, children, }: {
5
+ export declare function FoundationProviders({ mockMode, queryClient, errorReporting, palette, palettePicker, children, }: {
5
6
  /** Whether the API is served by MSW — the app's own IS_MOCK_MODE. */
6
7
  mockMode: boolean;
7
8
  /** Defaults to createQueryClient(); pass one to share it with code outside React. */
@@ -13,5 +14,17 @@ export declare function FoundationProviders({ mockMode, queryClient, errorReport
13
14
  * report holds and never holds. `{ release }` names the build.
14
15
  */
15
16
  errorReporting?: boolean | ErrorReportingOptions;
17
+ /**
18
+ * The app's palette (design-language.md "Palettes"). With one, the app
19
+ * is always dark in that palette and the shell has no light/dark
20
+ * toggle; printed notes still print light. Without, light and dark
21
+ * follow the system, as before.
22
+ */
23
+ palette?: PaletteName;
24
+ /**
25
+ * With `palette`: the shell offers all four palettes, and the one picked
26
+ * is remembered on this device. A new device starts on `palette`.
27
+ */
28
+ palettePicker?: boolean;
16
29
  children: ReactNode;
17
30
  }): import("react").JSX.Element;
@@ -5,20 +5,21 @@ import { createQueryClient } from '../../api/query-client.js';
5
5
  import { AuthProvider } from '../../auth/use-auth.js';
6
6
  import { MockModeBanner } from './mock-mode-banner.js';
7
7
  import { ThemeProvider } from '../theme-provider.js';
8
+ import { PaletteProvider } from './palette-provider.js';
8
9
  import { ErrorReportingProvider } from '../../error-reporting/error-reporting-provider.js';
9
10
  import { resolveErrorReporting } from '../../error-reporting/reporter.js';
10
11
  import { browserStaleChunkReload } from '../../lib/stale-chunk-reload.js';
11
12
  // Everything an app's root needs around its router, in the order that
12
- // works: the theme outermost, the mock-mode banner above the router so it
13
+ // works: the theme outermost (and the palette, which forces it dark), the mock-mode banner above the router so it
13
14
  // shows on every route (/login included), and AuthProvider inside
14
15
  // QueryClientProvider because it queries /auth/me through TanStack Query.
15
16
  // An app's DataEnvironmentBanner goes first among the children, before its
16
17
  // router: it queries too, and shows on every route like the mock banner.
17
18
  // It also reloads the page once when a lazy chunk is gone after a deploy
18
19
  // (lib/stale-chunk-reload.ts), so no app adds its own listener.
19
- export function FoundationProviders({ mockMode, queryClient, errorReporting, children, }) {
20
+ export function FoundationProviders({ mockMode, queryClient, errorReporting, palette, palettePicker = false, children, }) {
20
21
  const [client] = useState(() => queryClient ?? createQueryClient());
21
22
  const reporting = resolveErrorReporting(errorReporting);
22
23
  useEffect(() => browserStaleChunkReload().install(window), []);
23
- return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: reporting ? _jsx(ErrorReportingProvider, { options: reporting, children: children }) : children }) })] }));
24
+ return (_jsx(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, forcedTheme: palette ? 'dark' : undefined, children: _jsxs(PaletteProvider, { palette: palette, picker: palettePicker, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: reporting ? _jsx(ErrorReportingProvider, { options: reporting, children: children }) : children }) })] }) }));
24
25
  }
@@ -1,5 +1,5 @@
1
1
  import { type ComponentProps } from 'react';
2
- import { type HeadingFrom, type HeadingLevel } from './prose-look.js';
2
+ import { type HeadingFrom, type HeadingLevel, type TableLook } from './prose-look.js';
3
3
  export type MarkdownProps = {
4
4
  /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
5
5
  children: string;
@@ -44,6 +44,11 @@ export type MarkdownProps = {
44
44
  * are pictures and the text stays read-only.
45
45
  */
46
46
  onTasksChange?: (markdown: string) => void | Promise<unknown>;
47
+ /**
48
+ * How tables look: `'compact'` (the default) or `'boxed'`, as on
49
+ * RichTextEditor. Either way columns fit their words.
50
+ */
51
+ tableLook?: TableLook;
47
52
  className?: string;
48
53
  } & Omit<ComponentProps<'div'>, 'children'>;
49
- export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, className, ...rest }: MarkdownProps): import("react").JSX.Element;
54
+ export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, tableLook, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -23,6 +23,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
23
  // sit: `#` and `##` on a tinted band, `###` a small uppercase label, and
24
24
  // the text under them lined up with the words in the band (prose-look.ts).
25
25
  //
26
+ // A table's columns fit their words (a cell wraps at 18rem; a right-
27
+ // aligned column, numbers, never wraps), in one of two looks
28
+ // (`tableLook`, as on RichTextEditor: styles/index.css "Tables in written
29
+ // text"). A cell whose whole text is one highlight is painted in it, and
30
+ // a first column of bold cells is row headers (src/lib/markdown-table.ts).
26
31
  // A table wider than the text scrolls sideways, and while it does, its
27
32
  // box is a tab stop, named for its columns, that the arrow keys scroll
28
33
  // (prose-table-scroll.ts). Its cells wrap first, so that's only a table
@@ -60,11 +65,11 @@ import remarkGfm from 'remark-gfm';
60
65
  import { cn } from 'cn';
61
66
  import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
62
67
  import { Checkbox } from '../ui/checkbox.js';
63
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
64
68
  import { useFolds } from '../../hooks/fold-store.js';
65
69
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
66
70
  import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
67
71
  import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
72
+ import { rehypeTables } from '../../lib/markdown-table.js';
68
73
  import { toggleTaskAt } from '../../lib/markdown-tasks.js';
69
74
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
70
75
  import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
@@ -87,10 +92,9 @@ function omitNode(props) {
87
92
  delete rest.node;
88
93
  return rest;
89
94
  }
90
- // A GFM table in the package's own table, in its box that scrolls
91
- // sideways: a tab stop and a named region only while the table overflows
92
- // it. Watched again after each render, so a header row with new words
93
- // renames it.
95
+ // A GFM table in its box that scrolls sideways: a tab stop and a named
96
+ // region only while the table overflows it. Watched again after each
97
+ // render, so a header row with new words renames it.
94
98
  function NotesTable({ hidden, ...props }) {
95
99
  const boxRef = useRef(null);
96
100
  useEffect(() => {
@@ -101,9 +105,7 @@ function NotesTable({ hidden, ...props }) {
101
105
  const region = watchTableScroll(box, table, headerTexts(table));
102
106
  return () => region.destroy();
103
107
  });
104
- return (_jsx(Table, { ...props, containerRef: boxRef,
105
- // Folded away, the box goes with the table.
106
- containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
108
+ return (_jsx("div", { ref: boxRef, "data-slot": "table-container", className: cn('prose-table', TABLE_SCROLL_FOCUS, hidden && 'hidden'), children: _jsx("table", { ...props }) }));
107
109
  }
108
110
  // Ticks a task item by where it starts in the text: given only with
109
111
  // `onTasksChange`. Each task item hands its box its own start and words.
@@ -211,16 +213,9 @@ const COMPONENTS = {
211
213
  const { alt, ...rest } = omitNode(props);
212
214
  return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "h-auto max-w-full rounded-md" });
213
215
  },
214
- // GFM tables use the package's own table, zebra stripes and density
215
- // tokens included, so a table in the notes reads like every other table.
216
+ // GFM tables: drawn by styles/index.css "Tables in written text"
217
+ // (rehypeTables marks coloured cells and row headers).
216
218
  table: (props) => _jsx(NotesTable, { ...omitNode(props) }),
217
- thead: (props) => _jsx(TableHeader, { ...omitNode(props), className: "bg-muted" }),
218
- tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
219
- tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
220
- // Notes' cells wrap (a cell can hold a sentence) and have a line
221
- // between columns, so a wide table still reads column by column.
222
- th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
223
- td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
224
219
  };
225
220
  // The components for each `headingLevel` and level that takes it (`#`,
226
221
  // or the text's highest with `headingFrom="text"`), made once each.
@@ -236,7 +231,7 @@ function componentsFor(top, textTop) {
236
231
  }
237
232
  return components;
238
233
  }
239
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, className, ...rest }) {
234
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, tableLook = 'compact', className, ...rest }) {
240
235
  // On paper (EntityView's Print notes): every heading plain and open,
241
236
  // every task box a picture.
242
237
  const printing = usePrinting();
@@ -245,7 +240,7 @@ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headi
245
240
  const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
246
241
  const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
247
242
  const [folded, setFolded] = useFolds(foldKey);
248
- const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
243
+ const rehypePlugins = useMemo(() => (foldKey === undefined ? [rehypeTables] : [rehypeTables, [rehypeFold, { folded }]]), [foldKey, folded]);
249
244
  const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
250
245
  // Ticks not yet back as `children`: shown, and built on by the next
251
246
  // tick, so two quick ticks on a slow save both land. Kept while
@@ -261,5 +256,5 @@ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headi
261
256
  if (saving && typeof saving.then === 'function')
262
257
  saving.then(undefined, () => setPending((current) => (current?.sent.includes(next) ? null : current)));
263
258
  }, [onTasksChange, children, shown, live]);
264
- return (_jsx("div", { "data-slot": "markdown", "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
259
+ return (_jsx("div", { "data-slot": "markdown", "data-table-look": tableLook, "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
265
260
  }
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, foldKey, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, foldKey, tableLook, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;