@estiva-app/ui 0.20.0 → 0.21.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 (44) hide show
  1. package/README.md +38 -2
  2. package/dist/gates/app-checks.d.ts +40 -0
  3. package/dist/gates/app-checks.d.ts.map +1 -0
  4. package/dist/gates/chunk-AUXD4GCY.js +478 -0
  5. package/dist/gates/chunk-AUXD4GCY.js.map +7 -0
  6. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  7. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  8. package/dist/gates/cli.d.ts +2 -0
  9. package/dist/gates/cli.d.ts.map +1 -0
  10. package/dist/gates/cli.js +46 -0
  11. package/dist/gates/cli.js.map +7 -0
  12. package/dist/gates/count.d.ts +20 -0
  13. package/dist/gates/count.d.ts.map +1 -0
  14. package/dist/gates/create-app-cli.d.ts +2 -0
  15. package/dist/gates/create-app-cli.d.ts.map +1 -0
  16. package/dist/gates/create-app.d.ts +23 -0
  17. package/dist/gates/create-app.d.ts.map +1 -0
  18. package/dist/gates/create-app.js +30 -0
  19. package/dist/gates/create-app.js.map +7 -0
  20. package/dist/gates/gate-config.d.ts +44 -0
  21. package/dist/gates/gate-config.d.ts.map +1 -0
  22. package/dist/gates/hook.d.ts +18 -0
  23. package/dist/gates/hook.d.ts.map +1 -0
  24. package/dist/gates/index.d.ts +30 -0
  25. package/dist/gates/index.d.ts.map +1 -0
  26. package/dist/gates/index.js +417 -0
  27. package/dist/gates/index.js.map +7 -0
  28. package/dist/gates/status.d.ts +88 -0
  29. package/dist/gates/status.d.ts.map +1 -0
  30. package/dist/gates/token-lint.d.ts +26 -0
  31. package/dist/gates/token-lint.d.ts.map +1 -0
  32. package/package.json +30 -4
  33. package/src/gates/app-checks.ts +226 -0
  34. package/src/gates/cli.ts +61 -0
  35. package/src/gates/count.ts +76 -0
  36. package/src/gates/create-app-cli.ts +25 -0
  37. package/src/gates/create-app.test.ts +72 -0
  38. package/src/gates/create-app.ts +797 -0
  39. package/src/gates/gate-config.ts +78 -0
  40. package/src/gates/gates.test.ts +194 -0
  41. package/src/gates/hook.ts +111 -0
  42. package/src/gates/index.ts +30 -0
  43. package/src/gates/status.ts +545 -0
  44. package/src/gates/token-lint.ts +231 -0
@@ -0,0 +1,754 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/gates/create-app.ts
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { APP_RULE_IDS } from "../eslint/index.js";
9
+ var here = dirname(fileURLToPath(import.meta.url));
10
+ var packageRoot = resolve(here, "..", "..");
11
+ function themes() {
12
+ const css = readFileSync(join(packageRoot, "tokens.css"), "utf8");
13
+ return ["light", ...new Set([...css.matchAll(/data-theme='([\w-]+)'/g)].map((m) => m[1]))];
14
+ }
15
+ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
16
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`"${name}" is not a name an app can have: lowercase letters, digits and dashes, starting with a letter`);
17
+ const known = themes();
18
+ if (!known.includes(theme)) throw new Error(`"${theme}" is not one of the package's themes: ${known.join(", ")}`);
19
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
20
+ const own = (dep) => {
21
+ const range = pkg.devDependencies[dep] ?? versions[dep];
22
+ if (!range) throw new Error(`no version for ${dep}`);
23
+ return range;
24
+ };
25
+ const deps = (names) => Object.fromEntries(names.map((n) => [n, own(n)]));
26
+ const packageJson = {
27
+ name,
28
+ private: true,
29
+ version: "0.0.0",
30
+ type: "module",
31
+ scripts: {
32
+ dev: "vite",
33
+ build: "tsc -b && vite build",
34
+ preview: "vite preview",
35
+ typecheck: "tsc -b",
36
+ lint: "eslint .",
37
+ "lint:tokens": "eslint --config eslint.tokens.config.js .",
38
+ "lint:rules": "eslint --config eslint.gates.config.js .",
39
+ "postlint:rules": `estiva-gates count --repo ${name}`,
40
+ "gates:status": "estiva-gates status",
41
+ test: "vitest run",
42
+ storybook: "storybook dev -p 6006",
43
+ "build-storybook": "storybook build"
44
+ },
45
+ dependencies: {
46
+ "@estiva-app/identity": own("@estiva-app/identity"),
47
+ "@estiva-app/ui": ui ?? `^${pkg.version}`,
48
+ ...deps(["@tabler/icons-react", "react", "react-dom"])
49
+ },
50
+ devDependencies: deps([
51
+ "@eslint/js",
52
+ "@storybook/addon-docs",
53
+ "@storybook/react-vite",
54
+ "@testing-library/dom",
55
+ "@testing-library/react",
56
+ "@types/node",
57
+ "@types/react",
58
+ "@types/react-dom",
59
+ "@vitejs/plugin-react",
60
+ "autoprefixer",
61
+ "eslint",
62
+ "eslint-plugin-better-tailwindcss",
63
+ "eslint-plugin-react-hooks",
64
+ "globals",
65
+ "jsdom",
66
+ "postcss",
67
+ "storybook",
68
+ "tailwindcss",
69
+ "typescript",
70
+ "typescript-eslint",
71
+ "vite",
72
+ "vitest"
73
+ ])
74
+ };
75
+ const count = { schemaVersion: 1, repo: name, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }])) };
76
+ const themeAttr = ` data-theme="${theme}"`;
77
+ const tsBase = {
78
+ target: "ES2023",
79
+ module: "ESNext",
80
+ skipLibCheck: true,
81
+ moduleResolution: "bundler",
82
+ allowImportingTsExtensions: true,
83
+ verbatimModuleSyntax: true,
84
+ moduleDetection: "force",
85
+ noEmit: true,
86
+ strict: true,
87
+ noUnusedLocals: false,
88
+ noUnusedParameters: false,
89
+ erasableSyntaxOnly: true,
90
+ noFallthroughCasesInSwitch: true,
91
+ noUncheckedSideEffectImports: true
92
+ };
93
+ const json = (value) => `${JSON.stringify(value, null, 2)}
94
+ `;
95
+ return {
96
+ "package.json": json(packageJson),
97
+ ".gitignore": ["node_modules", "dist", "storybook-static", "*.local", "*.log", "*.tsbuildinfo", ".DS_Store", ""].join("\n"),
98
+ ".env.example": `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.
99
+ #
100
+ # Left empty, the app offers no sign-in at all and runs anonymous: it never
101
+ # reaches the real Estiva ID by accident.
102
+ #
103
+ # The app must be registered with that Estiva ID first, as its own app, with
104
+ # this redirect address: the origin the app runs on, and a slash
105
+ # (http://localhost:5173/). The real Estiva ID registers a new app on the
106
+ # server; a local one (http://localhost:8787) in its database.
107
+ VITE_ESTIVA_ID_ORIGIN=
108
+ VITE_ESTIVA_ID_CLIENT_ID=${name}
109
+ `,
110
+ "index.html": `<!doctype html>
111
+ <html lang="en"${themeAttr}>
112
+ <head>
113
+ <meta charset="UTF-8" />
114
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
115
+ <title>${title}</title>
116
+ </head>
117
+ <body>
118
+ <div id="root"></div>
119
+ <script type="module" src="/src/main.tsx"></script>
120
+ </body>
121
+ </html>
122
+ `,
123
+ "tsconfig.json": json({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }),
124
+ "tsconfig.app.json": json({
125
+ compilerOptions: { tsBuildInfoFile: "./node_modules/.tmp/tsconfig.app.tsbuildinfo", ...tsBase, useDefineForClassFields: true, lib: ["ES2023", "DOM", "DOM.Iterable"], types: ["vite/client"], jsx: "react-jsx", paths: { "@/*": ["./src/*"] } },
126
+ include: ["src", ".storybook"]
127
+ }),
128
+ "tsconfig.node.json": json({
129
+ compilerOptions: { tsBuildInfoFile: "./node_modules/.tmp/tsconfig.node.tsbuildinfo", ...tsBase, lib: ["ES2023"], types: ["node"] },
130
+ include: ["vite.config.ts"]
131
+ }),
132
+ "vite.config.ts": `import { fileURLToPath } from 'node:url'
133
+ import react from '@vitejs/plugin-react'
134
+ import { defineConfig } from 'vitest/config'
135
+
136
+ // \`react\` is deduped because @estiva-app/ui declares it a peer: two copies in one
137
+ // tree is the "invalid hook call" crash, and it appears at runtime.
138
+ export default defineConfig({
139
+ plugins: [react()],
140
+ resolve: {
141
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
142
+ dedupe: ['react', 'react-dom'],
143
+ },
144
+ test: {
145
+ environment: 'jsdom',
146
+ },
147
+ })
148
+ `,
149
+ "tailwind.config.js": `import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'
150
+
151
+ /**
152
+ * The package's preset, and nothing of the app's own: a token is added to the
153
+ * package, in every theme, never here.
154
+ *
155
+ * \`estivaContent\` is the package's own files. Tailwind does not merge \`content\`
156
+ * from a preset, so without it every class used only by a package part is
157
+ * purged, and the app builds clean while rendering at the wrong size.
158
+ *
159
+ * @type {import('tailwindcss').Config}
160
+ */
161
+ export default {
162
+ presets: [estiva],
163
+ content: [...estivaContent, './index.html', './src/**/*.{ts,tsx}', './.storybook/**/*.{ts,tsx}'],
164
+ }
165
+ `,
166
+ "postcss.config.js": `export default {
167
+ plugins: {
168
+ tailwindcss: {},
169
+ autoprefixer: {},
170
+ },
171
+ }
172
+ `,
173
+ "eslint.config.js": `import js from '@eslint/js'
174
+ import { gateLint, TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'
175
+ import reactHooks from 'eslint-plugin-react-hooks'
176
+ import { defineConfig, globalIgnores } from 'eslint/config'
177
+ import globals from 'globals'
178
+ import tseslint from 'typescript-eslint'
179
+
180
+ /**
181
+ * Everything, in one lint (\`npm run lint\`, a CI step): TypeScript's and React's
182
+ * recommended rules, the token contract and the UI Guardrails' rules. A new app
183
+ * has no backlog, so all of it is a gate from the first commit.
184
+ *
185
+ * The token contract and the gate are the package's (\`@estiva-app/ui/gates\`),
186
+ * imported, never copied: a rule written later arrives with a version bump.
187
+ */
188
+ export default defineConfig([
189
+ globalIgnores(TOKEN_LINT_IGNORES),
190
+ {
191
+ files: ['**/*.{ts,tsx}'],
192
+ extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],
193
+ languageOptions: { globals: globals.browser },
194
+ },
195
+ tokenLint(),
196
+ tokenValues(),
197
+ gateLint(),
198
+ ])
199
+ `,
200
+ "eslint.tokens.config.js": `import { TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'
201
+ import reactHooks from 'eslint-plugin-react-hooks'
202
+ import { defineConfig, globalIgnores } from 'eslint/config'
203
+
204
+ // The token contract on its own (\`npm run lint:tokens\`). React's hooks plugin is
205
+ // registered with its rules off, so its directives in the source do not break it.
206
+ export default defineConfig([
207
+ globalIgnores(TOKEN_LINT_IGNORES),
208
+ { plugins: { 'react-hooks': reactHooks }, linterOptions: { reportUnusedDisableDirectives: 'off' } },
209
+ tokenLint(),
210
+ tokenValues(),
211
+ ])
212
+ `,
213
+ "eslint.gates.config.js": `import { gateConfig } from '@estiva-app/ui/gates'
214
+ import reactHooks from 'eslint-plugin-react-hooks'
215
+
216
+ /**
217
+ * The UI Guardrails' rules on their own \u2014 \`npm run lint:rules\`, CI's job \`gate\`
218
+ * (GitHub requires it on main: never rename it), and what the editor hook in
219
+ * \`.claude/settings.json\` lints a proposed write with. The package's gate,
220
+ * imported. React's hooks plugin is named so its directives do not break it.
221
+ *
222
+ * A place that keeps something the gate refuses says why, on the line above:
223
+ * \`// @estiva-escape: <reason>\`. Never \`eslint-disable\`: the count refuses it.
224
+ */
225
+ export default gateConfig({ quiet: { 'react-hooks': reactHooks } })
226
+ `,
227
+ ".claude/settings.json": json({
228
+ hooks: {
229
+ PreToolUse: [{ matcher: "Edit|Write", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook' }] }]
230
+ }
231
+ }),
232
+ "scripts/gates-checks.mjs": `import { appChecks } from '@estiva-app/ui/gates'
233
+
234
+ /**
235
+ * What gates:status checks in ${name}: the gate checks every app runs, from the
236
+ * package (\`appChecks\`), and nothing of its own yet. A check about this app's
237
+ * own code goes beside them.
238
+ */
239
+ export default function define(h) {
240
+ return { repo: '${name}', tickets: appChecks(h, { page: 'src/pages/HomePage.tsx' }) }
241
+ }
242
+ `,
243
+ ".gates-count.json": json(count),
244
+ "docs/GATES-DEBT.md": `# What ${title} owes the gates
245
+
246
+ Nothing. ${title} was made with every gate on, at zero.
247
+
248
+ It should stay that way. A place that keeps something the gate refuses says why on
249
+ the line above it, \`// @estiva-escape: <reason>\`, and the count lists it; a whole
250
+ file that cannot pass yet goes here, with its reason.
251
+ `,
252
+ ".github/workflows/deploy.yml": `name: deploy
253
+
254
+ # Every pull request and every push to main runs the checks. There is no deploy
255
+ # job yet: a new app has nowhere to go until it has a home. When it does, the job
256
+ # goes here, and it needs \`check\` and \`gate\` first.
257
+
258
+ on:
259
+ push:
260
+ branches: [main]
261
+ pull_request:
262
+
263
+ concurrency:
264
+ group: deploy-${name}-\${{ github.ref }}
265
+ cancel-in-progress: true
266
+
267
+ jobs:
268
+ check:
269
+ runs-on: ubuntu-latest
270
+ steps:
271
+ - uses: actions/checkout@v4
272
+ - uses: actions/setup-node@v4
273
+ with:
274
+ node-version: 24
275
+ cache: npm
276
+ - run: npm ci
277
+ - name: Typecheck the app
278
+ run: npx tsc -b tsconfig.app.json
279
+ - name: Typecheck the settings files
280
+ run: npx tsc -b tsconfig.node.json
281
+ - name: Lint \u2014 everything, the token contract included
282
+ run: npm run lint
283
+ - name: Test
284
+ run: npm test
285
+ - name: Build
286
+ run: npm run build
287
+ # A class only a package part uses reaches the stylesheet only if
288
+ # tailwind.config.js spreads estivaContent. When it does not, the build still
289
+ # succeeds and parts render at the wrong size, so this is where it is caught.
290
+ - name: The package's classes survive Tailwind's purge
291
+ run: |
292
+ if ! grep -qr "max-h-72" dist/assets/*.css; then
293
+ echo "::error::a class used only by @estiva-app/ui is missing from the built CSS \u2014 check estivaContent is spread into tailwind.config.js"
294
+ exit 1
295
+ fi
296
+
297
+ # The UI Guardrails' rules, on their own: a raw element, behaviour a part owns
298
+ # written by hand, a part restyled. \`npm run lint:rules\` also writes
299
+ # .gates-count.json and fails on an eslint-disable that switches a rule off.
300
+ #
301
+ # A job of its own, because GitHub can require only a whole job, by its name:
302
+ # the rule on main requires \`gate\`. Renaming it leaves every pull request
303
+ # waiting for a check that never reports.
304
+ gate:
305
+ runs-on: ubuntu-latest
306
+ steps:
307
+ - uses: actions/checkout@v4
308
+ - uses: actions/setup-node@v4
309
+ with:
310
+ node-version: 24
311
+ cache: npm
312
+ - run: npm ci
313
+ - name: Gate lint
314
+ run: npm run lint:rules
315
+ `,
316
+ "src/index.css": `@import '@estiva-app/ui/tokens.css';
317
+ @import '@estiva-app/ui/base.css';
318
+
319
+ @tailwind base;
320
+ @tailwind components;
321
+ @tailwind utilities;
322
+
323
+ html,
324
+ body,
325
+ #root {
326
+ height: 100%;
327
+ }
328
+ `,
329
+ "src/vite-env.d.ts": `/// <reference types="vite/client" />
330
+
331
+ interface ImportMetaEnv {
332
+ readonly VITE_ESTIVA_ID_ORIGIN?: string
333
+ readonly VITE_ESTIVA_ID_CLIENT_ID?: string
334
+ }
335
+ `,
336
+ "src/config.ts": `/** What the app is called on screen. */
337
+ export const APP_TITLE = ${JSON.stringify(title)}
338
+
339
+ /**
340
+ * Estiva ID, or \`null\` when this build offers no sign-in at all. Empty is a real
341
+ * mode, not a broken one: the app runs anonymous, and a local build can never
342
+ * reach the real Estiva ID by accident. See .env.example.
343
+ */
344
+ export const ID_CONFIG: { base: string; clientId: string } | null =
345
+ import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID
346
+ ? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }
347
+ : null
348
+ `,
349
+ "src/auth/estivaId.ts": `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'
350
+ import { ID_CONFIG } from '../config'
351
+
352
+ /**
353
+ * Signing in with Estiva ID, the way Ship does: \`@estiva-app/identity\` holds the
354
+ * flow, this file the app's choices.
355
+ *
356
+ * - The redirect is the origin and a slash, never the current path: Estiva ID
357
+ * compares it exactly against the one address registered for the app.
358
+ * - The session lives in localStorage, so it outlives a tab; a sign-in's
359
+ * single-use credentials in sessionStorage, per tab.
360
+ */
361
+ const client = ID_CONFIG
362
+ ? createEstivaId({
363
+ base: ID_CONFIG.base,
364
+ clientId: ID_CONFIG.clientId,
365
+ redirectUri: () => \`\${window.location.origin}/\`,
366
+ storage: () => (typeof localStorage === 'undefined' ? null : localStorage),
367
+ pendingStore: () => (typeof sessionStorage === 'undefined' ? null : sessionStorage),
368
+ keyPrefix: '${name}.estiva-id',
369
+ navigate: (url) => window.location.assign(url),
370
+ })
371
+ : null
372
+
373
+ export const signInAvailable = client !== null
374
+
375
+ export function currentToken(): StoredToken | null {
376
+ return client?.validToken(Date.now() + 60_000) ?? null
377
+ }
378
+
379
+ export function startRenewal(): void {
380
+ client?.scheduleRenewal()
381
+ }
382
+
383
+ export async function beginSignIn(options: { silent?: boolean } = {}): Promise<void> {
384
+ await client?.beginSignIn(\`\${window.location.pathname}\${window.location.search}\`, options)
385
+ }
386
+
387
+ export function beginSignOut(): void {
388
+ client?.beginSignOut()
389
+ }
390
+
391
+ export async function completeSignIn(): Promise<{ token: StoredToken; returnTo: string }> {
392
+ if (!client) throw new Error('this build offers no sign-in')
393
+ return client.completeSignIn(window.location.search)
394
+ }
395
+
396
+ export function stashShellReason(reason: ShellReason): void {
397
+ client?.stashShellReason(reason)
398
+ }
399
+
400
+ export function takeShellReason() {
401
+ return client?.takeShellReason() ?? null
402
+ }
403
+
404
+ export function callbackParams(): { code?: string; error?: string } | null {
405
+ const params = new URLSearchParams(window.location.search)
406
+ const code = params.get('code') ?? undefined
407
+ const error = params.get('error') ?? undefined
408
+ return code || error ? { code, error } : null
409
+ }
410
+
411
+ export function clearQuery(): void {
412
+ window.history.replaceState({}, '', window.location.pathname)
413
+ }
414
+
415
+ export function guardAttemptedAt(): number | null {
416
+ if (typeof sessionStorage === 'undefined') return null
417
+ const raw = sessionStorage.getItem('estiva.authShell.silentAttempt')
418
+ const at = raw ? Number(raw) : NaN
419
+ return Number.isFinite(at) ? at : null
420
+ }
421
+
422
+ /**
423
+ * Who Estiva ID says this is. Asked of its directory with the token, so the
424
+ * answer also proves Estiva ID accepted the sign-in.
425
+ */
426
+ export async function whoAmI(): Promise<{ name?: string; email?: string } | null> {
427
+ const token = currentToken()
428
+ if (!ID_CONFIG || !token) return null
429
+ const response = await fetch(\`\${ID_CONFIG.base}/directory/\${token.pubkey}\`, { headers: { Authorization: \`Bearer \${token.accessToken}\` } })
430
+ if (!response.ok) return null
431
+ const entry = (await response.json()) as { displayName?: string; email?: string }
432
+ return { name: entry.displayName, email: entry.email }
433
+ }
434
+ `,
435
+ "src/auth/boot.ts": `import { decideBoot, type ShellReason, type ShellState } from '@estiva-app/identity'
436
+ import {
437
+ beginSignIn,
438
+ callbackParams,
439
+ clearQuery,
440
+ completeSignIn,
441
+ currentToken,
442
+ guardAttemptedAt,
443
+ signInAvailable,
444
+ startRenewal,
445
+ stashShellReason,
446
+ takeShellReason,
447
+ } from './estivaId'
448
+
449
+ export type BootResult = { kind: 'app' } | { kind: 'shell'; state: ShellState } | { kind: 'leaving' }
450
+
451
+ /**
452
+ * Settle who this load belongs to, before the app renders. The decision is
453
+ * \`decideBoot\` from \`@estiva-app/identity\`; this is the reading and acting
454
+ * around it. It never throws: a blank page is worse than an honest shell.
455
+ */
456
+ export async function bootAuth(): Promise<BootResult> {
457
+ if (!signInAvailable) return { kind: 'app' }
458
+
459
+ const action = decideBoot({
460
+ callback: callbackParams(),
461
+ hasValidToken: currentToken() !== null,
462
+ guardAttemptedAt: guardAttemptedAt(),
463
+ enteredThisPageLoad: false,
464
+ now: Date.now(),
465
+ })
466
+
467
+ switch (action.do) {
468
+ case 'enter':
469
+ startRenewal()
470
+ return { kind: 'app' }
471
+ case 'complete_callback':
472
+ try {
473
+ await completeSignIn()
474
+ clearQuery()
475
+ startRenewal()
476
+ return { kind: 'app' }
477
+ } catch (cause) {
478
+ clearQuery()
479
+ stashShellReason('exchange_failed')
480
+ return shell('exchange_failed', cause)
481
+ }
482
+ case 'probe_silently':
483
+ try {
484
+ await beginSignIn({ silent: true })
485
+ return { kind: 'leaving' }
486
+ } catch {
487
+ return shell('network')
488
+ }
489
+ case 'prompt_passkey':
490
+ return { kind: 'shell', state: { phase: 'authenticating', reason: takeShellReason() ?? action.reason } }
491
+ case 'fail':
492
+ return shell(action.reason)
493
+ }
494
+ }
495
+
496
+ function shell(reason: ShellReason, cause?: unknown): BootResult {
497
+ if (cause) console.warn('[auth] entry failed:', cause)
498
+ return { kind: 'shell', state: { phase: 'failed', reason } }
499
+ }
500
+
501
+ export async function enterFromShell(): Promise<void> {
502
+ await beginSignIn()
503
+ }
504
+ `,
505
+ "src/auth/AuthShell.tsx": `import { CONTINUE_LABEL, isRecoverable, RETRY_LABEL, SHELL_COPY, type ShellState } from '@estiva-app/identity'
506
+ import { Button } from '@estiva-app/ui'
507
+ import { APP_TITLE } from '../config'
508
+
509
+ export interface AuthShellProps {
510
+ state: ShellState
511
+ onContinue: () => void
512
+ }
513
+
514
+ /**
515
+ * What shows while nobody is signed in: the app's name, one line and at most one
516
+ * button. The words are \`@estiva-app/identity\`'s, so every app says the same.
517
+ */
518
+ export function AuthShell({ state, onContinue }: AuthShellProps) {
519
+ const waiting = state.phase === 'checking' || state.phase === 'entering' || state.phase === 'ready'
520
+ return (
521
+ <div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-bg-surface px-6 text-center">
522
+ <h1 className="text-h3 text-text-primary">{APP_TITLE}</h1>
523
+ {!waiting && (
524
+ <>
525
+ <p className="max-w-prose text-body-2 text-text-secondary">{SHELL_COPY[state.reason]}</p>
526
+ {isRecoverable(state.reason) && <Button onClick={onContinue}>{state.phase === 'authenticating' ? CONTINUE_LABEL : RETRY_LABEL}</Button>}
527
+ </>
528
+ )}
529
+ </div>
530
+ )
531
+ }
532
+ `,
533
+ "src/main.tsx": `import { StrictMode } from 'react'
534
+ import { createRoot } from 'react-dom/client'
535
+ import './index.css'
536
+ import { AuthShell } from './auth/AuthShell'
537
+ import { bootAuth, enterFromShell } from './auth/boot'
538
+
539
+ // Settle who this load belongs to first, then render: the app itself is imported
540
+ // only once sign-in is settled, so nothing in it evaluates as the wrong person.
541
+ void (async () => {
542
+ const root = createRoot(document.getElementById('root')!)
543
+ const boot = await bootAuth()
544
+ if (boot.kind === 'leaving') return
545
+ if (boot.kind === 'shell') {
546
+ root.render(
547
+ <StrictMode>
548
+ <AuthShell state={boot.state} onContinue={() => void enterFromShell()} />
549
+ </StrictMode>,
550
+ )
551
+ return
552
+ }
553
+ const { App } = await import('./App')
554
+ root.render(
555
+ <StrictMode>
556
+ <App />
557
+ </StrictMode>,
558
+ )
559
+ })()
560
+ `,
561
+ "src/App.tsx": `import { AppShell, IdentityMenu, NavItem, Sidebar, type Identity } from '@estiva-app/ui'
562
+ import { IconHome } from '@tabler/icons-react'
563
+ import { useEffect, useState } from 'react'
564
+ import { beginSignOut, currentToken, whoAmI } from './auth/estivaId'
565
+ import { APP_TITLE, ID_CONFIG } from './config'
566
+ import { HomePage } from './pages/HomePage'
567
+
568
+ /** The frame: the package's AppShell with a sidebar, and the one page. */
569
+ export function App() {
570
+ const signedIn = currentToken() !== null
571
+ const [me, setMe] = useState<Identity>({})
572
+
573
+ useEffect(() => {
574
+ if (!signedIn) return
575
+ let live = true
576
+ void whoAmI().then((who) => {
577
+ if (live && who) setMe(who)
578
+ })
579
+ return () => {
580
+ live = false
581
+ }
582
+ }, [signedIn])
583
+
584
+ return (
585
+ <AppShell
586
+ logo={APP_TITLE}
587
+ identity={<IdentityMenu me={me} signedIn={signedIn} idBase={ID_CONFIG?.base} onSignOut={signedIn ? beginSignOut : undefined} />}
588
+ nav={
589
+ <Sidebar>
590
+ <NavItem href="/" label="Home" icon={<IconHome size={16} stroke={1.5} />} active />
591
+ </Sidebar>
592
+ }
593
+ >
594
+ <HomePage />
595
+ </AppShell>
596
+ )
597
+ }
598
+ `,
599
+ "src/pages/HomePage.tsx": `import { EmptyState } from '@estiva-app/ui'
600
+
601
+ /** The first page. What it becomes is this app's own work. */
602
+ export function HomePage() {
603
+ return (
604
+ <div className="flex justify-center px-6 py-16">
605
+ <EmptyState message="Nothing here yet." />
606
+ </div>
607
+ )
608
+ }
609
+ `,
610
+ "src/pages/HomePage.stories.tsx": `import type { Meta, StoryObj } from '@storybook/react-vite'
611
+ import { HomePage } from './HomePage'
612
+
613
+ const meta = {
614
+ title: 'Pages/Home',
615
+ component: HomePage,
616
+ parameters: { layout: 'fullscreen' },
617
+ } satisfies Meta<typeof HomePage>
618
+
619
+ export default meta
620
+ type Story = StoryObj<typeof meta>
621
+
622
+ export const Empty: Story = {}
623
+ `,
624
+ "src/App.test.tsx": `import { render, screen } from '@testing-library/react'
625
+ import { describe, expect, it } from 'vitest'
626
+ import { App } from './App'
627
+ import { APP_TITLE } from './config'
628
+
629
+ describe('${title}', () => {
630
+ it('opens in its frame, anonymous in a build with no sign-in', () => {
631
+ render(<App />)
632
+ expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)
633
+ expect(screen.getByText('Nothing here yet.')).toBeTruthy()
634
+ expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()
635
+ })
636
+ })
637
+ `,
638
+ ".storybook/main.ts": `import type { StorybookConfig } from '@storybook/react-vite'
639
+
640
+ const config: StorybookConfig = {
641
+ framework: '@storybook/react-vite',
642
+ stories: ['../src/**/*.stories.@(ts|tsx)'],
643
+ addons: ['@storybook/addon-docs'],
644
+ }
645
+
646
+ export default config
647
+ `,
648
+ ".storybook/preview.tsx": `import type { Preview } from '@storybook/react-vite'
649
+ import '../src/index.css'
650
+
651
+ // The app's one theme, selected the way the app selects it: on <html>.
652
+ document.documentElement.dataset.theme = ${JSON.stringify(theme)}
653
+
654
+ const preview: Preview = {
655
+ parameters: { layout: 'centered' },
656
+ }
657
+
658
+ export default preview
659
+ `,
660
+ "README.md": `# ${title}
661
+
662
+ An Estiva app, made with \`create-estiva-app\` from \`@estiva-app/ui\`. It starts
663
+ with the package's sidebar frame, one theme (\`${theme}\`), sign-in with Estiva ID,
664
+ and every UI Guardrails gate on, at zero.
665
+
666
+ ## Run it
667
+
668
+ \`\`\`sh
669
+ npm install
670
+ npm run dev
671
+ \`\`\`
672
+
673
+ With no settings it runs **anonymous**: no sign-in is offered, and nothing reaches
674
+ the real Estiva ID. To sign in, copy \`.env.example\` to \`.env.local\` and fill it in.
675
+ The app must first be registered with that Estiva ID as its own app.
676
+
677
+ ## The checks
678
+
679
+ | command | what |
680
+ |---|---|
681
+ | \`npm run typecheck\` | TypeScript, the app and its settings files |
682
+ | \`npm run lint\` | everything: TypeScript's and React's rules, the token contract, the gate |
683
+ | \`npm run lint:rules\` | the gate alone, and \`.gates-count.json\` \u2014 CI's job \`gate\` |
684
+ | \`npm test\` | the tests |
685
+ | \`npm run build\` | the build |
686
+ | \`npm run gates:status\` | which gates are on, read from the code |
687
+ | \`npm run storybook\` | the stories |
688
+
689
+ The gates are the package's, imported rather than copied, so a rule written later
690
+ arrives with an ordinary version bump. A Claude session started in this folder is
691
+ stopped before it writes code the gate refuses (\`.claude/settings.json\`).
692
+
693
+ ## Once it is on GitHub
694
+
695
+ Require the check \`gate\` before anything merges into \`main\`: a ruleset on \`main\`
696
+ (Settings \u2192 Rules \u2192 Rulesets) with "Require status checks to pass", the check
697
+ \`gate\`, and no one allowed to bypass it. Until then \`npm run gates:status\` shows
698
+ UIG-6 as not done.
699
+ `,
700
+ "CLAUDE.md": `# ${title}, for Claude Code
701
+
702
+ ${title} is an Estiva app. Its parts, tokens and gates come from \`@estiva-app/ui\`.
703
+
704
+ **Use the package's parts, never a raw element or a hand-built look.** The gate
705
+ refuses a raw control, behaviour a part owns written by hand, and a part restyled
706
+ through \`className\`, and names what to use instead. It runs before you write
707
+ (the hook in \`.claude/settings.json\`, for a session started in this folder), in
708
+ \`npm run lint:rules\` and in CI. A session started elsewhere: run
709
+ \`npm run lint:rules\` after changing \`src/\`, and fix what it reports. Keep something
710
+ only with its reason on the line above, \`// @estiva-escape: <reason>\`, never with
711
+ \`eslint-disable\`.
712
+
713
+ **Tokens only.** Colours, type, corners and shadows come from the package's preset.
714
+
715
+ **The count starts at zero and stays there** (\`.gates-count.json\`, \`docs/GATES-DEBT.md\`).
716
+
717
+ What each gate is and how it is wired: the package's README,
718
+ \`node_modules/@estiva-app/ui/README.md\`.
719
+ `
720
+ };
721
+ }
722
+ function askNpm(names) {
723
+ const npm = process.env.npm_execpath;
724
+ const out = {};
725
+ for (const name of names) {
726
+ const args = ["view", name, "version"];
727
+ const version = (npm ? execFileSync(process.execPath, [npm, ...args]) : execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { shell: process.platform === "win32" })).toString().trim();
728
+ if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error(`npm did not say which version of ${name} is current: "${version}"`);
729
+ out[name] = `^${version}`;
730
+ }
731
+ return out;
732
+ }
733
+ function createApp(options) {
734
+ const dir = resolve(options.parent ?? process.cwd(), options.name);
735
+ if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`);
736
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
737
+ const missing = ["@estiva-app/identity", "eslint-plugin-react-hooks"].filter((n) => !pkg.devDependencies[n] && !options.versions?.[n]);
738
+ const versions = { ...missing.length ? askNpm(missing) : {}, ...options.versions };
739
+ const files = appFiles({ ...options, versions });
740
+ for (const [rel, text] of Object.entries(files)) {
741
+ const path = join(dir, rel);
742
+ mkdirSync(dirname(path), { recursive: true });
743
+ writeFileSync(path, text);
744
+ }
745
+ return dir;
746
+ }
747
+
748
+ export {
749
+ themes,
750
+ appFiles,
751
+ askNpm,
752
+ createApp
753
+ };
754
+ //# sourceMappingURL=chunk-ZGJ2J5NU.js.map