@estiva-app/ui 0.34.0 → 0.35.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.
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-JZE7TIQY.js → chunk-D466XXVL.js} +12 -6
- package/dist/gates/chunk-D466XXVL.js.map +7 -0
- package/dist/gates/{chunk-XAPFC37F.js → chunk-JWOU4UQZ.js} +43 -8
- package/dist/gates/chunk-JWOU4UQZ.js.map +7 -0
- package/dist/gates/cli.js +12 -4
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/gate-config.d.ts +1 -1
- package/dist/gates/hook.d.ts +1 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.js +2 -2
- package/dist/gates/index.js.map +1 -1
- package/dist/gates/status.d.ts +9 -3
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/registry/cli.js +103 -15
- package/dist/registry/cli.js.map +4 -4
- package/dist/registry/storymap.d.ts +12 -0
- package/dist/registry/storymap.d.ts.map +1 -0
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/gates/app-checks.ts +1 -1
- package/src/gates/cli.ts +15 -4
- package/src/gates/create-app.test.ts +56 -1
- package/src/gates/create-app.ts +11 -5
- package/src/gates/gate-config.ts +1 -1
- package/src/gates/gates.test.ts +35 -2
- package/src/gates/hook.ts +1 -1
- package/src/gates/status.ts +62 -17
- package/src/registry/cli.ts +21 -3
- package/src/registry/storymap.test.ts +96 -0
- package/src/registry/storymap.ts +107 -0
- package/dist/gates/chunk-JZE7TIQY.js.map +0 -7
- package/dist/gates/chunk-XAPFC37F.js.map +0 -7
package/src/gates/status.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* estiva-gates status one row per ticket
|
|
6
6
|
* estiva-gates status --detail every check under every row
|
|
7
7
|
* estiva-gates status --json machine output, read by estiva-ui's run
|
|
8
|
-
* estiva-gates status --app web the app sits in `web
|
|
8
|
+
* estiva-gates status --app web the app sits in `web/` (Ship's did, until PER-19)
|
|
9
9
|
*
|
|
10
10
|
* Every row is decided by checks on real files, a real lint run or a real
|
|
11
11
|
* GitHub setting. Nothing here reads a list that someone ticks by hand.
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
*
|
|
20
20
|
* What differs per repo is `scripts/gates-checks.mjs`, beside the app: its
|
|
21
21
|
* checks, or the checks every app runs (`appChecks`) plus its own. It sits with
|
|
22
|
-
* the app because it imports the package
|
|
23
|
-
*
|
|
22
|
+
* the app because it imports the package: in an app that sits in a folder of
|
|
23
|
+
* its own, the checks file sits there too (UIG-32).
|
|
24
24
|
*
|
|
25
25
|
* The rows and the reasons for each check are in estiva-ui docs/GATES.md §15
|
|
26
26
|
* and §17.
|
|
27
27
|
*/
|
|
28
|
-
import { execFileSync } from 'node:child_process'
|
|
28
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
29
29
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
30
30
|
import { createRequire } from 'node:module'
|
|
31
31
|
import { join, relative, resolve } from 'node:path'
|
|
@@ -36,7 +36,7 @@ export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail
|
|
|
36
36
|
type Maybe<T> = T | Promise<T>
|
|
37
37
|
|
|
38
38
|
export interface LintProbe {
|
|
39
|
-
/** The folder ESLint runs from, relative to the repo:
|
|
39
|
+
/** The folder ESLint runs from, relative to the repo: `.` unless the app sits in a folder of its own. */
|
|
40
40
|
cwd?: string
|
|
41
41
|
/** The config file, relative to `cwd`. */
|
|
42
42
|
config: string
|
|
@@ -106,7 +106,7 @@ export interface GateSpec {
|
|
|
106
106
|
tickets: GateTicket[]
|
|
107
107
|
/** estiva-ui only: every ticket, and the repos it joins in. */
|
|
108
108
|
all?: TicketListEntry[]
|
|
109
|
-
/** estiva-ui only: the repos beside it. `app` is the folder its app sits in
|
|
109
|
+
/** estiva-ui only: the repos beside it. `app` is the folder its app sits in, when not the top. */
|
|
110
110
|
siblings?: { name: string; path: string; env: string; app?: string }[]
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -292,16 +292,39 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
292
292
|
|
|
293
293
|
hook(settingsRel, needle) {
|
|
294
294
|
if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`)
|
|
295
|
-
let s: { hooks?: { PreToolUse?: { hooks?: { command?: string }[] }[] } }
|
|
295
|
+
let s: { hooks?: { PreToolUse?: { matcher?: string; hooks?: { command?: string }[] }[] } }
|
|
296
296
|
try {
|
|
297
297
|
s = JSON.parse(read(settingsRel))
|
|
298
298
|
} catch {
|
|
299
299
|
return FAIL(`${settingsRel} is not valid JSON`)
|
|
300
300
|
}
|
|
301
|
-
const
|
|
302
|
-
const hit =
|
|
303
|
-
if (hit) return
|
|
304
|
-
|
|
301
|
+
const entries = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => ({ matcher: m.matcher ?? '', command: x.command ?? '' })))
|
|
302
|
+
const hit = entries.find((e) => e.command.includes(needle))
|
|
303
|
+
if (!hit) return FAIL(entries.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`)
|
|
304
|
+
// A hook that is written but never fires, or fires and cannot run, guards
|
|
305
|
+
// nothing — and Claude Code lets a write through on any exit but 2 (audit
|
|
306
|
+
// A2: Ship's hook pointed at an install its checkout did not have). So the
|
|
307
|
+
// hook is run, as Claude Code would run it, on a raw <button>.
|
|
308
|
+
let matches: boolean
|
|
309
|
+
try {
|
|
310
|
+
matches = hit.matcher === '' || hit.matcher === '*' || new RegExp(`^(?:${hit.matcher})$`).test('Write')
|
|
311
|
+
} catch {
|
|
312
|
+
return FAIL(`${settingsRel}: the hook's matcher "${hit.matcher}" is not a pattern`)
|
|
313
|
+
}
|
|
314
|
+
if (!matches) return FAIL(`${settingsRel}: the hook runs only for "${hit.matcher}", never for a Write`)
|
|
315
|
+
const top = ROOT.split('\\').join('/')
|
|
316
|
+
const app = /--app\s+("?)([^"\s]+)\1/.exec(hit.command)?.[2] ?? '.'
|
|
317
|
+
const input = JSON.stringify({
|
|
318
|
+
hook_event_name: 'PreToolUse',
|
|
319
|
+
cwd: top,
|
|
320
|
+
tool_name: 'Write',
|
|
321
|
+
tool_input: { file_path: `${resolve(ROOT, app, 'src', '__gates_hook_probe__.tsx').split('\\').join('/')}`, content: 'export function Probe() {\n return <button type="button">x</button>\n}\n' },
|
|
322
|
+
})
|
|
323
|
+
const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top)
|
|
324
|
+
const run = spawnSync(command, { cwd: ROOT, input, shell: true, encoding: 'utf8', timeout: 120_000, env: { ...process.env, CLAUDE_PROJECT_DIR: top } })
|
|
325
|
+
if (run.status === 2) return PASS(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`)
|
|
326
|
+
const why = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : 'no output')
|
|
327
|
+
return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? 'none'}): ${why}`)
|
|
305
328
|
},
|
|
306
329
|
|
|
307
330
|
json(rel, test = () => true, label) {
|
|
@@ -418,8 +441,8 @@ function statusOf(checks: CheckResult[]) {
|
|
|
418
441
|
|
|
419
442
|
async function runRepo(ROOT: string, app: string, h: GateHelpers): Promise<{ spec: GateSpec; report: Report }> {
|
|
420
443
|
// The checks file sits with the app, because it imports `appChecks` from the
|
|
421
|
-
// package: in
|
|
422
|
-
// folder (UIG-32). Everything it names is still read from the
|
|
444
|
+
// package: for an app in a folder of its own, that install is there, not in
|
|
445
|
+
// the repo's top folder (UIG-32). Everything it names is still read from the top.
|
|
423
446
|
const checksFile = join(ROOT, app, 'scripts', 'gates-checks.mjs')
|
|
424
447
|
const define = ((await import(pathToFileURL(checksFile).href)) as { default: (h: GateHelpers) => GateSpec }).default
|
|
425
448
|
const spec = define(h)
|
|
@@ -452,7 +475,7 @@ async function runRepo(ROOT: string, app: string, h: GateHelpers): Promise<{ spe
|
|
|
452
475
|
* Another repository's report. A repo on the package's engine runs it from its
|
|
453
476
|
* own install; a repo still carrying `scripts/gates-status.mjs` runs that copy.
|
|
454
477
|
*
|
|
455
|
-
* `app` is the folder that repo's app sits in
|
|
478
|
+
* `app` is the folder that repo's app sits in, when not the top, where its
|
|
456
479
|
* install and its checks file are. The report is still of the whole repo.
|
|
457
480
|
*/
|
|
458
481
|
function runSibling(dir: string, app: string, shown: string): { report?: Report; error?: string } {
|
|
@@ -479,6 +502,28 @@ function runSibling(dir: string, app: string, shown: string): { report?: Report;
|
|
|
479
502
|
}
|
|
480
503
|
}
|
|
481
504
|
|
|
505
|
+
/**
|
|
506
|
+
* How far a checkout is behind its remote's main, after fetching it. Status reads
|
|
507
|
+
* the checkout, not main: a copy seven commits behind once reported a finished
|
|
508
|
+
* ticket as not started (audit A4). Empty when it is level; a warning otherwise.
|
|
509
|
+
*/
|
|
510
|
+
export function behindMain(dir: string): string {
|
|
511
|
+
const git = (...a: string[]) => {
|
|
512
|
+
try {
|
|
513
|
+
return execFileSync('git', a, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 30000 }).toString().trim()
|
|
514
|
+
} catch {
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (git('rev-parse', '--is-inside-work-tree') !== 'true') return ''
|
|
519
|
+
const fetched = git('fetch', '-q', 'origin') !== null
|
|
520
|
+
const main = (git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? 'origin/main').trim()
|
|
521
|
+
const count = Number(git('rev-list', '--count', `HEAD..${main}`))
|
|
522
|
+
const stale = fetched ? '' : ' (could not fetch: compared with the last fetch)'
|
|
523
|
+
if (!Number.isFinite(count) || count === 0) return stale ? ` ·${stale}` : ''
|
|
524
|
+
return ` · ⚠️ ${count} commit${count === 1 ? '' : 's'} behind ${main}${stale}: pull, or this reads old code`
|
|
525
|
+
}
|
|
526
|
+
|
|
482
527
|
function pad(s: string, n: number) {
|
|
483
528
|
const len = [...s].length
|
|
484
529
|
return len >= n ? s : s + ' '.repeat(n - len)
|
|
@@ -509,7 +554,7 @@ function summary(rows: Row[]) {
|
|
|
509
554
|
export interface StatusOptions {
|
|
510
555
|
/** The repository to report on. */
|
|
511
556
|
root?: string
|
|
512
|
-
/** The folder that holds the app, relative to `root`:
|
|
557
|
+
/** The folder that holds the app, relative to `root`: `.` unless it sits in a folder of its own. */
|
|
513
558
|
app?: string
|
|
514
559
|
json?: boolean
|
|
515
560
|
detail?: boolean
|
|
@@ -526,7 +571,7 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
|
|
|
526
571
|
|
|
527
572
|
const out: string[] = []
|
|
528
573
|
out.push('UI Guardrails · gates:status', 'Read from the code. Nothing here is a hand-ticked list.', '')
|
|
529
|
-
out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}`)
|
|
574
|
+
out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`)
|
|
530
575
|
|
|
531
576
|
if (!spec.all) {
|
|
532
577
|
// A sibling repo on its own: its own rows, then its parts of rows owned elsewhere.
|
|
@@ -553,7 +598,7 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
|
|
|
553
598
|
continue
|
|
554
599
|
}
|
|
555
600
|
const copy = r.report.engine !== ENGINE ? (r.report.engine.startsWith('@estiva-app/ui@') ? ` · on ${r.report.engine}'s engine` : ' · runs its own copy of the status engine; UIG-32 moves it onto the package') : ''
|
|
556
|
-
found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}` })
|
|
601
|
+
found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}${behindMain(dir)}` })
|
|
557
602
|
reports.push(r.report)
|
|
558
603
|
}
|
|
559
604
|
for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`)
|
package/src/registry/cli.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* **In an app**: nothing is committed (Katerina, 18 September 2026 — the
|
|
18
18
|
* package is public, the apps are private). `check` builds the app's catalogue
|
|
19
19
|
* and fails when a part has no one-line description or cannot be sorted, or
|
|
20
|
-
* when a reusable part breaks the usage-page contract (UIG-19, `./contract`)
|
|
21
|
-
*
|
|
20
|
+
* when a reusable part breaks the usage-page contract (UIG-19, `./contract`),
|
|
21
|
+
* or when the app's hand-written map of its Storybook no longer matches it
|
|
22
|
+
* (`./storymap`); CI's `gate` job runs it. `build` writes the catalogue to a file only when asked.
|
|
22
23
|
*
|
|
23
24
|
* `find` searches the package's catalogue — the one shipped inside the installed
|
|
24
25
|
* package, or this repo's own in the package — plus, in an app, the app's own,
|
|
@@ -39,6 +40,7 @@ import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
|
|
|
39
40
|
import { findInRegistries, formatFindings } from './find'
|
|
40
41
|
import { findSiblings, workspaceOf } from './siblings'
|
|
41
42
|
import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
|
|
43
|
+
import { storyMapProblems } from './storymap'
|
|
42
44
|
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -176,6 +178,21 @@ async function checkGated(registry: Registry, folder: string, kinds?: string[]):
|
|
|
176
178
|
return true
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The app's hand-written map of its Storybook — the heading order and the
|
|
183
|
+
* Introduction — against the Storybook and the catalogue (`./storymap`).
|
|
184
|
+
* Reports, and says whether it passed.
|
|
185
|
+
*/
|
|
186
|
+
function checkStoryMap(registry: Registry, folder: string): boolean {
|
|
187
|
+
const problems = storyMapProblems(registry, folder)
|
|
188
|
+
if (problems.length) {
|
|
189
|
+
process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
process.stdout.write('storybook map: the heading order and the Introduction match the Storybook and the catalogue\n')
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
179
196
|
/** One line per kind, in a fixed order, so two runs read the same. */
|
|
180
197
|
function summary(registry: Registry): string {
|
|
181
198
|
const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
|
|
@@ -266,7 +283,8 @@ async function main(): Promise<number> {
|
|
|
266
283
|
const linked = checkLinks(registry, root)
|
|
267
284
|
const shown = await checkGated(registry, root)
|
|
268
285
|
const loaded = checkLoader()
|
|
269
|
-
|
|
286
|
+
const mapped = checkStoryMap(registry, root)
|
|
287
|
+
return broken.length || !linked || !shown || !loaded || !mapped ? 1 : 0
|
|
270
288
|
}
|
|
271
289
|
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
272
290
|
const path = packageRegistryPath()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The app's hand-written map of its Storybook (audit A3). Every case is Peek's:
|
|
4
|
+
* the Huddles heading its Introduction and `HEADINGS` kept after FOL-23 deleted
|
|
5
|
+
* the stories, the "49 parts worth reusing" it kept when the catalogue built 41,
|
|
6
|
+
* and a Storybook with no map at all, which has nothing to rot.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
12
|
+
import type { Registry } from './schema'
|
|
13
|
+
import { introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
|
|
14
|
+
|
|
15
|
+
const made: string[] = []
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function app(files: Record<string, string>): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'storymap-'))
|
|
22
|
+
made.push(dir)
|
|
23
|
+
for (const [path, text] of Object.entries(files)) {
|
|
24
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
25
|
+
writeFileSync(join(dir, path), text)
|
|
26
|
+
}
|
|
27
|
+
return dir
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Only the classes matter here: the Introduction counts reusable and promote-candidate parts. */
|
|
31
|
+
const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
|
|
32
|
+
|
|
33
|
+
const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
|
|
34
|
+
const PREVIEW = (headings: string[]) =>
|
|
35
|
+
`export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
|
|
36
|
+
const INTRO = (count: number, headings: string[]) =>
|
|
37
|
+
`import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title="Docs/Introduction" />\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
|
|
38
|
+
|
|
39
|
+
const PEEK_TODAY = {
|
|
40
|
+
'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives', 'Huddles']),
|
|
41
|
+
'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
|
|
42
|
+
'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title='Primitives/Chip' />\n\n# Chip\n`,
|
|
43
|
+
'.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives', 'Huddles']),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('reading the map', () => {
|
|
47
|
+
it('reads the sidebar from story titles and from page titles in either quote', () => {
|
|
48
|
+
expect([...sidebarHeadings(app(PEEK_TODAY))].sort()).toEqual(['Docs', 'Primitives'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reads the order with its comments removed, and says when there is none', () => {
|
|
52
|
+
expect(orderedHeadings(PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
|
|
53
|
+
expect(orderedHeadings('export default {}')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reads the Introduction: its bold table rows and its stated count', () => {
|
|
57
|
+
const intro = INTRO(49, ['Docs', 'Huddles'])
|
|
58
|
+
expect([...introductionHeadings(intro)]).toEqual(['Docs', 'Huddles'])
|
|
59
|
+
expect(introductionCount(intro)).toBe(49)
|
|
60
|
+
expect(introductionCount('# No number here')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('storyMapProblems', () => {
|
|
65
|
+
it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
|
|
66
|
+
const problems = storyMapProblems(catalogue('reusable', 'reusable', 'reusable', 'local'), app(PEEK_TODAY))
|
|
67
|
+
expect(problems).toEqual([
|
|
68
|
+
'.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
|
|
69
|
+
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
|
|
70
|
+
'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('finds a heading the maps forget, which falls to the bottom of the sidebar', () => {
|
|
75
|
+
const dir = app({ ...PEEK_TODAY, 'src/pages/Desk.stories.tsx': STORY('Pages/Desk') })
|
|
76
|
+
expect(storyMapProblems(catalogue('reusable', 'promote-candidate'), dir)).toEqual([
|
|
77
|
+
'.storybook/preview.tsx: not named in HEADINGS, so they fall to the bottom of the sidebar: Pages',
|
|
78
|
+
'.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
|
|
79
|
+
'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
|
|
80
|
+
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('passes when both maps match the Storybook and the count matches the catalogue', () => {
|
|
85
|
+
const dir = app({
|
|
86
|
+
...PEEK_TODAY,
|
|
87
|
+
'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives']),
|
|
88
|
+
'.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives']),
|
|
89
|
+
})
|
|
90
|
+
expect(storyMapProblems(catalogue('reusable', 'promote-candidate', 'local'), dir)).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('has nothing to check in an app that keeps no map', () => {
|
|
94
|
+
expect(storyMapProblems(catalogue('reusable'), app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }))).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The hand-written map of an app's Storybook, held against the Storybook it
|
|
4
|
+
* describes (moved here from Peek's own status checks, audit A3).
|
|
5
|
+
*
|
|
6
|
+
* An app may keep two maps of its sidebar: the order of its top-level headings
|
|
7
|
+
* (`const HEADINGS = [...]` in `.storybook/preview.tsx`, read by `storySort`),
|
|
8
|
+
* and the Introduction page (`src/stories/Introduction.mdx`), which names each
|
|
9
|
+
* heading in a table row and states how many parts are worth reusing. Both are
|
|
10
|
+
* written by hand and both describe something that moves, so both rot: Peek's
|
|
11
|
+
* named a Huddles heading for a day after its stories were deleted, and said 49
|
|
12
|
+
* reusable parts when the catalogue built 41. Its own checks saw it, but they
|
|
13
|
+
* ran only in `gates:status`, never in CI.
|
|
14
|
+
*
|
|
15
|
+
* Every check here runs only when the app states the thing it checks: an app
|
|
16
|
+
* with no `HEADINGS` list, or no Introduction, has nothing to rot.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import type { Registry } from './schema'
|
|
21
|
+
|
|
22
|
+
const PREVIEW = '.storybook/preview.tsx'
|
|
23
|
+
const INTRODUCTION = 'src/stories/Introduction.mdx'
|
|
24
|
+
/** The classes an app's Introduction counts as "worth reusing". */
|
|
25
|
+
const WORTH_REUSING = ['reusable', 'promote-candidate']
|
|
26
|
+
|
|
27
|
+
function storyFiles(root: string): string[] {
|
|
28
|
+
const out: string[] = []
|
|
29
|
+
const walk = (dir: string) => {
|
|
30
|
+
if (!existsSync(dir)) return
|
|
31
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
33
|
+
const path = join(dir, entry.name)
|
|
34
|
+
if (entry.isDirectory()) walk(path)
|
|
35
|
+
else if (entry.name.endsWith('.stories.tsx') || entry.name.endsWith('.mdx')) out.push(path)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
walk(join(root, 'src'))
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every top-level heading the sidebar has: the first segment of each story file's or page's title. */
|
|
43
|
+
export function sidebarHeadings(root: string): Set<string> {
|
|
44
|
+
const real = new Set<string>()
|
|
45
|
+
for (const file of storyFiles(root)) {
|
|
46
|
+
const text = readFileSync(file, 'utf8')
|
|
47
|
+
const start = text.indexOf('const meta')
|
|
48
|
+
const meta = start === -1 ? '' : text.slice(start, text.indexOf('satisfies Meta', start) + 1 || undefined)
|
|
49
|
+
const title = meta.match(/\btitle: *['"]([^'"]+)['"]/) ?? text.match(/<Meta\s+title=['"]([^'"]+)['"]/)
|
|
50
|
+
if (title) real.add(title[1].split('/')[0].trim())
|
|
51
|
+
}
|
|
52
|
+
return real
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The headings `preview.tsx` orders, or null when it keeps no list. */
|
|
56
|
+
export function orderedHeadings(previewText: string): string[] | null {
|
|
57
|
+
const block = previewText.match(/const HEADINGS = \[([\s\S]*?)\]/)
|
|
58
|
+
if (!block) return null
|
|
59
|
+
// Comments first: the list is commented per heading, and an apostrophe in a
|
|
60
|
+
// comment would open a string that swallows the rest.
|
|
61
|
+
const listed = block[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
62
|
+
return [...listed.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The headings the Introduction names, one per table row that opens with a bold name. */
|
|
66
|
+
export function introductionHeadings(introText: string): Set<string> {
|
|
67
|
+
return new Set([...introText.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gm)].map((m) => m[1].trim()))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The number the Introduction states, or null when it states none. */
|
|
71
|
+
export function introductionCount(introText: string): number | null {
|
|
72
|
+
const said = introText.match(/\*\*(\d+) parts worth reusing\*\*/)
|
|
73
|
+
return said ? Number(said[1]) : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What is wrong with the app's hand-written map of its Storybook; empty when nothing is. */
|
|
77
|
+
export function storyMapProblems(registry: Registry, root: string): string[] {
|
|
78
|
+
const problems: string[] = []
|
|
79
|
+
const real = sidebarHeadings(root)
|
|
80
|
+
const previewPath = join(root, PREVIEW)
|
|
81
|
+
if (existsSync(previewPath)) {
|
|
82
|
+
const ordered = orderedHeadings(readFileSync(previewPath, 'utf8'))
|
|
83
|
+
if (ordered) {
|
|
84
|
+
const unordered = [...real].filter((r) => !ordered.includes(r))
|
|
85
|
+
const empty = ordered.filter((o) => !real.has(o))
|
|
86
|
+
if (unordered.length) problems.push(`${PREVIEW}: not named in HEADINGS, so they fall to the bottom of the sidebar: ${unordered.join(', ')}`)
|
|
87
|
+
if (empty.length) problems.push(`${PREVIEW}: named in HEADINGS but no story uses them: ${empty.join(', ')}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const introPath = join(root, INTRODUCTION)
|
|
91
|
+
if (existsSync(introPath)) {
|
|
92
|
+
const intro = readFileSync(introPath, 'utf8')
|
|
93
|
+
const named = introductionHeadings(intro)
|
|
94
|
+
if (named.size) {
|
|
95
|
+
const missing = [...real].filter((r) => !named.has(r))
|
|
96
|
+
const stale = [...named].filter((n) => !real.has(n))
|
|
97
|
+
if (missing.length) problems.push(`${INTRODUCTION}: the sidebar has, the page does not name: ${missing.join(', ')}`)
|
|
98
|
+
if (stale.length) problems.push(`${INTRODUCTION}: the page names, the sidebar does not have: ${stale.join(', ')}`)
|
|
99
|
+
}
|
|
100
|
+
const said = introductionCount(intro)
|
|
101
|
+
if (said !== null) {
|
|
102
|
+
const reusable = registry.entries.filter((entry) => WORTH_REUSING.includes(entry.app?.class ?? '')).length
|
|
103
|
+
if (said !== reusable) problems.push(`${INTRODUCTION}: says ${said} parts worth reusing; the catalogue builds ${reusable}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return problems
|
|
107
|
+
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../src/gates/create-app.ts"],
|
|
4
|
-
"sourcesContent": ["/// <reference types=\"node\" />\n/**\n * `create-estiva-app <name>` \u2014 a new Estiva app that runs, with every gate on\n * (UIG-10).\n *\n * npx -p @estiva-app/ui create-estiva-app leaf [--title Leaf] [--theme light]\n *\n * The command is `create-app-cli.ts`; this file is what it runs, and what a test reads.\n *\n * **It reads only this package** (Katerina's ruling of 17 September, docs/GATES.md\n * \u00A723). Never Peek, never Ship: they are private, and reading them would carry\n * their history into every new app. What a new app needs to run a gate is this\n * package's own \u2014 the rules, the token and gate configs, the hook, the count,\n * gates:status and the checks every app runs \u2014 so the app gets short files that\n * import them, and a gate added later arrives with a version bump.\n *\n * What only a new app has is written here: its first page in the sidebar frame\n * (ruled the same day), its one theme, sign-in the way Ship signs in, its one\n * relay connection the way Peek and Ship hold theirs, its tests, its stories,\n * the CI workflow with the job `gate`, and its README and CLAUDE.md.\n *\n * The relay (UIG-10, reopened 18 September): a made app gets `protocol`,\n * `platform` and `interop` and is connected from its first commit, or runs alone\n * when no relay is set. The wiring is the app's own file, using `platform` as it\n * is \u2014 not a helper in `platform` \u2014 because a package takes code a real app has\n * already run (ADR 0002 \u00A710), and Peek and Ship each hold theirs the same way.\n *\n * Tool versions are this package's own (its devDependencies build and test the\n * same tools). The ones it does not use itself ({@link ASKED_OF_NPM}) are asked of\n * the npm registry when the app is made. The lockfile is the app's first\n * `npm install`; make it on Linux.\n */\nimport { execFileSync } from 'node:child_process'\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { dirname, join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { APP_RULE_IDS } from '../eslint/index'\nimport { loaderText, SEARCH_RULES } from '../registry/skill'\n\ninterface PackageJson {\n version: string\n devDependencies: Record<string, string>\n}\n\nexport interface CreateAppOptions {\n /** The folder and npm name: lowercase letters, digits and dashes. */\n name: string\n /** What the app is called on screen. */\n title?: string\n /** One of this package's themes. */\n theme?: string\n /** The folder the app's folder is made in. */\n parent?: string\n /** The `@estiva-app/ui` dependency, when not this package's own version (a tarball, for a proof). */\n ui?: string\n /** Versions of the packages this package does not itself use; asked of npm when absent. */\n versions?: Record<string, string>\n}\n\nconst here = dirname(fileURLToPath(import.meta.url))\nconst packageRoot = resolve(here, '..', '..')\n\n/** What a made app depends on that this package does not use itself: asked of npm when the app is made. */\nexport const ASKED_OF_NPM = ['@estiva-app/identity', '@estiva-app/interop', '@estiva-app/platform', '@estiva-app/protocol', 'eslint-plugin-react-hooks']\n\n/** The package's themes, read from its tokens.css: `light` is the block with no attribute. */\nexport function themes(): string[] {\n const css = readFileSync(join(packageRoot, 'tokens.css'), 'utf8')\n return ['light', ...new Set([...css.matchAll(/data-theme='([\\w-]+)'/g)].map((m) => m[1]))]\n}\n\n/** Every file a new app starts with, by path. Nothing is written. */\nexport function appFiles({ name, title = name, theme = 'light', ui, versions = {} }: CreateAppOptions): Record<string, string> {\n 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`)\n const known = themes()\n if (!known.includes(theme)) throw new Error(`\"${theme}\" is not one of the package's themes: ${known.join(', ')}`)\n\n const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson\n const own = (dep: string) => {\n const range = pkg.devDependencies[dep] ?? versions[dep]\n if (!range) throw new Error(`no version for ${dep}`)\n return range\n }\n const deps = (names: string[]) => Object.fromEntries(names.map((n) => [n, own(n)]))\n\n const packageJson = {\n name,\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: 'tsc -b && vite build',\n preview: 'vite preview',\n typecheck: 'tsc -b',\n lint: 'eslint .',\n 'lint:tokens': 'eslint --config eslint.tokens.config.js .',\n 'lint:rules': 'eslint --config eslint.gates.config.js .',\n 'postlint:rules': `estiva-gates count --repo ${name}`,\n 'gates:status': 'estiva-gates status',\n 'ui:find': 'estiva-ui find',\n registry: 'estiva-ui build',\n 'registry:check': 'estiva-ui check',\n test: 'vitest run',\n storybook: 'storybook dev -p 6006',\n 'build-storybook': 'storybook build',\n },\n dependencies: {\n '@estiva-app/identity': own('@estiva-app/identity'),\n '@estiva-app/interop': own('@estiva-app/interop'),\n '@estiva-app/platform': own('@estiva-app/platform'),\n '@estiva-app/protocol': own('@estiva-app/protocol'),\n '@estiva-app/ui': ui ?? `^${pkg.version}`,\n ...deps(['@tabler/icons-react', 'react', 'react-dom']),\n },\n devDependencies: deps([\n '@eslint/js', '@storybook/addon-docs', '@storybook/react-vite', '@testing-library/dom', '@testing-library/react',\n '@types/node', '@types/react', '@types/react-dom', '@vitejs/plugin-react', 'autoprefixer', 'eslint',\n 'eslint-plugin-better-tailwindcss', 'eslint-plugin-react-hooks', 'globals', 'jsdom', 'postcss', 'storybook',\n 'tailwindcss', 'typescript', 'typescript-eslint', 'vite', 'vitest',\n ]),\n }\n\n 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 }])) }\n const themeAttr = ` data-theme=\"${theme}\"`\n const tsBase = {\n target: 'ES2023',\n module: 'ESNext',\n skipLibCheck: true,\n moduleResolution: 'bundler',\n allowImportingTsExtensions: true,\n verbatimModuleSyntax: true,\n moduleDetection: 'force',\n noEmit: true,\n strict: true,\n noUnusedLocals: false,\n noUnusedParameters: false,\n erasableSyntaxOnly: true,\n noFallthroughCasesInSwitch: true,\n noUncheckedSideEffectImports: true,\n }\n const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\\n`\n\n return {\n 'package.json': json(packageJson),\n\n // registry.json: the app's catalogue, written only when `npm run registry` is asked\n // to. It is built fresh from the code every time it is read, so it is never kept.\n '.gitignore': ['node_modules', 'dist', 'storybook-static', '*.local', '*.log', '*.tsbuildinfo', '.DS_Store', 'registry.json', ''].join('\\n'),\n\n '.env.example': `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.\n#\n# Left empty, the app offers no sign-in at all and runs anonymous: it never\n# reaches the real Estiva ID by accident.\n#\n# The app must be registered with that Estiva ID first, as its own app, with\n# this redirect address: the origin the app runs on, and a slash\n# (http://localhost:5173/). The real Estiva ID registers a new app on the\n# server; a local one (http://localhost:8787) in its database.\nVITE_ESTIVA_ID_ORIGIN=\nVITE_ESTIVA_ID_CLIENT_ID=${name}\n\n# The relay: the workspace this app reads and writes.\n#\n# Left empty, the app runs alone. It opens no connection, and its home page says\n# so. It also needs sign-in above: the relay refuses anything before sign-in, so\n# with a relay and no sign-in there is still no connection. And Estiva ID must\n# allow this app to sign in to that relay (the README says how).\n#\n# A relay running on this machine is http://localhost:3000.\nVITE_RELAY_URL=\n`,\n\n 'index.html': `<!doctype html>\n<html lang=\"en\"${themeAttr}>\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>${title}</title>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n`,\n\n 'tsconfig.json': json({ files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }),\n 'tsconfig.app.json': json({\n compilerOptions: { tsBuildInfoFile: './node_modules/.tmp/tsconfig.app.tsbuildinfo', ...tsBase, useDefineForClassFields: true, lib: ['ES2023', 'DOM', 'DOM.Iterable'], types: ['vite/client'], jsx: 'react-jsx', paths: { '@/*': ['./src/*'] } },\n include: ['src', '.storybook'],\n }),\n 'tsconfig.node.json': json({\n compilerOptions: { tsBuildInfoFile: './node_modules/.tmp/tsconfig.node.tsbuildinfo', ...tsBase, lib: ['ES2023'], types: ['node'] },\n include: ['vite.config.ts'],\n }),\n\n 'vite.config.ts': `import { fileURLToPath } from 'node:url'\nimport react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vitest/config'\n\n// \\`react\\` is deduped because @estiva-app/ui declares it a peer: two copies in one\n// tree is the \"invalid hook call\" crash, and it appears at runtime.\nexport default defineConfig({\n plugins: [react()],\n resolve: {\n alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },\n dedupe: ['react', 'react-dom'],\n },\n test: {\n environment: 'jsdom',\n },\n})\n`,\n\n 'tailwind.config.js': `import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'\n\n/**\n * The package's preset, and nothing of the app's own: a token is added to the\n * package, in every theme, never here.\n *\n * \\`estivaContent\\` is the package's own files. Tailwind does not merge \\`content\\`\n * from a preset, so without it every class used only by a package part is\n * purged, and the app builds clean while rendering at the wrong size.\n *\n * @type {import('tailwindcss').Config}\n */\nexport default {\n presets: [estiva],\n content: [...estivaContent, './index.html', './src/**/*.{ts,tsx}', './.storybook/**/*.{ts,tsx}'],\n}\n`,\n 'postcss.config.js': `export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n}\n`,\n\n 'eslint.config.js': `import js from '@eslint/js'\nimport { gateLint, TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport { defineConfig, globalIgnores } from 'eslint/config'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\n\n/**\n * Everything, in one lint (\\`npm run lint\\`, a CI step): TypeScript's and React's\n * recommended rules, the token contract and the UI Guardrails' rules. A new app\n * has no backlog, so all of it is a gate from the first commit.\n *\n * The token contract and the gate are the package's (\\`@estiva-app/ui/gates\\`),\n * imported, never copied: a rule written later arrives with a version bump.\n */\nexport default defineConfig([\n globalIgnores(TOKEN_LINT_IGNORES),\n {\n files: ['**/*.{ts,tsx}'],\n extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],\n languageOptions: { globals: globals.browser },\n },\n tokenLint(),\n tokenValues(),\n gateLint(),\n])\n`,\n 'eslint.tokens.config.js': `import { tokenConfig } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\n\n// The token contract on its own (\\`npm run lint:tokens\\`), the package's piece.\n// React's hooks plugin is named so its directives do not break it; TypeScript's\n// the package names itself.\nexport default tokenConfig({ quiet: { 'react-hooks': reactHooks } })\n`,\n 'eslint.gates.config.js': `import { gateConfig } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\n\n/**\n * The UI Guardrails' rules on their own \u2014 \\`npm run lint:rules\\`, CI's job \\`gate\\`\n * (GitHub requires it on main: never rename it), and what the editor hook in\n * \\`.claude/settings.json\\` lints a proposed write with. The package's gate,\n * imported. React's hooks plugin is named so its directives do not break it;\n * TypeScript's the package names itself.\n *\n * A place that keeps something the gate refuses says why, on the line above:\n * \\`// @estiva-escape: <reason>\\`. Never \\`eslint-disable\\`: the count refuses it.\n */\nexport default gateConfig({ quiet: { 'react-hooks': reactHooks } })\n`,\n\n '.claude/settings.json': json({\n hooks: {\n PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node \"$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js\" hook' }] }],\n },\n permissions: { allow: SEARCH_RULES },\n }),\n\n // The Claude skill (UIG-20): its trigger, and a line reading the installed package's text in.\n '.claude/skills/estiva-ui/SKILL.md': loaderText(resolve('/app'), resolve('/app/node_modules/@estiva-app/ui')),\n\n 'scripts/gates-checks.mjs': `import { appChecks } from '@estiva-app/ui/gates'\n\n/**\n * What gates:status checks in ${name}: the gate checks every app runs, from the\n * package (\\`appChecks\\`), and nothing of its own yet. A check about this app's\n * own code goes beside them.\n */\nexport default function define(h) {\n return { repo: '${name}', tickets: appChecks(h, { page: 'src/pages/HomePage.tsx' }) }\n}\n`,\n '.gates-count.json': json(count),\n 'docs/GATES-DEBT.md': `# What ${title} owes the gates\n\nNothing. ${title} was made with every gate on, at zero.\n\nIt should stay that way. A place that keeps something the gate refuses says why on\nthe line above it, \\`// @estiva-escape: <reason>\\`, and the count lists it; a whole\nfile that cannot pass yet goes here, with its reason.\n`,\n\n '.github/workflows/deploy.yml': `name: deploy\n\n# Every pull request and every push to main runs the checks. There is no deploy\n# job yet: a new app has nowhere to go until it has a home. When it does, the job\n# goes here, and it needs \\`check\\` and \\`gate\\` first.\n\non:\n push:\n branches: [main]\n pull_request:\n\nconcurrency:\n group: deploy-${name}-\\${{ github.ref }}\n cancel-in-progress: true\n\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Typecheck the app\n run: npx tsc -b tsconfig.app.json\n - name: Typecheck the settings files\n run: npx tsc -b tsconfig.node.json\n - name: Lint \u2014 everything, the token contract included\n run: npm run lint\n - name: Test\n run: npm test\n - name: Build\n run: npm run build\n # A class only a package part uses reaches the stylesheet only if\n # tailwind.config.js spreads estivaContent. When it does not, the build still\n # succeeds and parts render at the wrong size, so this is where it is caught.\n - name: The package's classes survive Tailwind's purge\n run: |\n if ! grep -qr \"max-h-72\" dist/assets/*.css; then\n 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\"\n exit 1\n fi\n\n # The UI Guardrails' rules, on their own: a raw element, behaviour a part owns\n # written by hand, a part restyled. \\`npm run lint:rules\\` also writes\n # .gates-count.json and fails on an eslint-disable that switches a rule off.\n #\n # A job of its own, because GitHub can require only a whole job, by its name:\n # the rule on main requires \\`gate\\`. Renaming it leaves every pull request\n # waiting for a check that never reports.\n gate:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Gate lint\n run: npm run lint:rules\n # Every part of the app says in one line what it is for, so the catalogue\n # (\\`npm run ui:find\\`) can offer it before someone builds it again.\n - name: Every part is described\n run: npm run registry:check\n`,\n\n 'src/index.css': `@import '@estiva-app/ui/tokens.css';\n@import '@estiva-app/ui/base.css';\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml,\nbody,\n#root {\n height: 100%;\n}\n`,\n 'src/vite-env.d.ts': `/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n readonly VITE_ESTIVA_ID_ORIGIN?: string\n readonly VITE_ESTIVA_ID_CLIENT_ID?: string\n readonly VITE_RELAY_URL?: string\n}\n`,\n 'src/config.ts': `/** What the app is called on screen. */\nexport const APP_TITLE = ${JSON.stringify(title)}\n\n/**\n * Estiva ID, or \\`null\\` when this build offers no sign-in at all. Empty is a real\n * mode, not a broken one: the app runs anonymous, and a local build can never\n * reach the real Estiva ID by accident. See .env.example.\n */\nexport const ID_CONFIG: { base: string; clientId: string } | null =\n import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID\n ? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }\n : null\n\n/**\n * The relay, the workspace this app reads and writes, or \\`null\\` when this build\n * has none. Empty is a real mode too: the app runs alone and opens no connection,\n * and a local build can never reach the real relay by accident. See .env.example.\n */\nexport const RELAY_URL: string | null = import.meta.env.VITE_RELAY_URL?.trim().replace(/\\\\/+$/, '') || null\n\n/** The relay as a person would name it: host and port, no scheme. */\nexport function relayLabel(url: string): string {\n try {\n return new URL(url).host\n } catch {\n return url\n }\n}\n`,\n 'src/auth/estivaId.ts': `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'\nimport { ID_CONFIG } from '../config'\n\n/**\n * Signing in with Estiva ID, the way Ship does: \\`@estiva-app/identity\\` holds the\n * flow, this file the app's choices.\n *\n * - The redirect is the origin and a slash, never the current path: Estiva ID\n * compares it exactly against the one address registered for the app.\n * - The session lives in localStorage, so it outlives a tab; a sign-in's\n * single-use credentials in sessionStorage, per tab.\n */\nconst client = ID_CONFIG\n ? createEstivaId({\n base: ID_CONFIG.base,\n clientId: ID_CONFIG.clientId,\n redirectUri: () => \\`\\${window.location.origin}/\\`,\n storage: () => (typeof localStorage === 'undefined' ? null : localStorage),\n pendingStore: () => (typeof sessionStorage === 'undefined' ? null : sessionStorage),\n keyPrefix: '${name}.estiva-id',\n navigate: (url) => window.location.assign(url),\n })\n : null\n\nexport const signInAvailable = client !== null\n\nexport function currentToken(): StoredToken | null {\n return client?.validToken(Date.now() + 60_000) ?? null\n}\n\nexport function startRenewal(): void {\n client?.scheduleRenewal()\n}\n\nexport async function beginSignIn(options: { silent?: boolean } = {}): Promise<void> {\n await client?.beginSignIn(\\`\\${window.location.pathname}\\${window.location.search}\\`, options)\n}\n\nexport function beginSignOut(): void {\n client?.beginSignOut()\n}\n\nexport async function completeSignIn(): Promise<{ token: StoredToken; returnTo: string }> {\n if (!client) throw new Error('this build offers no sign-in')\n return client.completeSignIn(window.location.search)\n}\n\nexport function stashShellReason(reason: ShellReason): void {\n client?.stashShellReason(reason)\n}\n\nexport function takeShellReason() {\n return client?.takeShellReason() ?? null\n}\n\nexport function callbackParams(): { code?: string; error?: string } | null {\n const params = new URLSearchParams(window.location.search)\n const code = params.get('code') ?? undefined\n const error = params.get('error') ?? undefined\n return code || error ? { code, error } : null\n}\n\nexport function clearQuery(): void {\n window.history.replaceState({}, '', window.location.pathname)\n}\n\nexport function guardAttemptedAt(): number | null {\n if (typeof sessionStorage === 'undefined') return null\n const raw = sessionStorage.getItem('estiva.authShell.silentAttempt')\n const at = raw ? Number(raw) : NaN\n return Number.isFinite(at) ? at : null\n}\n\n/**\n * Who Estiva ID says this is. Asked of its directory with the token, so the\n * answer also proves Estiva ID accepted the sign-in.\n */\nexport async function whoAmI(): Promise<{ name?: string; email?: string } | null> {\n const token = currentToken()\n if (!ID_CONFIG || !token) return null\n const response = await fetch(\\`\\${ID_CONFIG.base}/directory/\\${token.pubkey}\\`, { headers: { Authorization: \\`Bearer \\${token.accessToken}\\` } })\n if (!response.ok) return null\n const entry = (await response.json()) as { displayName?: string; email?: string }\n return { name: entry.displayName, email: entry.email }\n}\n`,\n 'src/auth/boot.ts': `import { decideBoot, type ShellReason, type ShellState } from '@estiva-app/identity'\nimport {\n beginSignIn,\n callbackParams,\n clearQuery,\n completeSignIn,\n currentToken,\n guardAttemptedAt,\n signInAvailable,\n startRenewal,\n stashShellReason,\n takeShellReason,\n} from './estivaId'\n\nexport type BootResult = { kind: 'app' } | { kind: 'shell'; state: ShellState } | { kind: 'leaving' }\n\n/**\n * Settle who this load belongs to, before the app renders. The decision is\n * \\`decideBoot\\` from \\`@estiva-app/identity\\`; this is the reading and acting\n * around it. It never throws: a blank page is worse than an honest shell.\n */\nexport async function bootAuth(): Promise<BootResult> {\n if (!signInAvailable) return { kind: 'app' }\n\n const action = decideBoot({\n callback: callbackParams(),\n hasValidToken: currentToken() !== null,\n guardAttemptedAt: guardAttemptedAt(),\n enteredThisPageLoad: false,\n now: Date.now(),\n })\n\n switch (action.do) {\n case 'enter':\n startRenewal()\n return { kind: 'app' }\n case 'complete_callback':\n try {\n await completeSignIn()\n clearQuery()\n startRenewal()\n return { kind: 'app' }\n } catch (cause) {\n clearQuery()\n stashShellReason('exchange_failed')\n return shell('exchange_failed', cause)\n }\n case 'probe_silently':\n try {\n await beginSignIn({ silent: true })\n return { kind: 'leaving' }\n } catch {\n return shell('network')\n }\n case 'prompt_passkey':\n return { kind: 'shell', state: { phase: 'authenticating', reason: takeShellReason() ?? action.reason } }\n case 'fail':\n return shell(action.reason)\n }\n}\n\nfunction shell(reason: ShellReason, cause?: unknown): BootResult {\n if (cause) console.warn('[auth] entry failed:', cause)\n return { kind: 'shell', state: { phase: 'failed', reason } }\n}\n\nexport async function enterFromShell(): Promise<void> {\n await beginSignIn()\n}\n`,\n 'src/auth/AuthShell.tsx': `import { CONTINUE_LABEL, isRecoverable, RETRY_LABEL, SHELL_COPY, type ShellState } from '@estiva-app/identity'\nimport { Button } from '@estiva-app/ui'\nimport { APP_TITLE } from '../config'\n\nexport interface AuthShellProps {\n state: ShellState\n onContinue: () => void\n}\n\n/**\n * What shows while nobody is signed in: the app's name, one line and at most one\n * button. The words are \\`@estiva-app/identity\\`'s, so every app says the same.\n */\nexport function AuthShell({ state, onContinue }: AuthShellProps) {\n const waiting = state.phase === 'checking' || state.phase === 'entering' || state.phase === 'ready'\n return (\n <div className=\"fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-bg-surface px-6 text-center\">\n <h1 className=\"text-h3 text-text-primary\">{APP_TITLE}</h1>\n {!waiting && (\n <>\n <p className=\"max-w-prose text-body-2 text-text-secondary\">{SHELL_COPY[state.reason]}</p>\n {isRecoverable(state.reason) && <Button onClick={onContinue}>{state.phase === 'authenticating' ? CONTINUE_LABEL : RETRY_LABEL}</Button>}\n </>\n )}\n </div>\n )\n}\n`,\n 'src/relay/client.ts': `import { signViaEstivaId } from '@estiva-app/identity'\nimport { browserOnlineSource, createLiveClientHolder, type LiveClient } from '@estiva-app/platform'\nimport { currentToken } from '../auth/estivaId'\nimport { ID_CONFIG, RELAY_URL } from '../config'\n\n/**\n * The event kinds this app reads. They are the app's own decision, and the one\n * blank a new app fills in: Ship reads seven (projects, issues, changes,\n * messages, comments, deletions and edits), Peek its own. Until there are kinds\n * here the app is connected and reads nothing, which is a correct state.\n */\nexport const KINDS: number[] = []\n\n/**\n * The tab's one relay client. \\`@estiva-app/platform\\` hands out a holder and\n * keeps nothing, so this line is where \"one connection per tab\" lives. The relay\n * signs a connection in once and caps subscriptions per connection, so a\n * connection per component is wrong, not only wasteful; React's StrictMode,\n * which mounts everything twice, is what would show it.\n *\n * Peek and Ship hold theirs the same way. Everything the client needs is handed\n * in and nothing here reads the app's own data, so it could move into\n * \\`@estiva-app/platform\\` as it is, if that is ever worth doing.\n */\nconst holder = createLiveClientHolder()\n\n/**\n * The client, or \\`null\\` when this build cannot have one: no relay is set, or\n * there is no sign-in. The relay refuses anything before sign-in, so a socket\n * without it would only connect, be refused and retry.\n */\nexport function relayClient(): LiveClient | null {\n const base = ID_CONFIG?.base\n if (!RELAY_URL || !base) return null\n return holder.get({\n relayUrl: RELAY_URL,\n // Read on every connect, never kept: a token kept from the first connect\n // outlives a silent renewal as a dead one, and the next reconnect fails.\n getCredential: () => {\n const token = currentToken()\n return token ? { accessToken: token.accessToken, pubkey: token.pubkey } : null\n },\n // \\`expectedPubkey\\` is a check, not a request. Estiva ID signs as the token's\n // owner whatever it is handed, so a mismatch comes back as a valid event\n // signed by somebody else. Always pass it.\n sign: (unsigned, token, expectedPubkey) => signViaEstivaId(unsigned, { base, token, expectedPubkey }),\n online: typeof window === 'undefined' ? undefined : browserOnlineSource(window),\n // Names this app in the relay's own logs.\n subscriptionPrefix: '${name}-',\n log: (message, detail) => console.debug('[relay]', message, detail ?? ''),\n })\n}\n`,\n 'src/relay/useRelayState.ts': `import type { RelayState } from '@estiva-app/protocol'\nimport { useSyncExternalStore } from 'react'\nimport { relayClient } from './client'\n\nconst subscribe = (onChange: () => void): (() => void) => relayClient()?.onState(onChange) ?? (() => {})\nconst snapshot = (): RelayState | 'off' => relayClient()?.state() ?? 'off'\n\n/** The connection's state, for a page to show: \\`'off'\\` when this build has no client. */\nexport function useRelayState(): RelayState | 'off' {\n return useSyncExternalStore(subscribe, snapshot, () => 'off')\n}\n`,\n 'src/relay/client.test.ts': `import { afterEach, describe, expect, it, vi } from 'vitest'\n\n/**\n * The tab's one relay connection. A fake socket stands in for the relay, so this\n * runs with no network, and counts the sockets opened.\n */\nclass FakeSocket {\n static opened = 0\n onopen: (() => void) | null = null\n onmessage: ((event: { data: unknown }) => void) | null = null\n onclose: (() => void) | null = null\n onerror: (() => void) | null = null\n constructor() {\n FakeSocket.opened += 1\n }\n send() {}\n close() {}\n}\n\nafterEach(() => {\n vi.unstubAllEnvs()\n vi.unstubAllGlobals()\n vi.resetModules()\n FakeSocket.opened = 0\n})\n\ndescribe('the relay client', () => {\n it('opens no connection when no relay is set', async () => {\n vi.stubEnv('VITE_RELAY_URL', '')\n vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')\n vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')\n vi.stubGlobal('WebSocket', FakeSocket)\n const { relayClient } = await import('./client')\n expect(relayClient()).toBeNull()\n expect(FakeSocket.opened).toBe(0)\n })\n\n it('opens no connection with a relay and no sign-in', async () => {\n vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')\n vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', '')\n vi.stubGlobal('WebSocket', FakeSocket)\n const { relayClient } = await import('./client')\n expect(relayClient()).toBeNull()\n expect(FakeSocket.opened).toBe(0)\n })\n\n it('holds one connection per tab, however often it is asked', async () => {\n vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')\n vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')\n vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')\n vi.stubGlobal('WebSocket', FakeSocket)\n const { relayClient } = await import('./client')\n const first = relayClient()\n expect(first).not.toBeNull()\n expect(relayClient()).toBe(first)\n expect(FakeSocket.opened).toBe(1)\n first?.close()\n })\n})\n`,\n 'src/main.tsx': `import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport { AuthShell } from './auth/AuthShell'\nimport { bootAuth, enterFromShell } from './auth/boot'\n\n// Settle who this load belongs to first, then render: the app itself is imported\n// only once sign-in is settled, so nothing in it evaluates as the wrong person.\nvoid (async () => {\n const root = createRoot(document.getElementById('root')!)\n const boot = await bootAuth()\n if (boot.kind === 'leaving') return\n if (boot.kind === 'shell') {\n root.render(\n <StrictMode>\n <AuthShell state={boot.state} onContinue={() => void enterFromShell()} />\n </StrictMode>,\n )\n return\n }\n const { App } = await import('./App')\n root.render(\n <StrictMode>\n <App />\n </StrictMode>,\n )\n})()\n`,\n 'src/App.tsx': `import { AppShell, IdentityMenu, NavItem, Sidebar, type Identity } from '@estiva-app/ui'\nimport { IconHome } from '@tabler/icons-react'\nimport { useEffect, useState } from 'react'\nimport { beginSignOut, currentToken, whoAmI } from './auth/estivaId'\nimport { APP_TITLE, ID_CONFIG, RELAY_URL } from './config'\nimport { HomePage } from './pages/HomePage'\nimport { useRelayState } from './relay/useRelayState'\n\n/** The frame: the package's AppShell with a sidebar, and the one page. */\nexport function App() {\n const signedIn = currentToken() !== null\n const [me, setMe] = useState<Identity>({})\n const relayState = useRelayState()\n\n useEffect(() => {\n if (!signedIn) return\n let live = true\n void whoAmI().then((who) => {\n if (live && who) setMe(who)\n })\n return () => {\n live = false\n }\n }, [signedIn])\n\n return (\n <AppShell\n logo={APP_TITLE}\n identity={<IdentityMenu me={me} signedIn={signedIn} idBase={ID_CONFIG?.base} onSignOut={signedIn ? beginSignOut : undefined} />}\n nav={\n <Sidebar>\n <NavItem href=\"/\" label=\"Home\" icon={<IconHome size={16} stroke={1.5} />} active />\n </Sidebar>\n }\n >\n <HomePage relay={RELAY_URL} state={relayState} name={me.name} />\n </AppShell>\n )\n}\n`,\n 'src/pages/HomePage.tsx': `import type { RelayState } from '@estiva-app/protocol'\nimport { EmptyState } from '@estiva-app/ui'\nimport { relayLabel } from '../config'\n\nexport interface HomePageProps {\n /** The relay this build is set to, or \\`null\\` when the app runs alone. */\n relay: string | null\n /** The connection's state: \\`'off'\\` when this build has no client. */\n state: RelayState | 'off'\n /** The signed-in person's name, once Estiva ID has said it. */\n name?: string\n}\n\nconst SAYS: Record<RelayState, (host: string, name?: string) => string> = {\n connecting: (host) => \\`Connecting to \\${host}\u2026\\`,\n authenticating: (host) => \\`Signing in to \\${host}\u2026\\`,\n live: (host, name) => (name ? \\`Connected to \\${host} as \\${name}.\\` : \\`Connected to \\${host}.\\`),\n reconnecting: (host) => \\`Reconnecting to \\${host}\u2026\\`,\n failed: (host) => \\`Could not connect to \\${host}.\\`,\n}\n\n/**\n * The first page. What it becomes is this app's own work; until then it says\n * whether the app is connected to the relay, and as whom.\n *\n * The empty state goes straight into the frame's \\`main\\`, with no box around it:\n * \\`main\\` is a flex column, so the empty state takes the room left and centres\n * in it both ways, as its own page says. A box around it would place it\n * instead, and no gate reads a box. The connection is a quiet caption after it,\n * at the foot of the page.\n */\nexport function HomePage({ relay, state, name }: HomePageProps) {\n const line = !relay\n ? 'Running alone: no relay is set. Set VITE_RELAY_URL in .env.local to connect.'\n : state === 'off'\n ? \\`Not connected: \\${relayLabel(relay)} needs sign-in, and this build has none.\\`\n : SAYS[state](relayLabel(relay), name)\n return (\n <>\n <EmptyState message=\"Nothing here yet.\" />\n <p className=\"px-6 pb-6 text-center text-caption text-text-muted\">{line}</p>\n </>\n )\n}\n`,\n 'src/pages/HomePage.stories.tsx': `import type { Meta, StoryObj } from '@storybook/react-vite'\nimport { HomePage } from './HomePage'\n\nconst meta = {\n title: 'Pages/Home',\n component: HomePage,\n parameters: { layout: 'fullscreen' },\n // Stands in for the frame's main: a flex column the height of the screen,\n // which is what the page is drawn into in the app.\n decorators: [\n (Story) => (\n <div className=\"flex h-screen flex-col\">\n <Story />\n </div>\n ),\n ],\n} satisfies Meta<typeof HomePage>\n\nexport default meta\ntype Story = StoryObj<typeof meta>\n\n/** No relay set: the app runs alone, the way it starts. */\nexport const RunningAlone: Story = { args: { relay: null, state: 'off' } }\n\n/** A relay set, before it has signed the connection in. */\nexport const Connecting: Story = { args: { relay: 'http://localhost:3000', state: 'connecting' } }\n\n/** Connected, as the person who signed in. */\nexport const Connected: Story = { args: { relay: 'http://localhost:3000', state: 'live', name: 'Alex Kim' } }\n`,\n 'src/App.test.tsx': `import { render, screen } from '@testing-library/react'\nimport { describe, expect, it } from 'vitest'\nimport { App } from './App'\nimport { APP_TITLE } from './config'\n\ndescribe('${title}', () => {\n it('opens in its frame, anonymous in a build with no sign-in', () => {\n render(<App />)\n expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)\n expect(screen.getByText('Nothing here yet.')).toBeTruthy()\n expect(screen.getByText(/^Running alone/)).toBeTruthy()\n expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()\n })\n})\n`,\n\n '.storybook/main.ts': `import type { StorybookConfig } from '@storybook/react-vite'\n\nconst config: StorybookConfig = {\n framework: '@storybook/react-vite',\n stories: ['../src/**/*.stories.@(ts|tsx)'],\n addons: ['@storybook/addon-docs'],\n}\n\nexport default config\n`,\n '.storybook/preview.tsx': `import type { Preview } from '@storybook/react-vite'\nimport '../src/index.css'\n\n// The app's one theme, selected the way the app selects it: on <html>.\ndocument.documentElement.dataset.theme = ${JSON.stringify(theme)}\n\nconst preview: Preview = {\n parameters: { layout: 'centered' },\n}\n\nexport default preview\n`,\n\n 'README.md': `# ${title}\n\nAn Estiva app, made with \\`create-estiva-app\\` from \\`@estiva-app/ui\\`. It starts\nwith the package's sidebar frame, one theme (\\`${theme}\\`), sign-in with Estiva ID,\nand every UI Guardrails gate on, at zero.\n\n## Run it\n\n\\`\\`\\`sh\nnpm install\nnpm run dev\n\\`\\`\\`\n\nWith no settings it runs **anonymous**: no sign-in is offered, and nothing reaches\nthe real Estiva ID. To sign in, copy \\`.env.example\\` to \\`.env.local\\` and fill it in.\nThe app must first be registered with that Estiva ID as its own app.\n\n## The relay\n\n${title} is connected to the relay, the workspace, from its first commit, as\nwhoever signed in. \\`src/relay/client.ts\\` holds the tab's one connection: ask it\nfor \\`relayClient()\\`, and never open a socket of your own. The home page shows the\nconnection's state.\n\nWith \\`VITE_RELAY_URL\\` empty it runs alone and opens no connection. The relay\nalso needs sign-in, so with a relay and no sign-in there is no connection either.\n\nThree packages come with it: \\`@estiva-app/protocol\\` (the wire: events, ids,\nsigning, the relay clients), \\`@estiva-app/platform\\` (the one connection a tab\nholds) and \\`@estiva-app/interop\\` (showing another app's objects, from the\nmanifest that app publishes).\n\n## What you fill in\n\n1. \\`.env.local\\`: Estiva ID and the relay (see \\`.env.example\\`).\n2. \\`KINDS\\` in \\`src/relay/client.ts\\`: the event kinds ${title} reads. Until then\n it is connected and reads nothing.\n3. The product: its pages, and the fold that turns what arrives on the\n connection into the app's state.\n\nBefore ${title} can sign in on the real Estiva ID it has to be registered there\nas its own app, with:\n\n- its exact redirect address\n- every event kind it will sign, **including 22242**, the relay's sign-in handshake\n- the relay's address, allowed for that handshake. Estiva ID signs a handshake\n only for a relay both the app and the deployment allow.\n\nAnything left out fails at the very last step: every screen looks right, and\nnothing arrives.\n\n## The checks\n\n| command | what |\n|---|---|\n| \\`npm run typecheck\\` | TypeScript, the app and its settings files |\n| \\`npm run lint\\` | everything: TypeScript's and React's rules, the token contract, the gate |\n| \\`npm run lint:rules\\` | the gate alone, and \\`.gates-count.json\\` \u2014 CI's job \\`gate\\` |\n| \\`npm test\\` | the tests |\n| \\`npm run build\\` | the build |\n| \\`npm run gates:status\\` | which gates are on, read from the code |\n| \\`npm run ui:find <words>\\` | what the package and this app already have for it |\n| \\`npm run registry:check\\` | every part says what it is for \u2014 CI's job \\`gate\\` |\n| \\`npm run storybook\\` | the stories |\n\nThe gates are the package's, imported rather than copied, so a rule written later\narrives with an ordinary version bump. A Claude session started in this folder is\nstopped before it writes code the gate refuses (\\`.claude/settings.json\\`).\n\n## Once it is on GitHub\n\nRequire the check \\`gate\\` before anything merges into \\`main\\`: a ruleset on \\`main\\`\n(Settings \u2192 Rules \u2192 Rulesets) with \"Require status checks to pass\", the check\n\\`gate\\`, and no one allowed to bypass it. Until then \\`npm run gates:status\\` shows\nUIG-6 as not done.\n`,\n 'CLAUDE.md': `# ${title}, for Claude Code\n\n${title} is an Estiva app. Its parts, tokens and gates come from \\`@estiva-app/ui\\`.\n\n**Use the package's parts, never a raw element or a hand-built look.** The gate\nrefuses a raw control, behaviour a part owns written by hand, and a part restyled\nthrough \\`className\\`, and names what to use instead. It runs before you write\n(the hook in \\`.claude/settings.json\\`, for a session started in this folder), in\n\\`npm run lint:rules\\` and in CI. A session started elsewhere: run\n\\`npm run lint:rules\\` after changing \\`src/\\`, and fix what it reports. Keep something\nonly with its reason on the line above, \\`// @estiva-escape: <reason>\\`, never with\n\\`eslint-disable\\`.\n\n**Tokens only.** Colours, type, corners and shadows come from the package's preset.\n\n**One relay connection per tab.** \\`relayClient()\\` in \\`src/relay/client.ts\\` is the\nconnection. Never open a socket and never make a second holder: the relay signs a\nconnection in once, and caps subscriptions per connection.\n\n**Look before you build.** \\`npm run ui:find <what it does>\\` searches the package's\nparts and this app's own. Use what it finds.\n\n**Every part says what it is for.** A new part gets a one-line \\`/** \u2026 */\\` comment\ndirectly above it. \\`npm run registry:check\\` and CI's job \\`gate\\` refuse a part without one.\n\n**The count starts at zero and stays there** (\\`.gates-count.json\\`, \\`docs/GATES-DEBT.md\\`).\n\nWhat each gate is and how it is wired: the package's README,\n\\`node_modules/@estiva-app/ui/README.md\\`.\n`,\n }\n}\n\n/** The versions of what this package does not use itself, from the npm registry. */\nexport function askNpm(names: string[]): Record<string, string> {\n const npm = process.env.npm_execpath\n const out: Record<string, string> = {}\n for (const name of names) {\n const args = ['view', name, 'version']\n const version = (npm ? execFileSync(process.execPath, [npm, ...args]) : execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { shell: process.platform === 'win32' }))\n .toString()\n .trim()\n if (!/^\\d+\\.\\d+\\.\\d+/.test(version)) throw new Error(`npm did not say which version of ${name} is current: \"${version}\"`)\n out[name] = `^${version}`\n }\n return out\n}\n\n/** Write the app. Refuses a folder that already exists. Returns the folder. */\nexport function createApp(options: CreateAppOptions): string {\n const dir = resolve(options.parent ?? process.cwd(), options.name)\n if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`)\n const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson\n const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n])\n const versions = { ...(missing.length ? askNpm(missing) : {}), ...options.versions }\n const files = appFiles({ ...options, versions })\n for (const [rel, text] of Object.entries(files)) {\n const path = join(dir, rel)\n mkdirSync(dirname(path), { recursive: true })\n writeFileSync(path, text)\n }\n return dir\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|