@abuzareal/contour-ui 0.5.0 → 0.6.1

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 (87) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/COMBOBOX.md +87 -0
  3. package/COMPOSITION.md +4 -0
  4. package/CONTRACTS.md +27 -27
  5. package/DOCS.md +86 -0
  6. package/FORMS.md +4 -0
  7. package/FOUNDATION.md +35 -0
  8. package/GETTING-STARTED.md +67 -0
  9. package/HELPERS.md +33 -0
  10. package/HOOKS.md +67 -0
  11. package/MANUAL-QA.md +38 -0
  12. package/MIGRATION.md +44 -0
  13. package/MODELS.md +18 -0
  14. package/NEXTJS.md +3 -1
  15. package/OVERLAYS.md +4 -0
  16. package/PROP-REFERENCE.md +37 -0
  17. package/PUBLIC-API.md +174 -164
  18. package/README.md +9 -5
  19. package/RECIPES.md +43 -0
  20. package/REFERENCE.md +193 -0
  21. package/RELEASING.md +8 -2
  22. package/SIZE.md +9 -3
  23. package/STYLING.md +4 -0
  24. package/SUPPORT.md +36 -0
  25. package/THREE.md +40 -0
  26. package/dist/components/ui/Combobox.d.ts +325 -0
  27. package/dist/components/ui/Combobox.js +135 -0
  28. package/dist/components/ui/Combobox.types.d.ts +46 -0
  29. package/dist/components/ui/Combobox.types.js +1 -0
  30. package/dist/components/ui/DataTable.js +5 -4
  31. package/dist/components/ui/Field.js +1 -1
  32. package/dist/components/ui/FloatingPortal.js +2 -40
  33. package/dist/components/ui/FloatingScope.d.ts +9 -0
  34. package/dist/components/ui/FloatingScope.js +43 -0
  35. package/dist/components/ui/Table.d.ts +20 -0
  36. package/dist/components/ui/Table.js +9 -0
  37. package/dist/hooks/useComboboxAnchor.d.ts +17 -0
  38. package/dist/hooks/useComboboxAnchor.js +61 -0
  39. package/dist/hooks/useInitialHashScroll.js +5 -1
  40. package/dist/hooks/usePrintPreparation.js +12 -1
  41. package/dist/hooks/useScrollSpy.js +6 -3
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +2 -0
  44. package/dist/kit/data-display.css +6 -0
  45. package/dist/kit/forms.css +34 -0
  46. package/dist/lib/componentClasses.js +1 -1
  47. package/dist/scoped/kit/data-display.css +6 -0
  48. package/dist/scoped/kit/forms.css +34 -0
  49. package/docs/prop-metadata.json +2668 -0
  50. package/examples/onboarding/SettingsForm.jsx +30 -0
  51. package/examples/onboarding/SettingsForm.tsx +50 -0
  52. package/examples/optional/LazyScene.tsx +78 -0
  53. package/examples/optional/OptionalSmoothScroll.tsx +25 -0
  54. package/examples/optional/SmoothScrollOwner.tsx +7 -0
  55. package/examples/optional/optional.css +25 -0
  56. package/examples/recipes/AsyncProjectPicker.tsx +82 -0
  57. package/examples/recipes/DeleteProjectConfirmation.tsx +97 -0
  58. package/examples/recipes/InputCompositions.tsx +49 -0
  59. package/examples/recipes/RemoteRecordsTable.tsx +256 -0
  60. package/examples/recipes/useRemoteResource.ts +81 -0
  61. package/examples/reference/HookExamples.tsx +146 -0
  62. package/examples/reproduction/README.md +9 -0
  63. package/examples/reproduction/index.html +14 -0
  64. package/examples/reproduction/package.json +27 -0
  65. package/examples/reproduction/src/main.tsx +33 -0
  66. package/examples/reproduction/src/style.css +19 -0
  67. package/examples/reproduction/tsconfig.json +18 -0
  68. package/examples/reproduction/vite.config.ts +19 -0
  69. package/examples/starters/next/README.md +18 -0
  70. package/examples/starters/next/app/Demo.tsx +65 -0
  71. package/examples/starters/next/app/layout.tsx +25 -0
  72. package/examples/starters/next/app/next-page/page.tsx +9 -0
  73. package/examples/starters/next/app/page.tsx +11 -0
  74. package/examples/starters/next/app/style.css +19 -0
  75. package/examples/starters/next/next-env.d.ts +2 -0
  76. package/examples/starters/next/next.config.mjs +1 -0
  77. package/examples/starters/next/package.json +27 -0
  78. package/examples/starters/next/proxy.ts +16 -0
  79. package/examples/starters/next/tsconfig.json +30 -0
  80. package/examples/starters/vite/README.md +16 -0
  81. package/examples/starters/vite/index.html +14 -0
  82. package/examples/starters/vite/package.json +27 -0
  83. package/examples/starters/vite/src/main.tsx +81 -0
  84. package/examples/starters/vite/src/style.css +19 -0
  85. package/examples/starters/vite/tsconfig.json +18 -0
  86. package/examples/starters/vite/vite.config.ts +19 -0
  87. package/package.json +37 -4
@@ -0,0 +1,256 @@
1
+ "use client";
2
+ import { useCreateAtom, useSelector } from "@tanstack/react-store";
3
+ import {
4
+ createColumnHelper,
5
+ functionalUpdate,
6
+ rowPaginationFeature,
7
+ rowSortingFeature,
8
+ tableFeatures,
9
+ useTable,
10
+ type PaginationState,
11
+ type SortingState,
12
+ } from "@tanstack/react-table";
13
+ import { Button, Table, TextField } from "@abuzareal/contour-ui";
14
+ import { useRemoteResource, type Loader } from "./useRemoteResource.js";
15
+
16
+ export type RecordRow = { id: string; name: string; owner: string };
17
+ export type RecordsQuery = {
18
+ filter: string;
19
+ sorting: SortingState;
20
+ pagination: PaginationState;
21
+ };
22
+ export type RecordsPage = {
23
+ rows: readonly RecordRow[];
24
+ total: number;
25
+ unfilteredTotal: number;
26
+ };
27
+ export type LoadRecords = Loader<RecordsQuery, RecordsPage>;
28
+ const features = tableFeatures({ rowSortingFeature, rowPaginationFeature });
29
+ const column = createColumnHelper<typeof features, RecordRow>();
30
+ const columns = column.columns([
31
+ column.accessor("name", { header: "Name" }),
32
+ column.accessor("owner", { header: "Owner" }),
33
+ ]);
34
+
35
+ /** Validate parsed JSON at the application's service boundary before giving it to this recipe. */
36
+ export function parseRecordsPage(
37
+ value: unknown,
38
+ pageSize: number,
39
+ ): RecordsPage {
40
+ if (
41
+ !value ||
42
+ typeof value !== "object" ||
43
+ !("rows" in value) ||
44
+ !Array.isArray(value.rows) ||
45
+ !("total" in value) ||
46
+ !("unfilteredTotal" in value)
47
+ )
48
+ throw new Error("Invalid records response");
49
+ const { rows, total, unfilteredTotal } = value;
50
+ if (
51
+ typeof total !== "number" ||
52
+ !Number.isSafeInteger(total) ||
53
+ total < 0 ||
54
+ typeof unfilteredTotal !== "number" ||
55
+ !Number.isSafeInteger(unfilteredTotal) ||
56
+ unfilteredTotal < total ||
57
+ rows.length > pageSize ||
58
+ rows.length > total
59
+ )
60
+ throw new Error("Invalid records count");
61
+ const ids = new Set<string>();
62
+ const parsed: RecordRow[] = rows.map((row: unknown) => {
63
+ if (
64
+ !row ||
65
+ typeof row !== "object" ||
66
+ !("id" in row) ||
67
+ typeof row.id !== "string" ||
68
+ !row.id ||
69
+ ids.has(row.id) ||
70
+ !("name" in row) ||
71
+ typeof row.name !== "string" ||
72
+ !("owner" in row) ||
73
+ typeof row.owner !== "string"
74
+ )
75
+ throw new Error("Invalid record");
76
+ ids.add(row.id);
77
+ return { id: row.id, name: row.name, owner: row.owner };
78
+ });
79
+ return { rows: parsed, total, unfilteredTotal };
80
+ }
81
+
82
+ /** The service performs sorting/filtering/pagination. This is not local DataTable sorting. */
83
+ export function RemoteRecordsTable({
84
+ loadRecords,
85
+ }: {
86
+ loadRecords: LoadRecords;
87
+ }) {
88
+ const queryAtom = useCreateAtom<RecordsQuery>({
89
+ filter: "",
90
+ sorting: [],
91
+ pagination: { pageIndex: 0, pageSize: 10 },
92
+ });
93
+ const query = useSelector(queryAtom);
94
+ const result = useRemoteResource(query, loadRecords);
95
+ const page = result.data;
96
+ const applied = result.appliedQuery;
97
+ const table = useTable({
98
+ features,
99
+ columns,
100
+ data: page?.rows ?? [],
101
+ getRowId: (row) => row.id,
102
+ manualSorting: true,
103
+ manualPagination: true,
104
+ rowCount: page?.total ?? 0,
105
+ state: { sorting: query.sorting, pagination: query.pagination },
106
+ onSortingChange: (update) =>
107
+ queryAtom.set((previous) => ({
108
+ ...previous,
109
+ sorting: functionalUpdate(update, previous.sorting),
110
+ pagination: { ...previous.pagination, pageIndex: 0 },
111
+ })),
112
+ onPaginationChange: (update) =>
113
+ queryAtom.set((previous) => ({
114
+ ...previous,
115
+ pagination: functionalUpdate(update, previous.pagination),
116
+ })),
117
+ });
118
+ const showingPrevious = !!applied && applied !== query;
119
+ const canPage =
120
+ !!page && !result.loading && !result.error && !showingPrevious;
121
+ return (
122
+ <section aria-label="Remote records">
123
+ <TextField
124
+ label="Filter records"
125
+ value={query.filter}
126
+ onChange={(event) =>
127
+ queryAtom.set((previous) => ({
128
+ ...previous,
129
+ filter: event.target.value,
130
+ pagination: { ...previous.pagination, pageIndex: 0 },
131
+ }))
132
+ }
133
+ />
134
+ <p role="status">
135
+ {result.loading
136
+ ? page
137
+ ? "Loading requested records; previous results remain below."
138
+ : "Loading records…"
139
+ : result.error
140
+ ? "Requested records are unavailable."
141
+ : `${page?.total ?? 0} records found.`}
142
+ </p>
143
+ {result.error && (
144
+ <div role="alert">
145
+ <p>
146
+ {result.error === "permission"
147
+ ? "You do not have permission to view these records."
148
+ : "Records could not be loaded. Previous results have not been replaced."}
149
+ </p>
150
+ <Button onClick={result.retry}>Retry records</Button>
151
+ </div>
152
+ )}
153
+ {page && applied && result.error !== "permission" && (
154
+ <>
155
+ <p>
156
+ Displayed results: page {applied.pagination.pageIndex + 1}, filter{" "}
157
+ {applied.filter ? `“${applied.filter}”` : "none"}, sort{" "}
158
+ {applied.sorting
159
+ .map(
160
+ (sort) =>
161
+ `${sort.id} ${sort.desc ? "descending" : "ascending"}`,
162
+ )
163
+ .join(", ") || "service default"}
164
+ .
165
+ {showingPrevious
166
+ ? " These are previous results, not the requested result set."
167
+ : ""}
168
+ </p>
169
+ <Table
170
+ caption="Records returned by the service"
171
+ aria-busy={result.loading || undefined}
172
+ >
173
+ <thead>
174
+ <tr>
175
+ {table.getHeaderGroups()[0]?.headers.map((header) => {
176
+ const sort = applied.sorting.find(
177
+ (sort) => sort.id === header.column.id,
178
+ );
179
+ return (
180
+ <th
181
+ scope="col"
182
+ key={header.id}
183
+ aria-sort={
184
+ sort ? (sort.desc ? "descending" : "ascending") : "none"
185
+ }
186
+ >
187
+ <button
188
+ type="button"
189
+ onClick={header.column.getToggleSortingHandler()}
190
+ >
191
+ <table.FlexRender header={header} />
192
+ </button>
193
+ </th>
194
+ );
195
+ })}
196
+ </tr>
197
+ </thead>
198
+ <tbody>
199
+ {table.getRowModel().rows.map((row) => (
200
+ <tr key={row.id}>
201
+ {row.getAllCells().map((cell) => (
202
+ <td key={cell.id}>
203
+ <table.FlexRender cell={cell} />
204
+ </td>
205
+ ))}
206
+ </tr>
207
+ ))}
208
+ </tbody>
209
+ </Table>
210
+ {!page.rows.length && (
211
+ <p>
212
+ {page.unfilteredTotal === 0
213
+ ? "No records exist yet."
214
+ : page.total === 0
215
+ ? "No records match this filter."
216
+ : "This page no longer exists. Return to the first page."}
217
+ </p>
218
+ )}
219
+ <Button
220
+ disabled={!canPage || query.pagination.pageIndex === 0}
221
+ onClick={() => table.previousPage()}
222
+ >
223
+ Previous page
224
+ </Button>
225
+ <Button
226
+ disabled={
227
+ !canPage ||
228
+ (query.pagination.pageIndex + 1) * query.pagination.pageSize >=
229
+ page.total
230
+ }
231
+ onClick={() => table.nextPage()}
232
+ >
233
+ Next page
234
+ </Button>
235
+ {page.total > 0 && !page.rows.length && (
236
+ <Button
237
+ onClick={() =>
238
+ queryAtom.set((previous) => ({
239
+ ...previous,
240
+ pagination: { ...previous.pagination, pageIndex: 0 },
241
+ }))
242
+ }
243
+ >
244
+ First page
245
+ </Button>
246
+ )}
247
+ </>
248
+ )}
249
+ <p>
250
+ This recipe has no selection, bulk actions, virtualization or adjustable
251
+ density. Add those application contracts before enabling their
252
+ table-engine features.
253
+ </p>
254
+ </section>
255
+ );
256
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ /** The application maps its service failures; never display raw server exceptions. */
5
+ export class RecipeServiceError extends Error {
6
+ constructor(public readonly kind: "permission" | "service") {
7
+ super(kind);
8
+ }
9
+ }
10
+ export type Loader<Query, Data> = (
11
+ query: Query,
12
+ signal: AbortSignal,
13
+ ) => Promise<Data>;
14
+
15
+ /** Application-owned fetching. The old snapshot stays labelled; obsolete work cannot commit. */
16
+ export function useRemoteResource<Query, Data>(
17
+ query: Query,
18
+ load: Loader<Query, Data>,
19
+ ) {
20
+ const [attempt, setAttempt] = useState(0);
21
+ const sequence = useRef(0);
22
+ const [state, setState] = useState<{
23
+ requested: Query | null;
24
+ loader: Loader<Query, Data> | null;
25
+ attempt: number;
26
+ applied: Query | null;
27
+ data: Data | null;
28
+ error: "permission" | "service" | null;
29
+ }>({
30
+ requested: null,
31
+ loader: null,
32
+ attempt: -1,
33
+ applied: null,
34
+ data: null,
35
+ error: null,
36
+ });
37
+ useEffect(() => {
38
+ const controller = new AbortController();
39
+ const revision = ++sequence.current;
40
+ void (async () => {
41
+ try {
42
+ const data = await load(query, controller.signal);
43
+ if (controller.signal.aborted || sequence.current !== revision) return;
44
+ setState({
45
+ requested: query,
46
+ loader: load,
47
+ attempt,
48
+ applied: query,
49
+ data,
50
+ error: null,
51
+ });
52
+ } catch (error) {
53
+ if (controller.signal.aborted || sequence.current !== revision) return;
54
+ const kind =
55
+ error instanceof RecipeServiceError ? error.kind : "service";
56
+ setState((previous) => ({
57
+ ...previous,
58
+ requested: query,
59
+ loader: load,
60
+ attempt,
61
+ error: kind,
62
+ // A denied snapshot must not reappear while a later query is pending.
63
+ data: kind === "permission" ? null : previous.data,
64
+ applied: kind === "permission" ? null : previous.applied,
65
+ }));
66
+ }
67
+ })();
68
+ return () => controller.abort();
69
+ }, [query, load, attempt]);
70
+ const current =
71
+ state.requested === query &&
72
+ state.loader === load &&
73
+ state.attempt === attempt;
74
+ return {
75
+ data: state.data,
76
+ appliedQuery: state.applied,
77
+ loading: !current,
78
+ error: current ? state.error : null,
79
+ retry: () => setAttempt((n) => n + 1),
80
+ };
81
+ }
@@ -0,0 +1,146 @@
1
+ import { useRef, useState } from "react";
2
+ import {
3
+ Button,
4
+ TextField,
5
+ ThemeScope,
6
+ ToastProvider,
7
+ useClipboardCopy,
8
+ useDismiss,
9
+ useIdleTrigger,
10
+ useMotionPreference,
11
+ useTheme,
12
+ useToast,
13
+ type Theme,
14
+ } from "@abuzareal/contour-ui";
15
+ export function ClipboardExample() {
16
+ const { copy, status } = useClipboardCopy(
17
+ "Public synthetic project reference",
18
+ );
19
+ return (
20
+ <section aria-label="Clipboard example">
21
+ <Button onClick={() => void copy()}>Copy reference</Button>
22
+ <p role="status">
23
+ {status === "error"
24
+ ? "Copy failed. You can try again."
25
+ : status === "copied"
26
+ ? "Copied."
27
+ : status === "copying"
28
+ ? "Copying…"
29
+ : "Ready to copy."}
30
+ </p>
31
+ </section>
32
+ );
33
+ }
34
+ export function MotionExample() {
35
+ const reduced = useMotionPreference();
36
+ const [paused, setPaused] = useState(false);
37
+ return (
38
+ <section aria-label="Motion preference">
39
+ <Button
40
+ aria-pressed={paused}
41
+ onClick={() => setPaused((value) => !value)}
42
+ >
43
+ Pause optional motion
44
+ </Button>
45
+ <p>Optional animation is {!reduced && !paused ? "allowed" : "paused"}.</p>
46
+ <p>
47
+ This example owns no animation loop; a scene must consume this combined
48
+ policy.
49
+ </p>
50
+ </section>
51
+ );
52
+ }
53
+ export function DismissExample() {
54
+ const [open, setOpen] = useState(false);
55
+ const panel = useRef<HTMLDivElement>(null),
56
+ trigger = useRef<HTMLButtonElement>(null);
57
+ useDismiss(
58
+ panel,
59
+ open,
60
+ (reason) => {
61
+ setOpen(false);
62
+ if (reason === "escape") trigger.current?.focus();
63
+ },
64
+ { branches: [trigger] },
65
+ );
66
+ return (
67
+ <section aria-label="Custom nonmodal dismissal">
68
+ <Button
69
+ ref={trigger}
70
+ aria-expanded={open}
71
+ aria-controls="example-help"
72
+ onClick={() => setOpen((value) => !value)}
73
+ >
74
+ Toggle custom help
75
+ </Button>
76
+ {open && (
77
+ <div
78
+ id="example-help"
79
+ ref={panel}
80
+ role="region"
81
+ aria-label="Custom help"
82
+ >
83
+ <p>This nonmodal example does not trap or move focus.</p>
84
+ <TextField label="Local note" />
85
+ </div>
86
+ )}
87
+ </section>
88
+ );
89
+ }
90
+ export function IdleExample() {
91
+ const [enabled, setEnabled] = useState(false);
92
+ const ready = useIdleTrigger(enabled);
93
+ return (
94
+ <section aria-label="Idle signal">
95
+ <Button onClick={() => setEnabled(true)}>Request idle signal</Button>
96
+ <p role="status">
97
+ {ready
98
+ ? "Idle signal received; no work was loaded."
99
+ : enabled
100
+ ? "Waiting for idle signal…"
101
+ : "Not requested."}
102
+ </p>
103
+ </section>
104
+ );
105
+ }
106
+ function ThemeAction() {
107
+ const { theme, toggle } = useTheme(false);
108
+ return (
109
+ <Button
110
+ aria-pressed={theme === "dark"}
111
+ onClick={(event) => toggle(event.currentTarget)}
112
+ >
113
+ Dark embedded theme
114
+ </Button>
115
+ );
116
+ }
117
+ export function ScopedThemeExample() {
118
+ const [theme, setTheme] = useState<Theme>("light");
119
+ return (
120
+ <ThemeScope theme={theme} onThemeChange={setTheme}>
121
+ <ThemeAction />
122
+ </ThemeScope>
123
+ );
124
+ }
125
+ function ToastAction() {
126
+ const toast = useToast();
127
+ return (
128
+ <Button
129
+ onClick={() =>
130
+ toast.show({
131
+ title: "Notification example",
132
+ message: "This local example sends and saves nothing.",
133
+ })
134
+ }
135
+ >
136
+ Show example notification
137
+ </Button>
138
+ );
139
+ }
140
+ export function ToastExample() {
141
+ return (
142
+ <ToastProvider>
143
+ <ToastAction />
144
+ </ToastProvider>
145
+ );
146
+ }
@@ -0,0 +1,9 @@
1
+ # Minimal component / overlay / form reproduction
2
+
3
+ Copy this folder alone into an empty directory. Run npm install --strict-peer-deps, npm run build and npm run preview. Versions are pinned in package.json; commit the generated lockfile. There are no workspace aliases or private catalogue dependencies. Use npm ci once your application lockfile exists.
4
+
5
+ Keep a failing interaction in src/main.tsx and remove unrelated UI. Include installed Contour/React/browser versions, expected/actual behavior, keyboard steps, theme, viewport and the smallest input data. Replace names and service URLs with synthetic data; never include credentials, customer records or private repository links. A public repository or sandbox can use this standalone folder.
6
+
7
+ [Report a Contour 0.6.1 Combobox issue](https://github.com/abuzareal/contour-ui/issues/new?template=bug_report.yml&title=Combobox%200.6.1%3A%20&component=Combobox&version=0.6.1)
8
+
9
+ Candidate versions need the package to be published before registry installation; the maintainer tests the same source against the packed candidate first.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <meta name="theme-color" content="#f2f3ee" />
7
+ <!-- theme-bootstrap -->
8
+ <title>Contour starter</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,27 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "engines": {
6
+ "node": ">=22.12.0"
7
+ },
8
+ "name": "contour-bug-reproduction",
9
+ "scripts": {
10
+ "dev": "vite",
11
+ "typecheck": "tsc --noEmit",
12
+ "build": "tsc --noEmit && vite build",
13
+ "preview": "vite preview"
14
+ },
15
+ "dependencies": {
16
+ "@abuzareal/contour-ui": "0.6.1",
17
+ "react": "18.3.1",
18
+ "react-dom": "18.3.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/react": "18.3.31",
22
+ "@types/react-dom": "18.3.7",
23
+ "@types/node": "26.6.5",
24
+ "typescript": "5.9.3",
25
+ "vite": "8.3.2"
26
+ }
27
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Button, Combobox, Dialog, Popover } from "@abuzareal/contour-ui";
4
+ import "@abuzareal/contour-ui/scoped.css";
5
+ import "./style.css";
6
+ const options = [
7
+ { value: "one", label: "One" },
8
+ { value: "two", label: "Two", disabled: true },
9
+ ];
10
+ function Reproduction() {
11
+ const [open, setOpen] = useState(false);
12
+ return (
13
+ <main className="contour-root starter">
14
+ <h1>Contour reproduction</h1>
15
+ <p>
16
+ Contour 0.6.0, React/React DOM 18.3.1. Replace only the smallest
17
+ relevant case. No real user data.
18
+ </p>
19
+ <Button onClick={() => setOpen(true)}>Open dialog</Button>
20
+ <Dialog open={open} onClose={() => setOpen(false)} title="Reproduction">
21
+ <Popover trigger="Open popover" title="Nested layer">
22
+ <form onSubmit={(event) => event.preventDefault()}>
23
+ <Combobox label="Choice" name="choice" options={options} required />
24
+ <Button type="reset">Reset</Button>
25
+ </form>
26
+ </Popover>
27
+ </Dialog>
28
+ </main>
29
+ );
30
+ }
31
+ const element = document.getElementById("root");
32
+ if (!element) throw new Error("Missing root");
33
+ createRoot(element).render(<Reproduction />);
@@ -0,0 +1,19 @@
1
+ body {
2
+ margin: 0;
3
+ background: var(--contour-bg);
4
+ color: var(--contour-fg);
5
+ }
6
+ .starter {
7
+ max-width: 42rem;
8
+ margin: 0 auto;
9
+ padding: 2rem 1rem;
10
+ display: grid;
11
+ gap: 1.25rem;
12
+ }
13
+ .starter form {
14
+ display: grid;
15
+ gap: 1rem;
16
+ }
17
+ .starter output {
18
+ overflow-wrap: anywhere;
19
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
5
+ "strict": true,
6
+ "skipLibCheck": false,
7
+ "noEmit": true,
8
+ "jsx": "react-jsx",
9
+ "module": "ESNext",
10
+ "moduleResolution": "Bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "esModuleInterop": true,
14
+ "isolatedModules": true
15
+ },
16
+ "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
17
+ "exclude": ["node_modules"]
18
+ }
@@ -0,0 +1,19 @@
1
+ import { createHash } from "node:crypto";
2
+ import { defineConfig } from "vite";
3
+ import { themeBootstrapScript } from "@abuzareal/contour-ui/theme-bootstrap";
4
+ const hash = createHash("sha256").update(themeBootstrapScript).digest("base64");
5
+ export default defineConfig({
6
+ plugins: [
7
+ {
8
+ name: "contour-theme-bootstrap",
9
+ transformIndexHtml(html) {
10
+ // Hash the exact bytes inserted here; no inline handler or unsafe-eval allowance.
11
+ const policy = `default-src 'self'; script-src 'self' 'sha256-${hash}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'`;
12
+ return html.replace(
13
+ "<!-- theme-bootstrap -->",
14
+ `<meta http-equiv="Content-Security-Policy" content="${policy}"><script>${themeBootstrapScript}</script>`,
15
+ );
16
+ },
17
+ },
18
+ ],
19
+ });
@@ -0,0 +1,18 @@
1
+ # Contour + Next App Router
2
+
3
+ Copy this directory. Use Node 22.12 or newer, then:
4
+
5
+ ```sh
6
+ npm install --strict-peer-deps
7
+ npm run dev
8
+ npm run build
9
+ npm start
10
+ ```
11
+
12
+ Direct versions: Contour 0.6.1, Next 16.4.0, React/React DOM 19.3.0, TypeScript 7.0.2; webpack explicitly selected. Commit the generated application lockfile and use npm ci afterward. There are no workspace aliases, optional Three/Lenis peers, fonts or required providers. To opt into branded fonts, import `@abuzareal/contour-ui/fonts-latin.css` in app/layout.tsx. Root CSS and route composition belong to the application.
13
+
14
+ layout.tsx and the page are server components. Demo.tsx owns client state, refs and handlers. The pure theme-bootstrap subpath is server-safe; before-paint script changes only the root theme attribute and theme-color. The root's suppressHydrationWarning is narrowly for this attribute, not a workaround for arbitrary mismatches.
15
+
16
+ proxy.ts generates a new nonce on every document request, forwards the CSP and nonce to Next and returns the CSP header. The dynamic layout reads that nonce so both bootstrap and framework scripts are authorized. This deliberately requires server rendering; static export/cached shared nonces are not supported. Inline styles remain allowed for library geometry. Production script policy has no unsafe-eval; development adds it for the bundler. Configure additional service origins explicitly. See [Next's CSP guide](https://nextjs.org/docs/app/guides/content-security-policy).
17
+
18
+ This folder establishes the named App Router/webpack pair only. Turbopack, Pages Router and Next Lenis are unverified. Optional lazy Three is covered separately by the library's optional fixture, not installed here. A release candidate uses an archive only in disposable validation copies; the exact registry installation requires the published release.