@tristan2828/ui-foundation 3.0.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 (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/ui-foundation.mjs +249 -0
  4. package/conventions/AGENTS.md +149 -0
  5. package/conventions/agents/deny-impl-read.mjs +36 -0
  6. package/conventions/agents/skills/new-entity/SKILL.md +22 -0
  7. package/conventions/agents/spec-tester.md +35 -0
  8. package/conventions/docs/add-an-entity.md +204 -0
  9. package/conventions/docs/cell-patterns.md +204 -0
  10. package/conventions/docs/column-options.md +122 -0
  11. package/conventions/docs/design-language.md +116 -0
  12. package/conventions/docs/entity-plan-template.md +67 -0
  13. package/dist/api/contracts.d.ts +20 -0
  14. package/dist/api/contracts.js +4 -0
  15. package/dist/api/gateway/auth.d.ts +9 -0
  16. package/dist/api/gateway/auth.js +37 -0
  17. package/dist/api/gateway/errors.d.ts +5 -0
  18. package/dist/api/gateway/errors.js +36 -0
  19. package/dist/api/query-client.d.ts +2 -0
  20. package/dist/api/query-client.js +19 -0
  21. package/dist/api/schema.d.ts +246 -0
  22. package/dist/api/schema.js +5 -0
  23. package/dist/api/transport/index.d.ts +5 -0
  24. package/dist/api/transport/index.js +14 -0
  25. package/dist/auth/auth-context.d.ts +19 -0
  26. package/dist/auth/auth-context.js +12 -0
  27. package/dist/auth/auth-provider.d.ts +4 -0
  28. package/dist/auth/auth-provider.js +101 -0
  29. package/dist/auth/use-auth.d.ts +2 -0
  30. package/dist/auth/use-auth.js +9 -0
  31. package/dist/components/app/app-shell.d.ts +28 -0
  32. package/dist/components/app/app-shell.js +64 -0
  33. package/dist/components/app/data-table.d.ts +56 -0
  34. package/dist/components/app/data-table.js +167 -0
  35. package/dist/components/app/entity-form.d.ts +14 -0
  36. package/dist/components/app/entity-form.js +8 -0
  37. package/dist/components/app/error-state.d.ts +6 -0
  38. package/dist/components/app/error-state.js +20 -0
  39. package/dist/components/app/foundation-providers.d.ts +9 -0
  40. package/dist/components/app/foundation-providers.js +15 -0
  41. package/dist/components/app/mock-mode-banner.d.ts +3 -0
  42. package/dist/components/app/mock-mode-banner.js +16 -0
  43. package/dist/components/app/multi-choice.d.ts +14 -0
  44. package/dist/components/app/multi-choice.js +13 -0
  45. package/dist/components/app/password-input.d.ts +3 -0
  46. package/dist/components/app/password-input.js +30 -0
  47. package/dist/components/app/route-error-boundary.d.ts +1 -0
  48. package/dist/components/app/route-error-boundary.js +13 -0
  49. package/dist/components/theme-provider.d.ts +2 -0
  50. package/dist/components/theme-provider.js +8 -0
  51. package/dist/components/ui/badge.d.ts +7 -0
  52. package/dist/components/ui/badge.js +50 -0
  53. package/dist/components/ui/button.d.ts +8 -0
  54. package/dist/components/ui/button.js +34 -0
  55. package/dist/components/ui/calendar.d.ts +10 -0
  56. package/dist/components/ui/calendar.js +76 -0
  57. package/dist/components/ui/card.d.ts +11 -0
  58. package/dist/components/ui/card.js +25 -0
  59. package/dist/components/ui/combobox.d.ts +24 -0
  60. package/dist/components/ui/combobox.js +62 -0
  61. package/dist/components/ui/dialog.d.ts +17 -0
  62. package/dist/components/ui/dialog.js +37 -0
  63. package/dist/components/ui/empty.d.ts +11 -0
  64. package/dist/components/ui/empty.js +33 -0
  65. package/dist/components/ui/field.d.ts +24 -0
  66. package/dist/components/ui/field.js +67 -0
  67. package/dist/components/ui/input-group.d.ts +18 -0
  68. package/dist/components/ui/input-group.js +57 -0
  69. package/dist/components/ui/input.d.ts +3 -0
  70. package/dist/components/ui/input.js +8 -0
  71. package/dist/components/ui/label.d.ts +3 -0
  72. package/dist/components/ui/label.js +7 -0
  73. package/dist/components/ui/popover.d.ts +9 -0
  74. package/dist/components/ui/popover.js +23 -0
  75. package/dist/components/ui/select.d.ts +15 -0
  76. package/dist/components/ui/select.js +35 -0
  77. package/dist/components/ui/separator.d.ts +3 -0
  78. package/dist/components/ui/separator.js +8 -0
  79. package/dist/components/ui/sheet.d.ts +14 -0
  80. package/dist/components/ui/sheet.js +38 -0
  81. package/dist/components/ui/sidebar.d.ts +63 -0
  82. package/dist/components/ui/sidebar.js +255 -0
  83. package/dist/components/ui/skeleton.d.ts +2 -0
  84. package/dist/components/ui/skeleton.js +6 -0
  85. package/dist/components/ui/sonner.d.ts +3 -0
  86. package/dist/components/ui/sonner.js +24 -0
  87. package/dist/components/ui/spinner.d.ts +2 -0
  88. package/dist/components/ui/spinner.js +7 -0
  89. package/dist/components/ui/table.d.ts +13 -0
  90. package/dist/components/ui/table.js +45 -0
  91. package/dist/components/ui/textarea.d.ts +3 -0
  92. package/dist/components/ui/textarea.js +7 -0
  93. package/dist/components/ui/tooltip.d.ts +6 -0
  94. package/dist/components/ui/tooltip.js +16 -0
  95. package/dist/gateway.d.ts +2 -0
  96. package/dist/gateway.js +5 -0
  97. package/dist/hooks/table-url-changes.d.ts +14 -0
  98. package/dist/hooks/table-url-changes.js +32 -0
  99. package/dist/hooks/use-debounced-value.d.ts +1 -0
  100. package/dist/hooks/use-debounced-value.js +12 -0
  101. package/dist/hooks/use-mobile.d.ts +1 -0
  102. package/dist/hooks/use-mobile.js +14 -0
  103. package/dist/hooks/use-table-url-state.d.ts +14 -0
  104. package/dist/hooks/use-table-url-state.js +43 -0
  105. package/dist/index.d.ts +22 -0
  106. package/dist/index.js +28 -0
  107. package/dist/lib/utils.d.ts +1 -0
  108. package/dist/lib/utils.js +1 -0
  109. package/dist/mocks/auth.d.ts +9 -0
  110. package/dist/mocks/auth.js +79 -0
  111. package/dist/mocks/e2e-hooks.d.ts +3 -0
  112. package/dist/mocks/e2e-hooks.js +28 -0
  113. package/dist/mocks/index.d.ts +3 -0
  114. package/dist/mocks/index.js +5 -0
  115. package/dist/mocks/override.d.ts +23 -0
  116. package/dist/mocks/override.js +1 -0
  117. package/dist/routes/login-schema.d.ts +7 -0
  118. package/dist/routes/login-schema.js +13 -0
  119. package/dist/routes/login.d.ts +8 -0
  120. package/dist/routes/login.js +58 -0
  121. package/dist/routes/register-schema.d.ts +9 -0
  122. package/dist/routes/register-schema.js +23 -0
  123. package/dist/routes/register.d.ts +1 -0
  124. package/dist/routes/register.js +58 -0
  125. package/dist/routes/return-path.d.ts +1 -0
  126. package/dist/routes/return-path.js +9 -0
  127. package/dist/testing/index.d.ts +40 -0
  128. package/dist/testing/index.js +103 -0
  129. package/eslint/index.d.ts +11 -0
  130. package/eslint/index.js +179 -0
  131. package/openapi/foundation.yaml +156 -0
  132. package/package.json +158 -0
  133. package/styles/index.css +88 -0
  134. package/styles/theme.css +209 -0
@@ -0,0 +1,67 @@
1
+ # <Entity> — entity plan
2
+
3
+ The input to the entity playbook (`docs/foundation/add-an-entity.md`, which any AI
4
+ tool can follow; Claude Code's `/new-entity` is a shortcut). It builds
5
+ exactly what this file says — no invented fields, options or rules. Copy
6
+ this file to `docs/entities/<entity>.md` (kebab-case, singular: `game.md`)
7
+ and fill it in, or ask your AI tool to plan the entity with you and it
8
+ will talk it through and write this file. `docs/entities/widget.md` (the
9
+ template's demo entity, until you delete it) is a filled-in example.
10
+
11
+ ## Purpose
12
+
13
+ One or two sentences: what one record is, and who uses the screens.
14
+
15
+ ## Fields
16
+
17
+ | Field | Label | Type | Required | Options / rules | List | Filter |
18
+ |---|---|---|---|---|---|---|
19
+ | title | Title | text | yes | 1–200 chars | column, sortable | search |
20
+ | status | Status | single choice | yes | Backlog, Playing, Finished; default Backlog | column, sortable | yes |
21
+ | priority | Priority | single choice | no | Low, Normal, High; empty means not set | column, sortable | yes |
22
+
23
+ - **Field**: camelCase, as it appears in the API (`releaseDate`).
24
+ - **Type** — what the foundation supports today:
25
+ - *Demonstrated by the Widget reference* (copied directly): `text` (one
26
+ line), `long text`, `decimal` (fixed places — say how many),
27
+ `date-time`, `email`, `single choice` (list the options, in display
28
+ order; see the note below if it is *optional*), `multi choice` (list
29
+ the options; any number can be picked —
30
+ chips on the form, badges in the table, an any-of filter), `reference`
31
+ (to another entity — name it; a searchable combobox).
32
+ - *Close variants* (built by a small, stated change to the nearest
33
+ pattern): `integer` (from decimal), `url` (from email — a format
34
+ check), `date` (from date-time — no time part).
35
+ - *Not supported yet:* boolean, file, and anything else.
36
+ Write it anyway with a note; the playbook will stop and raise it
37
+ rather than improvise.
38
+ - **Required**: `yes`, or `no` (then say whether empty means "unknown").
39
+ An **optional `single choice`** needs one extra decision: the label for
40
+ "not set" (`Not checked`, `Any`, `None`…), because the form shows it as
41
+ a real option rather than a blank. Say it in the Options column. Widget
42
+ has no optional choice field, so the playbook builds this one from the
43
+ worked example in `docs/foundation/add-an-entity.md` step 6 rather than by copying
44
+ a screen — a Base UI `Select` can't take `""` as an item value, so the
45
+ pattern needs a sentinel and gets the display wrong without one.
46
+ - **List**: `column` if it shows in the table, and `sortable` if you can
47
+ sort by it; blank if it's form-only.
48
+ - **Filter**: `search` (the text search box), `yes` (a filter control), or
49
+ blank.
50
+
51
+ ## List screen
52
+
53
+ - Default sort:
54
+ - Page size: 10 unless there's a reason.
55
+ - Anything else the table must show or do:
56
+
57
+ ## Screens and access
58
+
59
+ - Screens: list, create, edit, delete (the only shape the playbook builds
60
+ today).
61
+ - Ownership: **shared** (every signed-in user sees every record) or
62
+ **per-user** (each user sees only their own).
63
+
64
+ ## Open questions
65
+
66
+ Anything not decided yet. The playbook won't start while this section has
67
+ an unresolved item.
@@ -0,0 +1,20 @@
1
+ export type Page<T> = {
2
+ items: T[];
3
+ total: number;
4
+ page: number;
5
+ pageSize: number;
6
+ };
7
+ export type AppError = {
8
+ kind: "validation" | "notfound" | "auth" | "server" | "network";
9
+ message: string;
10
+ fieldErrors?: Record<string, string[]>;
11
+ };
12
+ export type QuerySpec = {
13
+ page: number;
14
+ pageSize: number;
15
+ sort?: {
16
+ field: string;
17
+ dir: "asc" | "desc";
18
+ };
19
+ filters?: Record<string, unknown>;
20
+ };
@@ -0,0 +1,4 @@
1
+ // UI-owned types. The gateway translates every backend response into these;
2
+ // nothing above the gateway may see a wire-shaped response (the
3
+ // anti-corruption layer — docs/ARCHITECTURE.md "The layers").
4
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { components } from "../schema.js";
2
+ type User = components["schemas"]["User"];
3
+ type LoginRequest = components["schemas"]["LoginRequest"];
4
+ type RegisterRequest = components["schemas"]["RegisterRequest"];
5
+ export declare function login(input: LoginRequest): Promise<User>;
6
+ export declare function register(input: RegisterRequest): Promise<User>;
7
+ export declare function logout(): Promise<void>;
8
+ export declare function getCurrentUser(): Promise<User>;
9
+ export {};
@@ -0,0 +1,37 @@
1
+ import { safeFetch, toAppError } from "./errors.js";
2
+ export async function login(input) {
3
+ const res = await safeFetch("/auth/login", {
4
+ method: "POST",
5
+ headers: { "content-type": "application/json" },
6
+ body: JSON.stringify(input),
7
+ });
8
+ if (res.status !== 200)
9
+ throw toAppError(res.status, res.body);
10
+ return res.body;
11
+ }
12
+ // Auto-logs in on success — the backend sets the same session cookie
13
+ // /auth/login does (see backend/app/routers/auth.py's _start_session), so
14
+ // the gateway shape is identical to login's: a User on 200, AppError
15
+ // otherwise (a duplicate email arrives as kind:'validation' with
16
+ // fieldErrors.email, same as any other field error).
17
+ export async function register(input) {
18
+ const res = await safeFetch("/auth/register", {
19
+ method: "POST",
20
+ headers: { "content-type": "application/json" },
21
+ body: JSON.stringify(input),
22
+ });
23
+ if (res.status !== 200)
24
+ throw toAppError(res.status, res.body);
25
+ return res.body;
26
+ }
27
+ export async function logout() {
28
+ const res = await safeFetch("/auth/logout", { method: "POST" });
29
+ if (res.status !== 204)
30
+ throw toAppError(res.status, res.body);
31
+ }
32
+ export async function getCurrentUser() {
33
+ const res = await safeFetch("/auth/me");
34
+ if (res.status !== 200)
35
+ throw toAppError(res.status, res.body);
36
+ return res.body;
37
+ }
@@ -0,0 +1,5 @@
1
+ import { type ApiResponse } from "../transport/index.js";
2
+ import type { AppError } from "../contracts.js";
3
+ export declare function safeFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
4
+ export declare function toAppError(status: number, body: unknown): AppError;
5
+ export declare function networkError(): AppError;
@@ -0,0 +1,36 @@
1
+ // Shared wire-error -> AppError translation for the gateway, plus a thin
2
+ // wrapper that turns a rejected apiFetch (network-level failure) into
3
+ // AppError{kind:'network'} instead of letting a raw fetch error escape. This
4
+ // is the anti-corruption layer's error seam (docs/ARCHITECTURE.md).
5
+ import { apiFetch } from "../transport/index.js";
6
+ export async function safeFetch(path, init) {
7
+ try {
8
+ return await apiFetch(path, init);
9
+ }
10
+ catch {
11
+ throw networkError();
12
+ }
13
+ }
14
+ export function toAppError(status, body) {
15
+ if (status === 422) {
16
+ const detail = body?.detail ?? [];
17
+ const fieldErrors = {};
18
+ for (const issue of detail) {
19
+ // The field is the last *string* segment: integer segments index into
20
+ // an array field (["body", "tags", 0] is a bad tag), and a form binds
21
+ // errors by field name, never by position.
22
+ const field = String([...issue.loc].reverse().find((segment) => typeof segment === "string") ?? issue.loc[issue.loc.length - 1]);
23
+ (fieldErrors[field] ??= []).push(issue.msg);
24
+ }
25
+ return { kind: "validation", message: "Validation failed", fieldErrors };
26
+ }
27
+ const message = body?.detail ?? `Request failed with status ${status}`;
28
+ if (status === 404)
29
+ return { kind: "notfound", message };
30
+ if (status === 401)
31
+ return { kind: "auth", message };
32
+ return { kind: "server", message };
33
+ }
34
+ export function networkError() {
35
+ return { kind: "network", message: "Network error — check your connection and try again." };
36
+ }
@@ -0,0 +1,2 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ export declare function createQueryClient(): QueryClient;
@@ -0,0 +1,19 @@
1
+ // The default QueryClient retries every failed query 3x with exponential
2
+ // backoff (~7s total) regardless of why it failed. That's right for a
3
+ // network blip but wrong for a 404, 401, or 422 — the gateway already
4
+ // classifies those as AppError.kind values that will never change on
5
+ // retry, so retrying just makes the user wait ~7s to see an error the
6
+ // first response already told us was final.
7
+ import { QueryClient } from "@tanstack/react-query";
8
+ function isTransient(error) {
9
+ return error.kind === "network";
10
+ }
11
+ export function createQueryClient() {
12
+ return new QueryClient({
13
+ defaultOptions: {
14
+ queries: {
15
+ retry: (failureCount, error) => isTransient(error) && failureCount < 2,
16
+ },
17
+ },
18
+ });
19
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * This file was auto-generated by openapi-typescript.
3
+ * Do not make direct changes to the file.
4
+ */
5
+ export interface paths {
6
+ "/auth/login": {
7
+ parameters: {
8
+ query?: never;
9
+ header?: never;
10
+ path?: never;
11
+ cookie?: never;
12
+ };
13
+ get?: never;
14
+ put?: never;
15
+ /** Log in with email and password, receiving a session cookie */
16
+ post: operations["login"];
17
+ delete?: never;
18
+ options?: never;
19
+ head?: never;
20
+ patch?: never;
21
+ trace?: never;
22
+ };
23
+ "/auth/register": {
24
+ parameters: {
25
+ query?: never;
26
+ header?: never;
27
+ path?: never;
28
+ cookie?: never;
29
+ };
30
+ get?: never;
31
+ put?: never;
32
+ /** Create an account and immediately log in, receiving a session cookie */
33
+ post: operations["register"];
34
+ delete?: never;
35
+ options?: never;
36
+ head?: never;
37
+ patch?: never;
38
+ trace?: never;
39
+ };
40
+ "/auth/logout": {
41
+ parameters: {
42
+ query?: never;
43
+ header?: never;
44
+ path?: never;
45
+ cookie?: never;
46
+ };
47
+ get?: never;
48
+ put?: never;
49
+ /** Clear the current session */
50
+ post: operations["logout"];
51
+ delete?: never;
52
+ options?: never;
53
+ head?: never;
54
+ patch?: never;
55
+ trace?: never;
56
+ };
57
+ "/auth/me": {
58
+ parameters: {
59
+ query?: never;
60
+ header?: never;
61
+ path?: never;
62
+ cookie?: never;
63
+ };
64
+ /** The currently authenticated user */
65
+ get: operations["getCurrentUser"];
66
+ put?: never;
67
+ post?: never;
68
+ delete?: never;
69
+ options?: never;
70
+ head?: never;
71
+ patch?: never;
72
+ trace?: never;
73
+ };
74
+ }
75
+ export type webhooks = Record<string, never>;
76
+ export interface components {
77
+ schemas: {
78
+ User: {
79
+ readonly id: number;
80
+ /** Format: email */
81
+ email: string;
82
+ name: string;
83
+ };
84
+ LoginRequest: {
85
+ /** Format: email */
86
+ email: string;
87
+ password: string;
88
+ };
89
+ RegisterRequest: {
90
+ email: components["schemas"]["User"]["email"];
91
+ name: components["schemas"]["User"]["name"];
92
+ password: components["schemas"]["LoginRequest"]["password"];
93
+ };
94
+ /** @description FastAPI's default 422 shape (RequestValidationError). */
95
+ ValidationErrorBody: {
96
+ detail: {
97
+ loc: (string | number)[];
98
+ msg: string;
99
+ type: string;
100
+ }[];
101
+ };
102
+ /** @description FastAPI's default HTTPException shape. */
103
+ HTTPErrorBody: {
104
+ detail: string;
105
+ };
106
+ };
107
+ responses: {
108
+ /** @description Validation failed on one or more fields. */
109
+ ValidationError: {
110
+ headers: {
111
+ [name: string]: unknown;
112
+ };
113
+ content: {
114
+ "application/json": components["schemas"]["ValidationErrorBody"];
115
+ };
116
+ };
117
+ /** @description Resource not found. */
118
+ NotFoundError: {
119
+ headers: {
120
+ [name: string]: unknown;
121
+ };
122
+ content: {
123
+ "application/json": components["schemas"]["HTTPErrorBody"];
124
+ };
125
+ };
126
+ /** @description Missing or invalid credentials. */
127
+ UnauthorizedError: {
128
+ headers: {
129
+ [name: string]: unknown;
130
+ };
131
+ content: {
132
+ "application/json": components["schemas"]["HTTPErrorBody"];
133
+ };
134
+ };
135
+ /** @description Unhandled server error. */
136
+ ServerError: {
137
+ headers: {
138
+ [name: string]: unknown;
139
+ };
140
+ content: {
141
+ "application/json": components["schemas"]["HTTPErrorBody"];
142
+ };
143
+ };
144
+ };
145
+ parameters: never;
146
+ requestBodies: never;
147
+ headers: never;
148
+ pathItems: never;
149
+ }
150
+ export type $defs = Record<string, never>;
151
+ export interface operations {
152
+ login: {
153
+ parameters: {
154
+ query?: never;
155
+ header?: never;
156
+ path?: never;
157
+ cookie?: never;
158
+ };
159
+ requestBody: {
160
+ content: {
161
+ "application/json": components["schemas"]["LoginRequest"];
162
+ };
163
+ };
164
+ responses: {
165
+ /** @description Authenticated. Sets the session_id cookie. */
166
+ 200: {
167
+ headers: {
168
+ [name: string]: unknown;
169
+ };
170
+ content: {
171
+ "application/json": components["schemas"]["User"];
172
+ };
173
+ };
174
+ 401: components["responses"]["UnauthorizedError"];
175
+ 422: components["responses"]["ValidationError"];
176
+ 500: components["responses"]["ServerError"];
177
+ };
178
+ };
179
+ register: {
180
+ parameters: {
181
+ query?: never;
182
+ header?: never;
183
+ path?: never;
184
+ cookie?: never;
185
+ };
186
+ requestBody: {
187
+ content: {
188
+ "application/json": components["schemas"]["RegisterRequest"];
189
+ };
190
+ };
191
+ responses: {
192
+ /** @description Registered and authenticated. Sets the session_id cookie. */
193
+ 200: {
194
+ headers: {
195
+ [name: string]: unknown;
196
+ };
197
+ content: {
198
+ "application/json": components["schemas"]["User"];
199
+ };
200
+ };
201
+ 422: components["responses"]["ValidationError"];
202
+ 500: components["responses"]["ServerError"];
203
+ };
204
+ };
205
+ logout: {
206
+ parameters: {
207
+ query?: never;
208
+ header?: never;
209
+ path?: never;
210
+ cookie?: never;
211
+ };
212
+ requestBody?: never;
213
+ responses: {
214
+ /** @description Logged out (always, whether or not a session existed). */
215
+ 204: {
216
+ headers: {
217
+ [name: string]: unknown;
218
+ };
219
+ content?: never;
220
+ };
221
+ 500: components["responses"]["ServerError"];
222
+ };
223
+ };
224
+ getCurrentUser: {
225
+ parameters: {
226
+ query?: never;
227
+ header?: never;
228
+ path?: never;
229
+ cookie?: never;
230
+ };
231
+ requestBody?: never;
232
+ responses: {
233
+ /** @description OK. */
234
+ 200: {
235
+ headers: {
236
+ [name: string]: unknown;
237
+ };
238
+ content: {
239
+ "application/json": components["schemas"]["User"];
240
+ };
241
+ };
242
+ 401: components["responses"]["UnauthorizedError"];
243
+ 500: components["responses"]["ServerError"];
244
+ };
245
+ };
246
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * This file was auto-generated by openapi-typescript.
3
+ * Do not make direct changes to the file.
4
+ */
5
+ export {};
@@ -0,0 +1,5 @@
1
+ export type ApiResponse = {
2
+ status: number;
3
+ body: unknown;
4
+ };
5
+ export declare function apiFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
@@ -0,0 +1,14 @@
1
+ // The only file allowed to call fetch directly — see AGENTS.md ("NEVER
2
+ // import from api/transport/ outside api/gateway/") and the matching
3
+ // no-restricted-imports / no-restricted-syntax rules in eslint.config.js.
4
+ // Owns the base URL and the raw HTTP call; knows nothing about widgets,
5
+ // pagination, or error shapes — that translation is the gateway's job.
6
+ const BASE_URL = "/api";
7
+ export async function apiFetch(path, init) {
8
+ const response = await fetch(`${BASE_URL}${path}`, init);
9
+ if (response.status === 204) {
10
+ return { status: response.status, body: undefined };
11
+ }
12
+ const body = await response.json().catch(() => undefined);
13
+ return { status: response.status, body };
14
+ }
@@ -0,0 +1,19 @@
1
+ import type { AppError } from '../api/contracts.js';
2
+ export type AuthUser = {
3
+ id: number;
4
+ name: string;
5
+ email: string;
6
+ };
7
+ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'unavailable';
8
+ export type AuthContextValue = {
9
+ user: AuthUser | null;
10
+ status: AuthStatus;
11
+ /** Why the session check failed; set only when status is 'unavailable'. */
12
+ error: AppError | null;
13
+ /** Re-runs the session check (the 'unavailable' state's retry). */
14
+ retry: () => void;
15
+ login: (email: string, password: string) => Promise<void>;
16
+ logout: () => Promise<void>;
17
+ register: (email: string, name: string, password: string) => Promise<void>;
18
+ };
19
+ export declare const AuthContext: import("react").Context<AuthContextValue>;
@@ -0,0 +1,12 @@
1
+ import { createContext } from 'react';
2
+ // Session-cookie auth against the backend. auth-provider.tsx is the only
3
+ // file that knows how any of this works; see AGENTS.md's hard rule.
4
+ export const AuthContext = createContext({
5
+ user: null,
6
+ status: 'loading',
7
+ error: null,
8
+ retry: () => { },
9
+ login: async () => { },
10
+ logout: async () => { },
11
+ register: async () => { },
12
+ });
@@ -0,0 +1,4 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function AuthProvider({ children }: {
3
+ children: ReactNode;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { getCurrentUser, login as loginRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
5
+ import { AuthContext } from './auth-context.js';
6
+ // TanStack Query, not useEffect — AGENTS.md's "NEVER fetch in useEffect"
7
+ // rule applies here too. A 401 here just means "not logged in", which the
8
+ // global QueryClient retry policy (api/query-client.ts) already treats as
9
+ // final rather than retrying.
10
+ const AUTH_QUERY_KEY = ['auth', 'me'];
11
+ function isAuthError(error) {
12
+ return error?.kind === 'auth';
13
+ }
14
+ // Every cached query except the session itself belongs to whoever was
15
+ // logged in when it ran. Dropped on every session change so the next user
16
+ // never sees the previous user's rows — widgets are per-user on the
17
+ // backend. `removeQueries` with a predicate, not `clear()`: clear() would
18
+ // also drop the auth query this provider's own useQuery observes, and that
19
+ // observer wouldn't pick up the replacement setQueryData creates.
20
+ function removeUserScopedQueries(queryClient) {
21
+ queryClient.removeQueries({ predicate: (query) => query.queryKey[0] !== AUTH_QUERY_KEY[0] });
22
+ }
23
+ function startSession(queryClient, user) {
24
+ removeUserScopedQueries(queryClient);
25
+ queryClient.setQueryData(AUTH_QUERY_KEY, user);
26
+ }
27
+ function endSession(queryClient) {
28
+ removeUserScopedQueries(queryClient);
29
+ queryClient.setQueryData(AUTH_QUERY_KEY, null);
30
+ }
31
+ // This is the only file that knows how auth works — everything else
32
+ // consumes useAuth() from ./use-auth, and the package exports nothing else
33
+ // of it (docs/ARCHITECTURE.md "The layers").
34
+ export function AuthProvider({ children }) {
35
+ const queryClient = useQueryClient();
36
+ const { data: user, error, isLoading, refetch, } = useQuery({
37
+ queryKey: AUTH_QUERY_KEY,
38
+ queryFn: getCurrentUser,
39
+ });
40
+ // A session can expire while the app is open: the next request for any
41
+ // other resource comes back 401. End the session here, once, rather than
42
+ // leaving each screen to show a "Not authorized" error inside a shell
43
+ // that still thinks it's logged in — AppShell then redirects to /login.
44
+ // A subscription, not a fetch; the auth query's own 401 is excluded (it
45
+ // is already the "logged out" signal).
46
+ useEffect(() => {
47
+ const onError = (error) => {
48
+ // Deferred: mutating the cache from inside its own notify callback
49
+ // would re-enter the cache mid-dispatch.
50
+ if (isAuthError(error))
51
+ queueMicrotask(() => endSession(queryClient));
52
+ };
53
+ const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
54
+ if (event.type === 'updated' &&
55
+ event.action.type === 'error' &&
56
+ event.query.queryKey[0] !== AUTH_QUERY_KEY[0]) {
57
+ onError(event.action.error);
58
+ }
59
+ });
60
+ const unsubscribeMutations = queryClient.getMutationCache().subscribe((event) => {
61
+ if (event.type === 'updated' && event.action.type === 'error') {
62
+ onError(event.action.error);
63
+ }
64
+ });
65
+ return () => {
66
+ unsubscribeQueries();
67
+ unsubscribeMutations();
68
+ };
69
+ }, [queryClient]);
70
+ // A 401 always means logged out, even if a user was cached from before
71
+ // (a background refetch of /auth/me after the session expired). Any other
72
+ // failure with no user is 'unavailable', not logged out.
73
+ const status = isLoading
74
+ ? 'loading'
75
+ : isAuthError(error)
76
+ ? 'unauthenticated'
77
+ : user
78
+ ? 'authenticated'
79
+ : error
80
+ ? 'unavailable'
81
+ : 'unauthenticated';
82
+ async function login(email, password) {
83
+ startSession(queryClient, await loginRequest({ email, password }));
84
+ }
85
+ async function logout() {
86
+ await logoutRequest();
87
+ endSession(queryClient);
88
+ }
89
+ async function register(email, name, password) {
90
+ startSession(queryClient, await registerRequest({ email, name, password }));
91
+ }
92
+ return (_jsx(AuthContext.Provider, { value: {
93
+ user: status === 'authenticated' ? (user ?? null) : null,
94
+ status,
95
+ error: status === 'unavailable' ? error : null,
96
+ retry: () => void refetch(),
97
+ login,
98
+ logout,
99
+ register,
100
+ }, children: children }));
101
+ }
@@ -0,0 +1,2 @@
1
+ export { AuthProvider } from './auth-provider.js';
2
+ export declare function useAuth(): import("./auth-context.js").AuthContextValue;
@@ -0,0 +1,9 @@
1
+ import { useContext } from 'react';
2
+ import { AuthContext } from './auth-context.js';
3
+ // Re-exported so the app root can mount the provider without importing
4
+ // auth-provider.tsx directly — this file is the one boundary everything
5
+ // else, including main.tsx, is allowed to import auth from.
6
+ export { AuthProvider } from './auth-provider.js';
7
+ export function useAuth() {
8
+ return useContext(AuthContext);
9
+ }
@@ -0,0 +1,28 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ export type NavItem = {
3
+ /** The route this entry links to. */
4
+ to: string;
5
+ label: string;
6
+ icon: ComponentType;
7
+ /** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
8
+ end?: boolean;
9
+ };
10
+ export type AppShellProps = {
11
+ /** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
12
+ title: string;
13
+ /** The app's primary navigation, in order. */
14
+ nav: readonly NavItem[];
15
+ /**
16
+ * More sidebar groups under the primary nav — reference links, say. Built
17
+ * from the sidebar primitives (`@tristan2828/ui-foundation/ui/sidebar`).
18
+ * Rendered inside the Primary landmark, so keep it navigation.
19
+ */
20
+ sidebarExtra?: ReactNode;
21
+ /**
22
+ * Whether the sidebar starts expanded for someone who has never toggled
23
+ * it. After the first toggle their choice is remembered (the sidebar
24
+ * primitive writes a cookie, read back here). A wide table wants false.
25
+ */
26
+ defaultSidebarOpen?: boolean;
27
+ };
28
+ export declare function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen }: AppShellProps): import("react").JSX.Element;