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