@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,531 @@
1
+ import type { NavGroup } from "@terpjs/contract";
2
+
3
+ import { THEMES } from "./themes";
4
+ import type { Theme } from "./themes";
5
+
6
+ /**
7
+ * The app's checked-in layout declaration — `frontend/layout-contract.json` — as the ONE
8
+ * source for what it declares.
9
+ *
10
+ * That file already existed and already governed something: the `terp/layout-contract` lint
11
+ * rule walks up from each linted file, reads `contract`, and enforces the slot table for it.
12
+ * The runtime half read nothing — it took a `layoutContract` string passed in TypeScript. So
13
+ * an app declared the same fact twice, and the template said so out loud: "keep the two in
14
+ * sync", with `layoutContract: "standard"` in `main.tsx` beside `{"contract": "standard"}` in
15
+ * the file. Delete one and the app keeps a build-time rule with no runtime check, or a runtime
16
+ * check no lint agrees with, and nothing says which.
17
+ *
18
+ * So the file is imported and passed in, and it carries the shell's declarations too. Three
19
+ * shell choices shipped in 0.10.0 as TypeScript options only — density, navigation placement
20
+ * and the content measure — which made them unreachable to anything that edits files rather
21
+ * than code. Moving them into the declaration is what lets a project's own checked-in document
22
+ * say how its shell is shaped, and therefore what lets a tool read and rewrite it.
23
+ *
24
+ * **Opt-in and non-exclusive** (the authoring-model rule the platform holds itself to): an app
25
+ * that passes no declaration behaves exactly as it did, and hand-writing every option in
26
+ * TypeScript stays legal and complete. What is refused is declaring one fact in both places —
27
+ * see {@link resolveLayoutDeclaration}, and the reason is not tidiness.
28
+ */
29
+
30
+ /**
31
+ * The shell's own declarations. Every key optional; an absent key changes nothing.
32
+ *
33
+ * **The value types are `string`, not the unions, and that is forced rather than sloppy.**
34
+ * `resolveJsonModule` types an imported JSON string as `string` — never as its literal — so
35
+ * `{ "shell": { "density": "compact" } }` imports as `{ shell: { density: string } }`. Declaring
36
+ * the unions here would make the template stop typechecking the moment an app filled the shell
37
+ * section in, which is the one thing this declaration is for. Measured before choosing: tsc
38
+ * reports "Type 'string' is not assignable to type '\"compact\" | \"comfortable\"'".
39
+ *
40
+ * So the legal values live in {@link resolveLayoutDeclaration}'s runtime check, and that check
41
+ * is not belt-and-braces — it is the ONLY check this key ever gets. The unions survive on
42
+ * {@link ResolvedLayout}, which is what the shell is handed.
43
+ */
44
+ export interface LayoutShellDeclaration {
45
+ /** App-wide density (`AppShell.density`): `"comfortable"` or `"compact"`. */
46
+ readonly density?: string;
47
+ /** Navigation placement on desktop (`AppShell.navPlacement`): `"sidebar"` or `"header"`. */
48
+ readonly navPlacement?: string;
49
+ /** Content measure (`AppShell.contentWidth`): `"full"` or `"measured"`. */
50
+ readonly contentWidth?: string;
51
+ /**
52
+ * The app's navigation groups, which a module's `NavItem.group` names by id.
53
+ *
54
+ * Declarable here and nowhere else in a file, for the reason groups exist at all: a group
55
+ * spans modules, so no module can own one — which left the app's own code as the only place
56
+ * one could be declared, and therefore left the order of an app's navigation out of reach of
57
+ * anything that edits files.
58
+ *
59
+ * Unlike the three keys above, `id` and `label` are typed as REQUIRED here, and that is not
60
+ * a lapse in the measured `string` rule beside it. That rule is about a value's TYPE — a JSON
61
+ * string never narrows to its literal, so a union would stop an app's own file typechecking.
62
+ * Presence is a different question, and one `resolveJsonModule` answers accurately: an
63
+ * imported file missing a label is a compile error naming the field, which is strictly better
64
+ * than the runtime refusal below. Both exist, because a declaration handed in at runtime gets
65
+ * only the second.
66
+ */
67
+ readonly navGroups?: readonly LayoutNavGroupDeclaration[];
68
+ /**
69
+ * The app's own mark, as paths its static-asset directory serves.
70
+ *
71
+ * Paths rather than elements, because a file is what a tool can put somewhere and a
72
+ * `ReactNode` is not. The bootstrap option stays a `ReactNode` and stays legal — an app
73
+ * that wants an inline SVG or a component keeps writing one — and declaring the same slot
74
+ * both ways is refused where both halves are visible, in `buildAppRouter`.
75
+ */
76
+ readonly brand?: LayoutBrandDeclaration;
77
+ }
78
+
79
+ /**
80
+ * The mark, and its dark counterpart.
81
+ *
82
+ * The counterpart is declared rather than derived: a mark with fixed colours cannot survive a
83
+ * dark background, and nothing here can tell whether this one can. An app with one mark
84
+ * declares one and keeps it everywhere, which is the right answer for a mark that does survive.
85
+ */
86
+ export interface LayoutBrandDeclaration {
87
+ /** The mark inside the app's own frame; the shell sizes it, so an oversized file is scaled. */
88
+ readonly logo?: string;
89
+ /** The mark for a dark palette. The stylesheet picks, by the appearance of the palette in
90
+ * force rather than by the viewer's platform — an app may pin a dark palette on a light
91
+ * platform, and a mark chosen by the platform would then be the wrong one. */
92
+ readonly logoDark?: string;
93
+ }
94
+
95
+ /**
96
+ * One navigation group as the file spells it.
97
+ *
98
+ * `label` is a plain `string`, not `string | null`, and the empty string is how the document
99
+ * says "render no label element at all". Two reasons, and the second is the real one. The
100
+ * standard's schema is validated by a deliberately minimal validator with no way to express
101
+ * "string or null". And required-with-an-empty-value keeps the property the runtime
102
+ * {@link NavGroup} chose `string | null` for in the first place: having no label is a decision
103
+ * the declaration STATES, rather than a key someone forgot. Optional-with-absent-meaning-none
104
+ * would have handed it straight back to omission.
105
+ */
106
+ export interface LayoutNavGroupDeclaration {
107
+ /** Referenced by `NavItem.group`. Non-empty — a group with no id is one nothing can name. */
108
+ readonly id: string;
109
+ /** Rendered above the group's list; `""` declares a positioning-only group with no label. */
110
+ readonly label: string;
111
+ /** Ascending sort key against sibling groups; absent is 0 and the sort is stable. */
112
+ readonly order?: number;
113
+ }
114
+
115
+ /**
116
+ * The contents of `frontend/layout-contract.json`.
117
+ *
118
+ * The file keeps its name. It is the file the lint rule already looks for, every scaffolded app
119
+ * already has one, and renaming it would break the one consumer that worked — for a nicer noun.
120
+ */
121
+ export interface LayoutDeclaration {
122
+ /** The slot-typed layout contract the app opts into, e.g. `"standard"`. */
123
+ readonly contract?: string;
124
+ /**
125
+ * The palette the app opens on, until a person chooses another (`ThemeProvider.defaultTheme`):
126
+ * one of the shipped theme names, or `"system"` to follow the viewer's own platform preference.
127
+ *
128
+ * Top level rather than under `shell`, because a palette paints the frame and the page alike
129
+ * while `shell` is where the frame's geometry is declared — and because the standard splits
130
+ * the two the other way round: shell vocabulary is fixed normatively, palette names are a
131
+ * stack's own to publish, so this follows `contract`'s half of that split.
132
+ *
133
+ * `string` for the same measured reason every shell value is (see
134
+ * {@link LayoutShellDeclaration}), so the runtime check below is the only one it gets.
135
+ */
136
+ readonly defaultTheme?: string;
137
+ /** The shell's shape. Grouped, because `contract` governs page bodies and this governs chrome. */
138
+ readonly shell?: LayoutShellDeclaration;
139
+ }
140
+
141
+ /** What the resolver hands the router: one value per key, from whichever source declared it. */
142
+ export interface ResolvedLayout {
143
+ readonly contract?: string;
144
+ readonly defaultTheme?: Theme;
145
+ readonly density?: "comfortable" | "compact";
146
+ readonly navPlacement?: "sidebar" | "header";
147
+ readonly contentWidth?: "full" | "measured";
148
+ readonly navGroups?: readonly NavGroup[];
149
+ /** The declared brand PATHS. Not merged with the `logo` option, which is a rendered
150
+ * element rather than a path; `buildAppRouter` refuses the two together. */
151
+ readonly brand?: LayoutBrandDeclaration;
152
+ }
153
+
154
+ /**
155
+ * The legal values per shell key, as data.
156
+ *
157
+ * One table, so the refusal message can name the alternatives and a new key cannot be added
158
+ * without its enum. This is the ONLY place the legal values are enforced for a declaration —
159
+ * the interface above cannot carry them (see LayoutShellDeclaration) — and it is also what
160
+ * narrows a `string` from the file to the union {@link ResolvedLayout} promises.
161
+ */
162
+ export const SHELL_VALUES = {
163
+ density: ["comfortable", "compact"],
164
+ navPlacement: ["sidebar", "header"],
165
+ contentWidth: ["full", "measured"],
166
+ } as const;
167
+
168
+ type ShellEnumKey = keyof typeof SHELL_VALUES;
169
+
170
+ const SHELL_ENUM_KEYS = Object.keys(SHELL_VALUES) as readonly ShellEnumKey[];
171
+
172
+ /**
173
+ * Shell keys that are a shape rather than a choice between fixed values.
174
+ *
175
+ * Written out beside {@link SHELL_VALUES} rather than folded into it, because the two are read
176
+ * differently and by different readers: the enum table is what narrows a `string` from the file
177
+ * to the union {@link ResolvedLayout} promises, and this one only says the key exists. The
178
+ * architecture suite parses both literals to hold the whole shell vocabulary against the
179
+ * standard's schema, so a key added to neither is a key the standard would not know about.
180
+ */
181
+ export const SHELL_STRUCTURED_KEYS = ["brand", "navGroups"] as const;
182
+
183
+ /** Every key `shell` admits, in the order a refusal offers them. */
184
+ const SHELL_KEYS: readonly string[] = [...SHELL_ENUM_KEYS, ...SHELL_STRUCTURED_KEYS];
185
+
186
+ /** Every field a declared navigation group may carry. */
187
+ export const NAV_GROUP_FIELDS = ["id", "label", "order"] as const;
188
+
189
+ /**
190
+ * The fields a group must carry.
191
+ *
192
+ * Named rather than written inline in the loop that reads it, for the reason the other four
193
+ * vocabulary literals are named: the published manifest mirrors this one too, and a mirror
194
+ * compared against a copy of itself in a test proves nothing.
195
+ */
196
+ export const NAV_GROUP_REQUIRED = ["id", "label"] as const;
197
+
198
+ /** Every slot the brand declares. */
199
+ export const BRAND_FIELDS = ["logo", "logoDark"] as const;
200
+
201
+ /** Where a refusal points the reader. The file, not the option, because the file is the source. */
202
+ const FILE = "frontend/layout-contract.json";
203
+
204
+ /**
205
+ * Every key the document itself admits.
206
+ *
207
+ * Hoisted out of the refusal that reads it so that all four vocabulary literals in this module
208
+ * are named constants: the published `layout.manifest.json` mirrors them for tools, and both
209
+ * parity tests — this stack's against the standard, and the manifest's against this module —
210
+ * find them by name.
211
+ */
212
+ export const TOP_LEVEL_KEYS = ["contract", "defaultTheme", "shell"] as const;
213
+
214
+ /** A JSON object — not null, not an array, not a scalar. */
215
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
216
+ return typeof value === "object" && value !== null && !Array.isArray(value);
217
+ }
218
+
219
+ /** What the reader actually wrote, for a message that saves them opening the file.
220
+ *
221
+ * `an object` is spelled out rather than left to `a ${typeof value}`, which reported
222
+ * `"navGroups": {}` as "got a object" — a typo in the message rather than a fact about the
223
+ * file. The array case predates this and is reached by `resolveLayoutDeclaration([])` and by
224
+ * `"shell": []`, neither of which involves a group; it is listed here because both are article
225
+ * exceptions, not because either is new. */
226
+ function describe(value: unknown): string {
227
+ if (value === null) return "null";
228
+ if (value === undefined) return "nothing";
229
+ if (Array.isArray(value)) return "an array";
230
+ if (typeof value === "object") return "an object";
231
+ return `a ${typeof value}`;
232
+ }
233
+
234
+ /**
235
+ * The declared brand, checked.
236
+ *
237
+ * Every refusal is the same kind as the enum refusal on a shell value: a path the shell would
238
+ * hand to an `<img>` that cannot load it, which renders as a broken mark rather than as the
239
+ * placeholder an app with no mark gets — worse than declaring nothing, and silent until someone
240
+ * looks at the header.
241
+ */
242
+ function resolveBrand(declared: unknown): LayoutBrandDeclaration {
243
+ if (!isPlainObject(declared)) {
244
+ throw new Error(`${FILE}: shell.brand must be a JSON object, got ${describe(declared)}.`);
245
+ }
246
+ const unknown = Object.keys(declared).filter(
247
+ (key) => !(BRAND_FIELDS as readonly string[]).includes(key),
248
+ );
249
+ if (unknown.length > 0) {
250
+ throw new Error(
251
+ `${FILE}: shell.brand has unknown ${unknown.length === 1 ? "mark" : "marks"} ` +
252
+ `${unknown.map((key) => `"${key}"`).join(", ")}; a brand is ` +
253
+ `${BRAND_FIELDS.map((key) => `"${key}"`).join(" and ")}.`,
254
+ );
255
+ }
256
+ const brand: { logo?: string; logoDark?: string } = {};
257
+ for (const slot of BRAND_FIELDS) {
258
+ const value = declared[slot];
259
+ if (value === undefined) continue;
260
+ if (typeof value !== "string") {
261
+ throw new Error(
262
+ `${FILE}: shell.brand.${slot} must be a path, got ${describe(value)}.`,
263
+ );
264
+ }
265
+ if (value.trim() === "") {
266
+ throw new Error(
267
+ `${FILE}: shell.brand.${slot} is empty; omit it to keep one mark for every palette.`,
268
+ );
269
+ }
270
+ brand[slot] = value;
271
+ }
272
+ return brand;
273
+ }
274
+
275
+ /** A group list as a refusal names it: the ids, in order, or the fact that there are none.
276
+ *
277
+ * Each id quoted separately, like every other list this module emits. Wrapping the joined
278
+ * string in one pair of quotes rendered two groups as `"work, admin"`, which reads as a single
279
+ * group whose id contains a comma — the opposite of the reason the ids are in the message at
280
+ * all, which is that they are the part a reader can match up against either source by eye. */
281
+ function describeGroups(groups: readonly NavGroup[]): string {
282
+ if (groups.length === 0) return "no groups";
283
+ return groups.map((group) => `"${group.id}"`).join(", ");
284
+ }
285
+
286
+ /**
287
+ * The file's navigation groups as the runtime {@link NavGroup}s the shell is handed.
288
+ *
289
+ * Every refusal here is the same kind as the enum refusal on a shell value: a group the shell
290
+ * would accept and then render as nothing, or not render at all. A group with a numeric id is
291
+ * one no `NavItem.group` string can ever match, so its items fall into the trailing unlabelled
292
+ * bucket and the group never appears — a declaration that silently does nothing, which is the
293
+ * failure this whole document exists to remove.
294
+ *
295
+ * Duplicate ids are deliberately NOT refused here. `buildAppRouter` already refuses them, with
296
+ * a message this one could only restate, and routing the resolved list through that check means
297
+ * one refusal covers a group declared in the file and a group passed as an option alike.
298
+ */
299
+ function resolveNavGroups(declared: unknown): readonly NavGroup[] {
300
+ if (!Array.isArray(declared)) {
301
+ throw new Error(
302
+ `${FILE}: shell.navGroups must be an array, got ${describe(declared)}.`,
303
+ );
304
+ }
305
+ return declared.map((entry: unknown, index: number): NavGroup => {
306
+ const at = `shell.navGroups[${index}]`;
307
+ if (!isPlainObject(entry)) {
308
+ throw new Error(`${FILE}: ${at} must be a JSON object, got ${describe(entry)}.`);
309
+ }
310
+ const unknown = Object.keys(entry).filter(
311
+ (key) => !(NAV_GROUP_FIELDS as readonly string[]).includes(key),
312
+ );
313
+ if (unknown.length > 0) {
314
+ throw new Error(
315
+ `${FILE}: ${at} has unknown ${unknown.length === 1 ? "field" : "fields"} ` +
316
+ `${unknown.map((key) => `"${key}"`).join(", ")}; a group is ` +
317
+ `${NAV_GROUP_FIELDS.map((key) => `"${key}"`).join(", ")}.`,
318
+ );
319
+ }
320
+ // A missing required field is reported as missing, not as a type. "must be a string, got
321
+ // nothing" sends the reader looking for a value they never wrote; the schema requires both
322
+ // fields, so the message should say which one is absent.
323
+ for (const field of NAV_GROUP_REQUIRED) {
324
+ if (entry[field] === undefined) {
325
+ throw new Error(
326
+ `${FILE}: ${at} is missing "${field}"; every group declares ` +
327
+ `${NAV_GROUP_REQUIRED.map((name) => `"${name}"`).join(" and ")}` +
328
+ (field === "label" ? ', and "" is a group that renders no label at all.' : "."),
329
+ );
330
+ }
331
+ }
332
+ if (typeof entry.id !== "string") {
333
+ throw new Error(`${FILE}: ${at}.id must be a string, got ${describe(entry.id)}.`);
334
+ }
335
+ if (entry.id === "") {
336
+ throw new Error(
337
+ `${FILE}: ${at}.id is the empty string; a group with no id is one no navigation ` +
338
+ "item can name.",
339
+ );
340
+ }
341
+ if (typeof entry.label !== "string") {
342
+ throw new Error(
343
+ `${FILE}: ${at}.label must be a string, got ${describe(entry.label)}; use "" for a ` +
344
+ "group that renders no label at all.",
345
+ );
346
+ }
347
+ // The one line where the document's spelling and the runtime's meet: `""` is how a file
348
+ // says "render no label element", and `null` is how NavGroup says the same thing.
349
+ const group: NavGroup = { id: entry.id, label: entry.label === "" ? null : entry.label };
350
+ if (entry.order === undefined) {
351
+ return group;
352
+ }
353
+ // Split from the guard above rather than folded into one condition, because a compound
354
+ // negation over `unknown` does not narrow: `order` stayed `{} | null` and the assignment
355
+ // below did not compile. Two guards narrow it to `number` with no cast.
356
+ if (typeof entry.order !== "number" || !Number.isInteger(entry.order)) {
357
+ throw new Error(
358
+ `${FILE}: ${at}.order must be a whole number, got ` +
359
+ `${typeof entry.order === "number" ? entry.order : describe(entry.order)}; it is a ` +
360
+ "sort key against sibling groups, and absent means 0.",
361
+ );
362
+ }
363
+ return { ...group, order: entry.order };
364
+ });
365
+ }
366
+
367
+ /**
368
+ * Resolve the declaration against the options an app passed in TypeScript.
369
+ *
370
+ * Returns *explicit* untouched when there is no declaration, so an app that passes none is on
371
+ * the exact path it was on before this existed.
372
+ *
373
+ * Three things are refused, each fail-closed at compose time rather than absorbed:
374
+ *
375
+ * 1. **A value outside its enum, or a malformed navigation group.** A JSON file is not
376
+ * typechecked, so `"density": "compakt"` would otherwise reach the shell as an attribute
377
+ * value nothing styles, and a group with a numeric id would reach it as a group no item can
378
+ * name — declarations that silently do nothing, which is the failure this whole declaration
379
+ * exists to remove.
380
+ * 2. **A key the framework does not know.** Same reasoning one level up: a hand-written or
381
+ * newer-framework key would sit in the file looking effective. Refusing names it instead.
382
+ * This is the cost of the choice and it is the right way round — an app pinned to a release
383
+ * is told the release cannot honour a key, rather than being told nothing.
384
+ * 3. **One fact declared twice.** If the file says `"density": "compact"` and the app also
385
+ * passes `density: "comfortable"`, one of them loses silently. Whichever way that
386
+ * precedence went, a person editing the losing source would watch their change do nothing —
387
+ * and once a file is what tools edit, the tool becomes the one making a change that does
388
+ * nothing. So it is an authoring error, named with both sources.
389
+ */
390
+ export function resolveLayoutDeclaration(
391
+ declaration: LayoutDeclaration | undefined,
392
+ explicit: ResolvedLayout,
393
+ ): ResolvedLayout {
394
+ if (declaration === undefined) {
395
+ return explicit;
396
+ }
397
+ // The shape first, because this comes from a FILE and a file can hold anything JSON can.
398
+ // Every branch below was reached by probing rather than imagined: `null` threw a bare
399
+ // "Cannot convert undefined or null to object" with no mention of which file; a string or an
400
+ // array had its character/element indices reported as unknown KEYS; and an array was
401
+ // accepted outright — `Object.keys([])` is empty, so `[]` declared nothing and returned
402
+ // silently, which is the exact failure this module exists to prevent, in this module.
403
+ if (!isPlainObject(declaration)) {
404
+ throw new Error(`${FILE}: expected a JSON object, got ${describe(declaration)}.`);
405
+ }
406
+ const known = new Set<string>(TOP_LEVEL_KEYS);
407
+ const unknownTop = Object.keys(declaration).filter((key) => !known.has(key));
408
+ if (unknownTop.length > 0) {
409
+ throw new Error(
410
+ `${FILE}: unknown ${unknownTop.length === 1 ? "key" : "keys"} ` +
411
+ `${unknownTop.map((key) => `"${key}"`).join(", ")}; this release reads ` +
412
+ // Comma-joined like the shell message beside it. It read "a and b" while there were
413
+ // exactly two keys, which does not survive a third: "a and b and c".
414
+ `${[...known].map((key) => `"${key}"`).join(", ")}.`,
415
+ );
416
+ }
417
+
418
+ if (declaration.contract !== undefined && typeof declaration.contract !== "string") {
419
+ throw new Error(
420
+ `${FILE}: "contract" must be a string, got ${describe(declaration.contract)}.`,
421
+ );
422
+ }
423
+ if (declaration.defaultTheme !== undefined && typeof declaration.defaultTheme !== "string") {
424
+ throw new Error(
425
+ `${FILE}: "defaultTheme" must be a string, got ${describe(declaration.defaultTheme)}.`,
426
+ );
427
+ }
428
+ // Checked BEFORE defaulting, not after: `?? {}` swallows `null`, so `"shell": null` would
429
+ // have been read as "declared nothing" while `"shell": []` was refused — and the standard's
430
+ // own schema types this as an object, so it rejects null. Two consumers disagreeing about one
431
+ // document is the thing the declaration exists to stop.
432
+ if (declaration.shell !== undefined && !isPlainObject(declaration.shell)) {
433
+ throw new Error(
434
+ `${FILE}: "shell" must be a JSON object, got ${describe(declaration.shell)}.`,
435
+ );
436
+ }
437
+ const shell = declaration.shell ?? {};
438
+ const unknownShell = Object.keys(shell).filter((key) => !SHELL_KEYS.includes(key));
439
+ if (unknownShell.length > 0) {
440
+ throw new Error(
441
+ `${FILE}: unknown shell ${unknownShell.length === 1 ? "key" : "keys"} ` +
442
+ `${unknownShell.map((key) => `"${key}"`).join(", ")}; this release reads ` +
443
+ `${SHELL_KEYS.map((key) => `"${key}"`).join(", ")}.`,
444
+ );
445
+ }
446
+
447
+ const resolved: Record<string, unknown> = { ...explicit };
448
+ const conflicts: string[] = [];
449
+
450
+ if (declaration.contract !== undefined) {
451
+ if (explicit.contract !== undefined) {
452
+ conflicts.push(`"contract" (file: "${declaration.contract}", code: "${explicit.contract}")`);
453
+ }
454
+ resolved.contract = declaration.contract;
455
+ }
456
+
457
+ if (declaration.defaultTheme !== undefined) {
458
+ // The enum this key gets, and the list is imported rather than restated: `THEMES` is the
459
+ // same array the theme control offers and the provider validates a stored choice against,
460
+ // held to the compiled stylesheet by `theme.themes.test.ts`. A copy here would be a third
461
+ // place a shipped palette could go missing from.
462
+ //
463
+ // Refused rather than fallen back to a palette that does exist, which the standard states
464
+ // outright: a fallback is how a declaration ends up doing nothing while looking like it
465
+ // works — `data-theme="midnite"` matches no block, so the app renders the base palette and
466
+ // nothing anywhere says the file was ignored.
467
+ if (!(THEMES as readonly string[]).includes(declaration.defaultTheme)) {
468
+ throw new Error(
469
+ `${FILE}: "defaultTheme" is "${declaration.defaultTheme}"; expected one of ` +
470
+ `${THEMES.map((theme) => `"${theme}"`).join(", ")}.`,
471
+ );
472
+ }
473
+ if (explicit.defaultTheme !== undefined) {
474
+ conflicts.push(
475
+ `"defaultTheme" (file: "${declaration.defaultTheme}", code: "${explicit.defaultTheme}")`,
476
+ );
477
+ }
478
+ resolved.defaultTheme = declaration.defaultTheme;
479
+ }
480
+
481
+ const declaredBrand = shell.brand;
482
+ if (declaredBrand !== undefined) {
483
+ resolved.brand = resolveBrand(declaredBrand);
484
+ }
485
+
486
+ const declaredGroups = shell.navGroups;
487
+ if (declaredGroups !== undefined) {
488
+ const groups = resolveNavGroups(declaredGroups);
489
+ if (explicit.navGroups !== undefined) {
490
+ // Not the value, which is a list of objects nobody wants stringified into an error, but
491
+ // the ids — the one part of a group a reader can match up against either source by eye.
492
+ conflicts.push(
493
+ `"shell.navGroups" (file: ${describeGroups(groups)}, ` +
494
+ `code: ${describeGroups(explicit.navGroups)})`,
495
+ );
496
+ }
497
+ resolved.navGroups = groups;
498
+ }
499
+
500
+ for (const key of SHELL_ENUM_KEYS) {
501
+ const declared = shell[key];
502
+ if (declared === undefined) continue;
503
+ // A file can put a number or an object here. Refused with the type named rather than
504
+ // stringified into the enum message, where `shell.density is "7"` would read as a typo in
505
+ // a string the author never wrote.
506
+ if (typeof declared !== "string") {
507
+ throw new Error(`${FILE}: shell.${key} must be a string, got ${describe(declared)}.`);
508
+ }
509
+ const legal = SHELL_VALUES[key] as readonly string[];
510
+ if (!legal.includes(declared)) {
511
+ throw new Error(
512
+ `${FILE}: shell.${key} is "${declared}"; expected one of ` +
513
+ `${legal.map((value) => `"${value}"`).join(", ")}.`,
514
+ );
515
+ }
516
+ const fromCode = explicit[key];
517
+ if (fromCode !== undefined) {
518
+ conflicts.push(`"shell.${key}" (file: "${declared}", code: "${fromCode}")`);
519
+ }
520
+ resolved[key] = declared;
521
+ }
522
+
523
+ if (conflicts.length > 0) {
524
+ throw new Error(
525
+ `${FILE} and the bootstrap options both declare ${conflicts.join("; ")}. ` +
526
+ "Declare each in one place: the file is what a tool can read and rewrite, so " +
527
+ "prefer it and drop the option.",
528
+ );
529
+ }
530
+ return resolved as ResolvedLayout;
531
+ }
package/src/locale.tsx CHANGED
@@ -37,6 +37,8 @@ export const LOCALE_NL: LocaleCatalog = {
37
37
  signingIn: "Bezig met inloggen…",
38
38
  email: "E-mailadres",
39
39
  password: "Wachtwoord",
40
+ showPassword: "Wachtwoord tonen",
41
+ hidePassword: "Wachtwoord verbergen",
40
42
  signInFailed: "Inloggen mislukt. Controleer je gegevens.",
41
43
  fillDevCredentials: "Vul dev-inloggegevens in",
42
44
  continueWith: "Doorgaan met",
@@ -60,6 +62,7 @@ export const LOCALE_NL: LocaleCatalog = {
60
62
  role: "Rol",
61
63
  home: "Start",
62
64
  primaryNavigationLabel: "Hoofdnavigatie",
65
+ skipToContent: "Naar de inhoud",
63
66
  collapseSidebar: "Zijbalk inklappen",
64
67
  expandSidebar: "Zijbalk uitklappen",
65
68
  openNavigation: "Navigatie openen",
@@ -40,7 +40,6 @@ const STYLESHEET = "./styles.ts";
40
40
  */
41
41
  const MARKERS = [
42
42
  "admin-form",
43
- "admin-payload",
44
43
  "admin-section-title",
45
44
  "alert",
46
45
  "alert-body",
@@ -56,10 +55,15 @@ const MARKERS = [
56
55
  "appshell-header",
57
56
  "appshell-header-group",
58
57
  "appshell-main",
58
+ "appshell-mark",
59
59
  "appshell-nav",
60
+ "appshell-nav-group",
61
+ "appshell-nav-group-label",
60
62
  "appshell-nav-label",
61
63
  "appshell-nav-list",
62
64
  "appshell-sidebar",
65
+ "appshell-skip-link",
66
+ "avatar",
63
67
  "badge",
64
68
  "breadcrumbs",
65
69
  "breadcrumbs-current",
@@ -80,6 +84,8 @@ const MARKERS = [
80
84
  "card-heading",
81
85
  "card-title",
82
86
  "checkbox",
87
+ "code",
88
+ "code-block",
83
89
  "combobox",
84
90
  "combobox-empty",
85
91
  "combobox-field",
@@ -124,6 +130,7 @@ const MARKERS = [
124
130
  "dataview-toolbar-spacer",
125
131
  "dataview-toolbar-status",
126
132
  "detail-list",
133
+ "detail-list-row",
127
134
  "detail-list-term",
128
135
  "detail-list-value",
129
136
  "dialog",
@@ -131,6 +138,7 @@ const MARKERS = [
131
138
  "dialog-body",
132
139
  "dialog-description",
133
140
  "dialog-title",
141
+ "divider",
134
142
  "drawer-focus-end",
135
143
  "drawer-focus-start",
136
144
  "empty-state",
@@ -146,6 +154,8 @@ const MARKERS = [
146
154
  "field-hint",
147
155
  "field-label",
148
156
  "field-label-text",
157
+ "grid",
158
+ "heading",
149
159
  "hubcard",
150
160
  "hubcard-body",
151
161
  "hubcard-description",
@@ -158,8 +168,10 @@ const MARKERS = [
158
168
  "icon",
159
169
  "iconbutton",
160
170
  "input",
171
+ "input-password",
161
172
  "language-switcher",
162
173
  "language-switcher-label",
174
+ "link",
163
175
  "loading-state",
164
176
  "loading-state-spinner",
165
177
  "login-brand",
@@ -190,7 +202,6 @@ const MARKERS = [
190
202
  "page-title",
191
203
  "popover",
192
204
  "popover-panel",
193
- "profile-avatar",
194
205
  "profile-card",
195
206
  "profile-email",
196
207
  "profile-role",
@@ -205,12 +216,15 @@ const MARKERS = [
205
216
  "resource-list-items",
206
217
  "resource-list-row",
207
218
  "spinner-ring",
219
+ "splitpage-panes",
220
+ "splitpane",
208
221
  "stack",
209
222
  "switch",
210
223
  "tab",
211
224
  "tab-list",
212
225
  "tab-panel",
213
226
  "tabs",
227
+ "text",
214
228
  "theme-toggle",
215
229
  "theme-toggle-label",
216
230
  "toast",
@@ -221,7 +235,6 @@ const MARKERS = [
221
235
  "tooltip",
222
236
  "tooltip-anchor",
223
237
  "user-menu",
224
- "user-menu-avatar",
225
238
  "user-menu-email",
226
239
  "user-menu-header",
227
240
  "user-menu-identity",
@@ -549,8 +562,15 @@ describe("data-terp markers", () => {
549
562
  // property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
550
563
  // tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
551
564
  // collapsing into one grid item. So the property itself is what this pins.
552
- expect(Object.keys(manifest.exports)).toEqual(["."]);
553
- expect(manifest.exports["."]).toBe("./src/index.ts");
565
+ // One CODE entry point, which is the property. Data subpaths are allowed and do not
566
+ // weaken it: `./layout.manifest.json` is JSON a tool reads out of node_modules, no module
567
+ // imports it, and importing JSON loads no JavaScript at all — so there is still exactly one
568
+ // way into this package's modules. Asserted by extension rather than by key count, so that
569
+ // publishing a second entry point is what fails and publishing another data file is not.
570
+ const codeEntries = Object.entries(manifest.exports).filter(([, target]) =>
571
+ /\.(ts|tsx|js|mjs|cjs)$/.test(target),
572
+ );
573
+ expect(codeEntries).toEqual([[".", "./src/index.ts"]]);
554
574
  expect(
555
575
  "sideEffects" in manifest,
556
576
  "declaring sideEffects would let a bundler drop the modules that inject the stylesheet",