@terpjs/react-core 0.9.0 → 0.10.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 (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
@@ -0,0 +1,213 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import type { ReactNode } from "react";
5
+
6
+ import { DataView } from "./dataview";
7
+ import { InMemoryDataViewRepository } from "./dataview";
8
+ import type { DataViewColumn } from "./dataview";
9
+ import {
10
+ formatDate,
11
+ formatDateTime,
12
+ formatNumber,
13
+ useFormatDate,
14
+ useFormatNumber,
15
+ } from "./format";
16
+ import { LocaleProvider } from "./locale";
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ window.localStorage.clear();
21
+ });
22
+
23
+ // Midday UTC keeps the calendar date stable across most zones, but only most: UTC+13 and UTC+14
24
+ // exist (Pacific/Apia, Pacific/Kiritimati), so at 12:00Z the local date there is already the 8th.
25
+ // No assertion below depends on WHICH day it is — the locale assertions compare two locales against
26
+ // each other, and the shape assertions ask whether the day or the month comes first. An earlier
27
+ // version asserted the literal digit 7 and would have failed in Kiritimati and nowhere else.
28
+ const WHEN = "2026-07-07T12:00:00Z";
29
+
30
+ const NL = { label: "Nederlands", strings: {} };
31
+ const EN = { label: "English", strings: {} };
32
+
33
+ describe("the locale-explicit formatters", () => {
34
+ it("actually varies with the locale it is given", () => {
35
+ // The whole defect was a missing argument, so the assertion that matters is that the argument
36
+ // changes the answer. Comparing against one hard-coded string would pass just as happily if
37
+ // the locale were ignored and both calls fell through to the runner's default.
38
+ const dutch = formatDate(WHEN, "nl");
39
+ const american = formatDate(WHEN, "en-US");
40
+ expect(dutch).not.toBe(american);
41
+ // Day-first versus month-first is the visible difference, and it survives both an ICU update
42
+ // and a runner in a zone where the local calendar date is already the next day.
43
+ expect(dutch).toMatch(/^\d/);
44
+ expect(american).toMatch(/^[A-Za-z]/);
45
+ });
46
+
47
+ it("adds a time of day only in the date-time form", () => {
48
+ // The audit log's column is titled "when"; dropping its clock would be a silent downgrade.
49
+ expect(formatDateTime(WHEN, "en-US")).toMatch(/\d{1,2}:\d{2}/);
50
+ expect(formatDate(WHEN, "en-US")).not.toMatch(/\d{1,2}:\d{2}/);
51
+ });
52
+
53
+ it("groups and separates numbers the way the locale does", () => {
54
+ expect(formatNumber(1234.5, "nl")).not.toBe(formatNumber(1234.5, "en-US"));
55
+ expect(formatNumber(1234.5, "en-US")).toBe("1,234.5");
56
+ });
57
+
58
+ it("renders an em dash for nothing, rather than throwing or printing Invalid Date", () => {
59
+ // `new Date("whenever")` yields an Invalid Date whose `format` throws a RangeError, so one
60
+ // malformed row from an API would take down a whole table instead of showing one dash.
61
+ for (const value of [null, undefined, "", "not a date", Number.NaN]) {
62
+ expect(formatDate(value, "nl")).toBe("—");
63
+ expect(formatDateTime(value, "nl")).toBe("—");
64
+ }
65
+ expect(formatNumber(null, "nl")).toBe("—");
66
+ expect(formatNumber(Number.NaN, "nl")).toBe("—");
67
+ });
68
+
69
+ it("builds one formatter per locale, not one per value", () => {
70
+ // Constructing an Intl formatter costs ~55x using one, and every table cell comes through
71
+ // here, so the first version of this file turned a locale fix into a rendering cost: a 200-row
72
+ // table with three date columns built 600 formatters per render. Counting constructions is the
73
+ // only way to see it — the output is identical either way, which is exactly why it shipped.
74
+ const original = Intl.DateTimeFormat;
75
+ let constructed = 0;
76
+ try {
77
+ (Intl as { DateTimeFormat: unknown }).DateTimeFormat = function counted(
78
+ ...args: ConstructorParameters<typeof Intl.DateTimeFormat>
79
+ ) {
80
+ constructed += 1;
81
+ return new original(...args);
82
+ };
83
+ for (let index = 0; index < 50; index += 1) {
84
+ formatDate(`2026-07-${String((index % 28) + 1).padStart(2, "0")}T12:00:00Z`, "en-GB");
85
+ }
86
+ expect(constructed).toBe(1);
87
+ // A second locale is a second formatter, not a cache that answers with the wrong one.
88
+ formatDate(WHEN, "en-IE");
89
+ expect(constructed).toBe(2);
90
+ } finally {
91
+ (Intl as { DateTimeFormat: unknown }).DateTimeFormat = original;
92
+ }
93
+ });
94
+
95
+ it("accepts the three shapes a date field arrives in", () => {
96
+ const iso = formatDate(WHEN, "en-US");
97
+ expect(formatDate(new Date(WHEN), "en-US")).toBe(iso);
98
+ expect(formatDate(new Date(WHEN).getTime(), "en-US")).toBe(iso);
99
+ });
100
+ });
101
+
102
+ function ShownDate() {
103
+ return <p data-testid="shown">{useFormatDate()(WHEN)}</p>;
104
+ }
105
+
106
+ function ShownNumber() {
107
+ return <p data-testid="shown">{useFormatNumber()(1234.5)}</p>;
108
+ }
109
+
110
+ /** Render `body` under one app locale and return what it printed. */
111
+ function shownUnder(locale: string, body: ReactNode): string {
112
+ const { unmount } = render(
113
+ <LocaleProvider locales={{ nl: NL, "en-US": EN }} defaultLocale={locale}>
114
+ {body}
115
+ </LocaleProvider>,
116
+ );
117
+ const text = screen.getByTestId("shown").textContent ?? "";
118
+ unmount();
119
+ return text;
120
+ }
121
+
122
+ describe("the hooks", () => {
123
+ // These compare TWO app locales against each other rather than one against an expected string,
124
+ // and the reason is worth stating because the first version of this file did the latter and a
125
+ // mutation proved it worthless. This machine's Node resolves to nl-NL, so
126
+ // `formatDate(value, undefined)` and `formatDate(value, "nl")` are the same string: a test that
127
+ // rendered a Dutch provider and asserted the Dutch spelling passed with the hook ignoring its
128
+ // locale entirely. It would have failed on an English host and passed here, which is worse than
129
+ // no test. Two locales cannot agree unless the locale is being dropped, on any host.
130
+
131
+ it("reads the app's locale, not the machine's", () => {
132
+ const dutch = shownUnder("nl", <ShownDate />);
133
+ const american = shownUnder("en-US", <ShownDate />);
134
+ expect(dutch).not.toBe(american);
135
+ expect(dutch).toBe(formatDate(WHEN, "nl"));
136
+ expect(american).toBe(formatDate(WHEN, "en-US"));
137
+ });
138
+
139
+ it("formats numbers through the same locale", () => {
140
+ const dutch = shownUnder("nl", <ShownNumber />);
141
+ const american = shownUnder("en-US", <ShownNumber />);
142
+ expect(dutch).not.toBe(american);
143
+ expect(american).toBe("1,234.5");
144
+ });
145
+
146
+ it("falls back to the runtime default outside a LocaleProvider", () => {
147
+ // `useLocale()` returns null with no provider, and that must mean "let Intl decide" rather
148
+ // than throw: `Field`, `DataView` and the admin screens all render fine without one.
149
+ render(<ShownDate />);
150
+ expect(screen.getByTestId("shown")).toHaveTextContent(formatDate(WHEN, undefined));
151
+ });
152
+ });
153
+
154
+ interface Dated {
155
+ id: string;
156
+ when: Date;
157
+ }
158
+
159
+ const DATED_COLUMNS: DataViewColumn<Dated>[] = [
160
+ { id: "when", header: "When", accessor: (row) => row.when, meta: { mobileSlot: "title" } },
161
+ ];
162
+
163
+ function datedView(locale: string, when: Date) {
164
+ const repository = new InMemoryDataViewRepository([{ id: "1", when }], {
165
+ getRowId: (row) => row.id,
166
+ getValue: (row, column) => row[column as keyof Dated],
167
+ });
168
+ return (
169
+ <LocaleProvider locales={{ nl: NL, "en-US": EN }} defaultLocale={locale}>
170
+ <DataView<Dated> repository={repository} columns={DATED_COLUMNS} />
171
+ </LocaleProvider>
172
+ );
173
+ }
174
+
175
+ describe("a Date in a cell", () => {
176
+ // Rendered under BOTH locales, for the reason stated at the top of the hook block: the runner is
177
+ // nl-NL, so a single Dutch render would go green with `useCellFormatter` ignoring its locale
178
+ // entirely. That is what the first version of this test did — twenty lines under a comment
179
+ // explaining why not to. Two locales cannot both be right unless the locale is being read.
180
+ const when = new Date(WHEN);
181
+
182
+ it("guards its own premise: the two spellings differ", () => {
183
+ expect(formatDate(when, "nl")).not.toBe(formatDate(when, "en-US"));
184
+ });
185
+
186
+ it("is formatted for the app's locale instead of stringified — table view", async () => {
187
+ // `accessor` returns `unknown`, so a Date is type-legal, and `String(value)` renders
188
+ // "Tue Jul 07 2026 14:00:00 GMT+0200 (Central European Summer Time)" into a table cell.
189
+ render(datedView("en-US", when));
190
+ expect(await screen.findByText(formatDate(when, "en-US"))).toBeInTheDocument();
191
+ expect(screen.queryByText(formatDate(when, "nl"))).toBeNull();
192
+ expect(screen.queryByText(String(when))).toBeNull();
193
+ cleanup();
194
+
195
+ render(datedView("nl", when));
196
+ expect(await screen.findByText(formatDate(when, "nl"))).toBeInTheDocument();
197
+ expect(screen.queryByText(formatDate(when, "en-US"))).toBeNull();
198
+ });
199
+
200
+ it("is formatted for the app's locale instead of stringified — card view", async () => {
201
+ // The stated reason for extracting `useCellFormatter` was that the two renderers had drifted
202
+ // apart unnoticed, so pinning only the desktop one would reproduce the defect the extraction
203
+ // was for. `DataView` reads `matchMedia` to pick a layout and jsdom always says no, so the
204
+ // card list is only reachable through the explicit toggle.
205
+ render(datedView("en-US", when));
206
+ expect(await screen.findByRole("table")).toBeInTheDocument();
207
+ fireEvent.click(screen.getByRole("button", { name: "Card view" }));
208
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
209
+ expect(screen.getByText(formatDate(when, "en-US"))).toBeInTheDocument();
210
+ expect(screen.queryByText(formatDate(when, "nl"))).toBeNull();
211
+ expect(screen.queryByText(String(when))).toBeNull();
212
+ });
213
+ });
package/src/format.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { useCallback } from "react";
2
+
3
+ import { useLocale } from "./locale";
4
+
5
+ /**
6
+ * Locale-aware date and number formatting.
7
+ *
8
+ * Seven places in this package formatted a date with `toLocaleDateString()` or
9
+ * `toLocaleString()` and no locale argument, which asks the *browser* what language to use. An app
10
+ * that ships Dutch through `LocaleProvider` therefore rendered its own admin tables in whatever
11
+ * the visitor's OS was set to, one row above a `DatePicker` that got it right — because the
12
+ * correct helper already existed, private, in a file about calendars.
13
+ *
14
+ * That helper is now here, unchanged, and `DatePicker` imports it back. Adopting its exact shape
15
+ * rather than inventing one is deliberate: it is the only date rendering in the package that was
16
+ * already locale-correct, so it is the one that defines the house shape, and moving it moves no
17
+ * pixels.
18
+ *
19
+ * Each formatter comes in two forms. The hook reads the app's locale from context and is what a
20
+ * component should use; the plain function takes the locale explicitly, for a caller that already
21
+ * has one or is not a component. The hooks are `useCallback`-stable so a column list built in a
22
+ * `useMemo` can depend on one without rebuilding every render.
23
+ */
24
+
25
+ /** Anything a record's date field plausibly arrives as. */
26
+ export type FormattableDate = Date | string | number | null | undefined;
27
+
28
+ /**
29
+ * What an absent or unparseable date renders as.
30
+ *
31
+ * An em dash rather than an empty cell, because a blank reads as "still loading" in a table and as
32
+ * a layout bug in a detail list. This is the glyph the audit screen already used for the same job.
33
+ */
34
+ const EMPTY = "—";
35
+
36
+ /**
37
+ * Parse without throwing, and treat an unparseable value as absent.
38
+ *
39
+ * `new Date("not a date")` yields an Invalid Date whose `format` throws a RangeError, so a single
40
+ * malformed row from an API would take down the whole table rather than showing one dash.
41
+ */
42
+ function toDate(value: FormattableDate): Date | null {
43
+ if (value === null || value === undefined) {
44
+ return null;
45
+ }
46
+ const date = value instanceof Date ? value : new Date(value);
47
+ return Number.isNaN(date.getTime()) ? null : date;
48
+ }
49
+
50
+ const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
51
+ year: "numeric",
52
+ month: "short",
53
+ day: "numeric",
54
+ };
55
+
56
+ const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
57
+ ...DATE_OPTIONS,
58
+ hour: "2-digit",
59
+ minute: "2-digit",
60
+ };
61
+
62
+ /**
63
+ * Formatters, kept.
64
+ *
65
+ * Constructing an `Intl` formatter is expensive out of all proportion to using one — measured on
66
+ * this repository's Node, roughly 55x a cached instance and 69x the `toLocaleDateString()` these
67
+ * helpers replaced, because the built-in call is serviced from V8's own cache for the default
68
+ * locale and an explicit constructor is not. Every cell of every table goes through here, so
69
+ * building one per value turned a locale fix into a rendering cost: a 200-row table with three
70
+ * date columns is 600 constructions per render.
71
+ *
72
+ * The keys are a closed set in practice — an app declares its locales in `LocaleProvider` — so
73
+ * this is a cache with no eviction on purpose rather than by oversight.
74
+ */
75
+ const DATE_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
76
+ const NUMBER_FORMATTERS = new Map<string, Intl.NumberFormat>();
77
+
78
+ function dateFormatter(locale: string | undefined, withTime: boolean): Intl.DateTimeFormat {
79
+ const key = `${withTime ? "t" : "d"}|${locale ?? ""}`;
80
+ let formatter = DATE_FORMATTERS.get(key);
81
+ if (formatter === undefined) {
82
+ formatter = new Intl.DateTimeFormat(locale, withTime ? DATE_TIME_OPTIONS : DATE_OPTIONS);
83
+ DATE_FORMATTERS.set(key, formatter);
84
+ }
85
+ return formatter;
86
+ }
87
+
88
+ function numberFormatter(
89
+ locale: string | undefined,
90
+ options: Intl.NumberFormatOptions | undefined,
91
+ ): Intl.NumberFormat {
92
+ // Options are a caller's object rather than one of two constants, so they join the key. Two
93
+ // equivalent objects written in a different order miss each other, which costs one extra
94
+ // formatter and never a wrong answer.
95
+ const key = `${locale ?? ""}|${options === undefined ? "" : JSON.stringify(options)}`;
96
+ let formatter = NUMBER_FORMATTERS.get(key);
97
+ if (formatter === undefined) {
98
+ formatter = new Intl.NumberFormat(locale, options);
99
+ NUMBER_FORMATTERS.set(key, formatter);
100
+ }
101
+ return formatter;
102
+ }
103
+
104
+ /** Locale-explicit short date, e.g. `7 jul 2026` under `nl`. `EMPTY` for absent or unparseable. */
105
+ export function formatDate(value: FormattableDate, locale: string | undefined): string {
106
+ const date = toDate(value);
107
+ return date === null ? EMPTY : dateFormatter(locale, false).format(date);
108
+ }
109
+
110
+ /** The same date with the time of day, for a column whose subject is *when* something happened. */
111
+ export function formatDateTime(value: FormattableDate, locale: string | undefined): string {
112
+ const date = toDate(value);
113
+ return date === null ? EMPTY : dateFormatter(locale, true).format(date);
114
+ }
115
+
116
+ /** Locale-explicit number, with the grouping and decimal separators the locale expects. */
117
+ export function formatNumber(
118
+ value: number | null | undefined,
119
+ locale: string | undefined,
120
+ options?: Intl.NumberFormatOptions,
121
+ ): string {
122
+ return value === null || value === undefined || Number.isNaN(value)
123
+ ? EMPTY
124
+ : numberFormatter(locale, options).format(value);
125
+ }
126
+
127
+ /** {@link formatDate} bound to the app's locale. */
128
+ export function useFormatDate(): (value: FormattableDate) => string {
129
+ const locale = useLocale()?.locale;
130
+ return useCallback((value: FormattableDate) => formatDate(value, locale), [locale]);
131
+ }
132
+
133
+ /** {@link formatDateTime} bound to the app's locale. */
134
+ export function useFormatDateTime(): (value: FormattableDate) => string {
135
+ const locale = useLocale()?.locale;
136
+ return useCallback((value: FormattableDate) => formatDateTime(value, locale), [locale]);
137
+ }
138
+
139
+ /** {@link formatNumber} bound to the app's locale. */
140
+ export function useFormatNumber(): (
141
+ value: number | null | undefined,
142
+ options?: Intl.NumberFormatOptions,
143
+ ) => string {
144
+ const locale = useLocale()?.locale;
145
+ return useCallback(
146
+ (value: number | null | undefined, options?: Intl.NumberFormatOptions) =>
147
+ formatNumber(value, locale, options),
148
+ [locale],
149
+ );
150
+ }
package/src/icons.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import type { IconName } from "@terpjs/contract";
1
2
  import type { ReactNode } from "react";
2
3
 
3
4
  import { injectTerpStyles } from "./styles";
@@ -27,8 +28,28 @@ const svgProps = {
27
28
  focusable: false,
28
29
  } as const;
29
30
 
30
- /** The bundled glyphs, keyed by the names a manifest's `NavItem.icon` may use. */
31
- export const ICON_GLYPHS: Record<string, ReactNode> = {
31
+ // `IconName` must be the literal union, not `string`. Drop the `as const` from `ICON_NAMES` in
32
+ // the contract and this directive becomes unused, which TypeScript reports as an error — which
33
+ // is the point of writing it here rather than trusting the annotation over there. A scan cannot
34
+ // check this, because the thing being checked is what the scan would be built out of.
35
+ // @ts-expect-error the union must reject a name that is not in it
36
+ const _iconNameIsNotWidened: IconName = "no-such-glyph";
37
+ void _iconNameIsNotWidened;
38
+
39
+ /**
40
+ * The bundled glyphs, keyed by the names a manifest's `NavItem.icon` may use.
41
+ *
42
+ * `satisfies Record<IconName, ReactNode>` is exhaustive in **both** directions at compile time:
43
+ * a glyph whose name is not in {@link ICON_NAMES} is an excess-property error, and a name with
44
+ * no glyph is a missing-property error. That is strictly stronger than the parity test ADR 0097
45
+ * §5 asked for — a test can only run after a build that already succeeded, and it would have to
46
+ * be kept in sync by hand — and it costs one keyword.
47
+ *
48
+ * The exported type stays `Record<string, ReactNode>` deliberately. `NavIcon` indexes this table
49
+ * with a `string` (its fallback is a designed behaviour, not an error), so narrowing the export
50
+ * would make that a type error at a call site the design wants to keep working.
51
+ */
52
+ const GLYPHS = {
32
53
  home: (
33
54
  <svg {...svgProps}>
34
55
  <path d="M3 10.5 12 3l9 7.5" />
@@ -433,7 +454,34 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
433
454
  <path d="M13 3 4 14h6l-1 7 9-11h-6l1-7Z" />
434
455
  </svg>
435
456
  ),
436
- };
457
+ // The password reveal, and the only pair here where one glyph is the other struck through.
458
+ // The slash is part of `eye-off` rather than an overlay, so the two swap as whole glyphs and
459
+ // neither depends on the other rendering underneath it.
460
+ eye: (
461
+ <svg {...svgProps}>
462
+ <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z" />
463
+ <circle cx="12" cy="12" r="3" />
464
+ </svg>
465
+ ),
466
+ "eye-off": (
467
+ <svg {...svgProps}>
468
+ <path d="M10.6 6.2A9.9 9.9 0 0 1 12 5c6.4 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.1" />
469
+ <path d="M6.6 6.8A17.7 17.7 0 0 0 2 12s3.6 7 10 7a9.8 9.8 0 0 0 4.5-1.1" />
470
+ <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
471
+ <path d="m3 3 18 18" />
472
+ </svg>
473
+ ),
474
+ } satisfies Record<IconName, ReactNode>;
475
+
476
+ /**
477
+ * The glyph table as the package publishes it.
478
+ *
479
+ * Typed as a string-keyed record rather than an IconName-keyed one, and that is not laziness:
480
+ * NavIcon looks a glyph up by a plain string on purpose, so a narrower index signature would
481
+ * break the one call site whose unknown-name behaviour is designed. The exhaustiveness lives on
482
+ * the declaration above, where it belongs.
483
+ */
484
+ export const ICON_GLYPHS: Record<string, ReactNode> = GLYPHS;
437
485
 
438
486
  export interface NavIconProps {
439
487
  /** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
@@ -460,8 +508,22 @@ export function NavIcon({ name, label }: NavIconProps) {
460
508
  }
461
509
 
462
510
  export interface IconProps {
463
- /** Glyph name from {@link ICON_GLYPHS}; unknown names render nothing. */
464
- name: string;
511
+ /**
512
+ * Which glyph to render, by checked name.
513
+ *
514
+ * A checked name here and a plain `string` on {@link NavIconProps}, and the difference is the
515
+ * failure mode rather than the audience. `NavIcon` falls back to the label's initial in a
516
+ * tile — visible, designed, and gated by a specimen — so an unknown name there produces
517
+ * something. `Icon` rendered **nothing**: an empty box where a glyph should be, which is
518
+ * exactly the silent no-op this codebase refuses everywhere else.
519
+ *
520
+ * That was not hypothetical. The workbench's own icon gallery rendered
521
+ * `<Icon name="close" />` — there is no `close` glyph, the glyph is `x` — and shipped a blank
522
+ * cell with a caption under it. Its baseline recorded the blank and passed for releases,
523
+ * because a missing glyph and a glyph that is not there look identical. The type is what
524
+ * turns that into a build failure.
525
+ */
526
+ name: IconName;
465
527
  /**
466
528
  * CSS length applied to width & height (default `1em`, so the glyph tracks
467
529
  * the surrounding text size). All strokes use `currentColor`.
package/src/index.ts CHANGED
@@ -23,11 +23,23 @@ export type {
23
23
  } from "./realtime";
24
24
  export { unwrap, unwrapOptional, ApiError } from "./unwrap";
25
25
  export type { FetchResult } from "./unwrap";
26
+ export {
27
+ formatDate,
28
+ formatDateTime,
29
+ formatNumber,
30
+ useFormatDate,
31
+ useFormatDateTime,
32
+ useFormatNumber,
33
+ } from "./format";
34
+ export type { FormattableDate } from "./format";
26
35
  export { ResourceList } from "./ResourceList";
27
36
  export type { ResourceListProps } from "./ResourceList";
28
37
  export { RequireAuth } from "./RequireAuth";
29
38
  export type { RequireAuthProps } from "./RequireAuth";
30
- export { visibleNav } from "./nav";
39
+ export { groupNav, isDeclarationVisible, visibleNav } from "./nav";
40
+ export type { NavSection, NavVisibilityContext } from "./nav";
41
+ export { activeNavPath, isNavItemActive } from "./navActive";
42
+ export type { NavActiveCandidate } from "./navActive";
31
43
  export {
32
44
  AppShell,
33
45
  SIDEBAR_STORAGE_KEY,
@@ -51,6 +63,9 @@ export { Page } from "./Page";
51
63
  export type { PageProps } from "./Page";
52
64
  export { LAYOUT_CONTRACTS } from "./layoutContract";
53
65
  export type { LayoutContractSpec, LayoutSlotSpec } from "./layoutContract";
66
+ // `ResolvedLayout` is deliberately NOT exported: nothing hands one to app code, so it
67
+ // would be public surface a consumer can never hold.
68
+ export type { LayoutDeclaration, LayoutShellDeclaration } from "./layoutDeclaration";
54
69
  export { PageActions } from "./PageActions";
55
70
  export type { OverflowAction, PageActionsProps } from "./PageActions";
56
71
  export { ModuleNav } from "./ModuleNav";
@@ -59,6 +74,17 @@ export { OverviewPage } from "./OverviewPage";
59
74
  export type { OverviewPageProps } from "./OverviewPage";
60
75
  export { DetailPage } from "./DetailPage";
61
76
  export type { DetailPageProps } from "./DetailPage";
77
+ export { FormPage } from "./FormPage";
78
+ export type { FormPageProps } from "./FormPage";
79
+ export { SettingsPage } from "./SettingsPage";
80
+ export type { SettingsPageProps } from "./SettingsPage";
81
+ export { SplitPage, SplitPane } from "./SplitPage";
82
+ export type {
83
+ SplitPageProps,
84
+ SplitPaneProps,
85
+ SplitPaneRole,
86
+ SplitListWidth,
87
+ } from "./SplitPage";
62
88
  export { HubPage, HubCard } from "./HubPage";
63
89
  export type { HubPageProps, HubCardProps, RenderHubCardLink } from "./HubPage";
64
90
  export { UiTextProvider, useStrings, useUiText, resolveUiText, DEFAULT_STRINGS } from "./uiText";
@@ -76,11 +102,11 @@ export type { LoadingStateProps, InlineSpinnerProps } from "./LoadingState";
76
102
  export { ToastProvider, useToast } from "./toast";
77
103
  export type { ToastApi, ToastOptions, ToastProviderProps, ToastVariant } from "./toast";
78
104
  export { Button } from "./ui/Button";
79
- export type { ButtonProps, ButtonVariant } from "./ui/Button";
105
+ export type { ButtonProps, ButtonSize, ButtonVariant } from "./ui/Button";
80
106
  export { Input } from "./ui/Input";
81
107
  export type { InputProps } from "./ui/Input";
82
108
  export { Select } from "./ui/Select";
83
- export type { SelectProps } from "./ui/Select";
109
+ export type { SelectProps, SelectOption } from "./ui/Select";
84
110
  export { Textarea } from "./ui/Textarea";
85
111
  export type { TextareaProps } from "./ui/Textarea";
86
112
  export { Popover } from "./ui/Popover";
@@ -99,10 +125,12 @@ export { Switch } from "./ui/Switch";
99
125
  export type { SwitchProps } from "./ui/Switch";
100
126
  export { Tabs } from "./ui/Tabs";
101
127
  export type { TabItem, TabsProps } from "./ui/Tabs";
128
+ export { Avatar } from "./ui/Avatar";
129
+ export type { AvatarProps } from "./ui/Avatar";
102
130
  export { Badge } from "./ui/Badge";
103
131
  export type { BadgeProps, BadgeTone } from "./ui/Badge";
104
132
  export { Card } from "./ui/Card";
105
- export type { CardProps } from "./ui/Card";
133
+ export type { CardProps, CardVariant } from "./ui/Card";
106
134
  export { Tooltip } from "./ui/Tooltip";
107
135
  export type { TooltipProps } from "./ui/Tooltip";
108
136
  export { Alert } from "./ui/Alert";
@@ -111,10 +139,32 @@ export { Markdown } from "./ui/Markdown";
111
139
  export type { MarkdownProps } from "./ui/Markdown";
112
140
  export { saveBlob, useEndpointDownload, fetchDownload, downloadUrl } from "./download";
113
141
  export type { DownloadTarget } from "./download";
142
+ export { Heading, Text, Code, Link } from "./typography";
143
+ export type {
144
+ HeadingProps,
145
+ HeadingLevel,
146
+ HeadingSize,
147
+ TextProps,
148
+ TextTone,
149
+ TextSize,
150
+ CodeProps,
151
+ LinkProps,
152
+ } from "./typography";
114
153
  export { Field } from "./Field";
115
154
  export type { FieldProps } from "./Field";
116
- export { Stack, DetailList } from "./layout";
117
- export type { StackProps, DetailListProps, DetailItem, SpaceToken } from "./layout";
155
+ export { Stack, Grid, Divider, DetailList } from "./layout";
156
+ export type {
157
+ StackProps,
158
+ GridProps,
159
+ GridColumns,
160
+ GridMinColumn,
161
+ DividerProps,
162
+ Responsive,
163
+ DetailListProps,
164
+ DetailListLayout,
165
+ DetailItem,
166
+ SpaceToken,
167
+ } from "./layout";
118
168
  export {
119
169
  buildAppRouter,
120
170
  DEFAULT_ROLE_RANKS,