@terpjs/react-core 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
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,
@@ -0,0 +1,118 @@
1
+ {
2
+ "$comment": "The vocabulary of frontend/layout-contract.json as THIS release reads it — the standard's layout-declaration.schema.json with the per-stack value sets filled in. Published so a tool that edits an app's files can offer exactly the keys and values the app's own pinned framework will honour, rather than the ones the tool happens to know about; that inversion is the whole point, because an unknown key is refused at compose time rather than ignored. Hand-written rather than generated: every machine fact here — the keys at all three levels, the enums, the declared types, the emptiness floors, the required pair and the unknown-key refusals — mirrors a literal in layoutDeclaration.ts, themes.ts or layoutContract.ts, and layout.manifest.test.ts holds each of them to its source. The titles and descriptions are the part no generator could produce, and are held only to being present. The copy stays a copy, and the copy is checked.",
3
+ "title": "The app's layout declaration",
4
+ "description": "What this app declares once, in a file both its build-time checker and its running shell read. Every key is optional and an absent key is not a default: it is the app declining to declare, leaving whatever was already in force alone. Declaring a key here and passing the matching bootstrap option in code is refused when the router is composed.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "contract": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "title": "Page contract",
12
+ "enum": [
13
+ "standard"
14
+ ],
15
+ "description": "Constrain every page archetype's body to the contract's own components, checked at lint time and again on the rendered page. Leaving it unset keeps archetype-only enforcement. Adopting one on an app that has none can turn a green build red, because it starts governing screens that were legal a moment earlier."
16
+ },
17
+ "defaultTheme": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "title": "Starting palette",
21
+ "enum": [
22
+ "light",
23
+ "dark",
24
+ "midnight",
25
+ "twilight",
26
+ "contrast",
27
+ "system"
28
+ ],
29
+ "description": "The palette the app opens on, until a person picks another from the theme menu and for as long as they have not. \"system\" opens on whatever light or dark preference the viewer's own device reports."
30
+ },
31
+ "shell": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "title": "Shell",
35
+ "description": "The frame around every page: how dense its controls are, where its navigation sits, how wide its content runs, and which groups that navigation is arranged into.",
36
+ "properties": {
37
+ "density": {
38
+ "type": "string",
39
+ "title": "Density",
40
+ "enum": [
41
+ "comfortable",
42
+ "compact"
43
+ ],
44
+ "description": "Control height and table cell padding for the whole app, from one attribute on the shell. Compact fits more rows on a screen; comfortable is easier to hit on a touch device."
45
+ },
46
+ "navPlacement": {
47
+ "type": "string",
48
+ "title": "Navigation placement",
49
+ "enum": [
50
+ "sidebar",
51
+ "header"
52
+ ],
53
+ "description": "Where the primary navigation sits on a wide screen: a full-height rail beside the content, or a row inside the header with no rail at all. On a narrow screen both become the same drawer."
54
+ },
55
+ "contentWidth": {
56
+ "type": "string",
57
+ "title": "Content width",
58
+ "enum": [
59
+ "full",
60
+ "measured"
61
+ ],
62
+ "description": "Whether a page's content runs the full width of the track or is capped at the shell's reading measure. Measured keeps long prose readable; full suits wide tables."
63
+ },
64
+ "brand": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "title": "Brand mark",
68
+ "description": "The image your app is recognised by, shown beside its name in the header. Put the file in the app's frontend/public folder and name it here the way a browser would ask for it, starting with a slash.",
69
+ "properties": {
70
+ "logo": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "title": "Mark",
74
+ "description": "The mark shown inside the app. It is sized to the header's own box, so a large file is scaled down rather than cut off."
75
+ },
76
+ "logoDark": {
77
+ "type": "string",
78
+ "minLength": 1,
79
+ "title": "Mark for dark palettes",
80
+ "description": "A second file for the dark palettes. Only worth setting when the first one has fixed colours that disappear on a dark background — most company marks do. Leave it empty and the same mark is used everywhere."
81
+ }
82
+ }
83
+ },
84
+ "navGroups": {
85
+ "type": "array",
86
+ "title": "Navigation groups",
87
+ "description": "The named sections the navigation is arranged into. A module's own navigation item points at one by id; an item naming a group that is not declared here is not an error, it simply lands in a trailing section with no heading. Declared here rather than on a module, because a group spans modules and no module can own one. Two groups may not share an id: every field of the second one is legal on its own, and the app still refuses to start.",
88
+ "items": {
89
+ "type": "object",
90
+ "additionalProperties": false,
91
+ "required": [
92
+ "id",
93
+ "label"
94
+ ],
95
+ "properties": {
96
+ "id": {
97
+ "type": "string",
98
+ "minLength": 1,
99
+ "title": "Id",
100
+ "description": "What a module's navigation item names to join this group. Never shown to anyone, and no two groups may share one."
101
+ },
102
+ "label": {
103
+ "type": "string",
104
+ "title": "Heading",
105
+ "description": "The heading shown above this group's links. Leave it empty for a group that shows no heading at all — a way to place items somewhere other than the end without inventing a title for them."
106
+ },
107
+ "order": {
108
+ "type": "integer",
109
+ "title": "Position",
110
+ "description": "Where this group sits against its siblings, lowest first. Groups that share a position keep the order they are written in, so a list that is already in the right order needs no positions at all."
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,205 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { LAYOUT_CONTRACTS } from "./layoutContract";
6
+ import {
7
+ BRAND_FIELDS,
8
+ NAV_GROUP_FIELDS,
9
+ NAV_GROUP_REQUIRED,
10
+ SHELL_STRUCTURED_KEYS,
11
+ SHELL_VALUES,
12
+ TOP_LEVEL_KEYS,
13
+ } from "./layoutDeclaration";
14
+ import { THEMES } from "./themes";
15
+
16
+ // The published layout vocabulary against the module that enforces it.
17
+ //
18
+ // `layout.manifest.json` exists so that a tool editing an app's files can offer exactly the keys
19
+ // and values THAT APP'S OWN pinned framework will honour. That inversion is the point: an unknown
20
+ // key is refused when the router is composed, so a tool working from its own idea of the
21
+ // vocabulary can hand an app a file its framework will not start on. Reading the vocabulary out
22
+ // of the app's own node_modules removes the guess.
23
+ //
24
+ // Which makes every drift here a live defect rather than a tidiness problem, and both directions
25
+ // fail differently:
26
+ //
27
+ // * A key or value the resolver reads and the manifest omits is a choice no tool will ever
28
+ // offer — declarable, documented, and invisible to the audience the file was published for.
29
+ // * A key or value the manifest offers and the resolver does not read is worse: a tool writes
30
+ // it in good faith and the app refuses to start, naming a key the tool was told to use.
31
+ //
32
+ // The manifest is hand-written rather than generated, for the reason `theme.themes.test.ts` gives
33
+ // about the theme union: generating it would need a TypeScript loader in a build step this package
34
+ // does not have, and half its content — the titles and descriptions an operator reads — is not
35
+ // derivable from the source at all. So the copy stays a copy, and the copy is checked.
36
+ //
37
+ // What "checked" covers, precisely, because an earlier version of this comment claimed more than
38
+ // the assertions delivered: every key at all three levels, every enum, every declared type, every
39
+ // emptiness floor, the required pair, the unknown-key refusals, and the export subpath. What it
40
+ // does NOT cover is prose — a title or description is asserted present, never compared to
41
+ // anything, because there is nothing to compare it to.
42
+
43
+ interface ManifestProperty {
44
+ type?: string;
45
+ title?: string;
46
+ description?: string;
47
+ enum?: string[];
48
+ properties?: Record<string, ManifestProperty>;
49
+ items?: ManifestProperty;
50
+ required?: string[];
51
+ additionalProperties?: boolean;
52
+ minLength?: number;
53
+ }
54
+
55
+ const manifest: ManifestProperty = JSON.parse(
56
+ readFileSync(new URL("./layout.manifest.json", import.meta.url), "utf-8"),
57
+ );
58
+
59
+ const properties = manifest.properties ?? {};
60
+ const shell = properties.shell?.properties ?? {};
61
+ const group = shell.navGroups?.items?.properties ?? {};
62
+
63
+ /** Every property in the document, by the path a refusal would name it with. */
64
+ function everyProperty(): [string, ManifestProperty][] {
65
+ return [
66
+ ...Object.entries(properties).map(([key, value]): [string, ManifestProperty] => [key, value]),
67
+ ...Object.entries(shell).map(([key, value]): [string, ManifestProperty] => [
68
+ `shell.${key}`,
69
+ value,
70
+ ]),
71
+ ...Object.entries(group).map(([key, value]): [string, ManifestProperty] => [
72
+ `shell.navGroups.items.${key}`,
73
+ value,
74
+ ]),
75
+ ];
76
+ }
77
+
78
+ describe("the published layout vocabulary", () => {
79
+ it("reads the manifest it is asserting about", () => {
80
+ // Every assertion below compares against something parsed out of the file. A file that
81
+ // failed to parse into the expected shape would compare empty objects and report green,
82
+ // which is the one failure mode a parity test cannot afford.
83
+ expect(Object.keys(properties).length).toBeGreaterThan(2);
84
+ expect(Object.keys(shell).length).toBeGreaterThan(2);
85
+ expect(Object.keys(group).length).toBeGreaterThan(2);
86
+ });
87
+
88
+ it("offers exactly the keys the document admits", () => {
89
+ expect(Object.keys(properties).sort()).toEqual([...TOP_LEVEL_KEYS].sort());
90
+ });
91
+
92
+ it("offers exactly the keys the shell admits", () => {
93
+ const declared = [...Object.keys(SHELL_VALUES), ...SHELL_STRUCTURED_KEYS];
94
+ expect(Object.keys(shell).sort()).toEqual(declared.sort());
95
+ });
96
+
97
+ it("offers exactly the values each shell key accepts", () => {
98
+ for (const [key, values] of Object.entries(SHELL_VALUES)) {
99
+ expect(shell[key]?.enum, `shell.${key}`).toEqual([...values]);
100
+ }
101
+ });
102
+
103
+ it("offers every palette this release ships, and no other", () => {
104
+ // Including "system", which is a real thing to declare rather than the absence of one — an
105
+ // absent key leaves whatever was in force alone, this one pins the platform preference.
106
+ expect(properties.defaultTheme?.enum).toEqual([...THEMES]);
107
+ });
108
+
109
+ it("offers every layout contract this release knows, and no other", () => {
110
+ // A contract offered here that `buildAppRouter` cannot find is an app that refuses to
111
+ // start with "Unknown layout contract", pointing at a value a tool was told was legal.
112
+ expect(properties.contract?.enum?.slice().sort()).toEqual(Object.keys(LAYOUT_CONTRACTS).sort());
113
+ });
114
+
115
+ it("offers exactly the fields a navigation group carries", () => {
116
+ expect(Object.keys(group).sort()).toEqual([...NAV_GROUP_FIELDS].sort());
117
+ // Against the resolver's own list, not against a copy of it written here. Comparing a
118
+ // mirror to a second copy of the thing it mirrors proves nothing about either.
119
+ expect(shell.navGroups?.items?.required?.slice().sort()).toEqual(
120
+ [...NAV_GROUP_REQUIRED].sort(),
121
+ );
122
+ });
123
+
124
+ it("offers exactly the slots a brand declares", () => {
125
+ const brand = shell.brand?.properties ?? {};
126
+ expect(Object.keys(brand).sort()).toEqual([...BRAND_FIELDS].sort());
127
+ expect(shell.brand?.type).toBe("object");
128
+ expect(shell.brand?.additionalProperties).toBe(false);
129
+ // Both floored and neither required: an empty path is a mark that fails to load, while an
130
+ // absent one is the app saying it has no second file.
131
+ for (const slot of BRAND_FIELDS) {
132
+ expect(brand[slot]?.type, slot).toBe("string");
133
+ expect(brand[slot]?.minLength, slot).toBe(1);
134
+ }
135
+ expect(shell.brand?.required).toBeUndefined();
136
+ });
137
+
138
+ it("refuses an unknown key at every level it has one", () => {
139
+ // The manifest describes a document whose consumer refuses unknown keys at both levels and
140
+ // on a group entry. A manifest that said otherwise would tell a tool it may write anything.
141
+ expect(manifest.additionalProperties).toBe(false);
142
+ expect(properties.shell?.additionalProperties).toBe(false);
143
+ expect(shell.navGroups?.items?.additionalProperties).toBe(false);
144
+ });
145
+
146
+ it("gives every property a title and a description", () => {
147
+ // The half of the file no generator could produce, and the reason it is hand-written. A
148
+ // property with neither renders in a form as its own key and nothing else, which for
149
+ // `navPlacement` is a shrug and for `contract` is a choice that can turn a build red.
150
+ for (const [path, property] of everyProperty()) {
151
+ expect(property.title?.trim(), `${path}: title`).toBeTruthy();
152
+ expect(property.description?.trim(), `${path}: description`).toBeTruthy();
153
+ }
154
+ });
155
+
156
+ it("declares a type for every property, so a renderer never has to guess", () => {
157
+ for (const [path, property] of everyProperty()) {
158
+ expect(property.type, `${path}: type`).toBeTruthy();
159
+ }
160
+ });
161
+
162
+ it("declares the type the resolver actually refuses anything else for", () => {
163
+ // Truthiness was the whole of this check, and truthiness is not a type: with `navGroups`
164
+ // retyped to "object" and `order` to "string" every assertion in this file stayed green,
165
+ // while `resolveNavGroups` refuses a non-array and a non-integer by name. That is exactly
166
+ // the failure the header calls worse — a tool writes `"navGroups": {}` in good faith,
167
+ // reading a type the manifest offered, and the app will not compose.
168
+ expect(shell.navGroups?.type).toBe("array");
169
+ expect(shell.navGroups?.items?.type).toBe("object");
170
+ expect(group.order?.type).toBe("integer");
171
+ expect(group.id?.type).toBe("string");
172
+ expect(group.label?.type).toBe("string");
173
+ for (const key of Object.keys(SHELL_VALUES)) {
174
+ expect(shell[key]?.type, `shell.${key}`).toBe("string");
175
+ }
176
+ });
177
+
178
+ it("declares the emptiness floors the resolver refuses, and no others", () => {
179
+ // Each of these is a value the resolver rejects, so a manifest offering it without the
180
+ // floor tells a tool the empty string is legal. And `label` must NOT carry one: the empty
181
+ // label is how the document declares a group that renders no heading, and a floor there
182
+ // would tell a tool to refuse the one way of stating that.
183
+ expect(properties.contract?.minLength).toBe(1);
184
+ expect(properties.defaultTheme?.minLength).toBe(1);
185
+ expect(group.id?.minLength).toBe(1);
186
+ expect(group.label?.minLength).toBeUndefined();
187
+ expect(group.order?.minLength).toBeUndefined();
188
+ });
189
+
190
+ it("is reachable by the subpath a consumer imports it from", () => {
191
+ // The file only does its job from inside an app's node_modules, and the only thing that
192
+ // puts it there under a stable name is the package's own export map. A rename that moved
193
+ // the file would leave every gate above green while the published path 404s in the one
194
+ // place the file is read — and this package's own suite reads it by relative URL, so
195
+ // nothing else here would notice.
196
+ const pkg: { exports: Record<string, string> } = JSON.parse(
197
+ readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
198
+ );
199
+ const subpath = pkg.exports["./layout.manifest.json"];
200
+ expect(subpath).toBe("./src/layout.manifest.json");
201
+ expect(
202
+ readFileSync(new URL(`../${subpath!.slice(2)}`, import.meta.url), "utf-8").length,
203
+ ).toBeGreaterThan(0);
204
+ });
205
+ });