@estiva-app/ui 0.21.1 → 0.23.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 (108) hide show
  1. package/README.md +21 -0
  2. package/dist/Avatar.d.ts +3 -0
  3. package/dist/Avatar.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts +3 -0
  7. package/dist/ChipInput.d.ts.map +1 -1
  8. package/dist/CommandPalette.d.ts +6 -0
  9. package/dist/CommandPalette.d.ts.map +1 -1
  10. package/dist/ContainerHeader.d.ts +25 -0
  11. package/dist/ContainerHeader.d.ts.map +1 -0
  12. package/dist/EmptyState.d.ts +10 -1
  13. package/dist/EmptyState.d.ts.map +1 -1
  14. package/dist/IdentityMenu.d.ts +3 -0
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Menu.d.ts +7 -0
  17. package/dist/Menu.d.ts.map +1 -1
  18. package/dist/Skeleton.d.ts +2 -0
  19. package/dist/Skeleton.d.ts.map +1 -1
  20. package/dist/Toast.d.ts +5 -0
  21. package/dist/Toast.d.ts.map +1 -1
  22. package/dist/Toolbar.d.ts +4 -0
  23. package/dist/Toolbar.d.ts.map +1 -1
  24. package/dist/Tooltip.d.ts +4 -0
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/gates/app-checks.d.ts.map +1 -1
  27. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  28. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  29. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  30. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  31. package/dist/gates/cli.js +1 -1
  32. package/dist/gates/create-app.d.ts +2 -0
  33. package/dist/gates/create-app.d.ts.map +1 -1
  34. package/dist/gates/create-app.js +1 -1
  35. package/dist/gates/index.js +25 -3
  36. package/dist/gates/index.js.map +2 -2
  37. package/dist/gates/status.d.ts +2 -0
  38. package/dist/gates/status.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +69 -48
  42. package/dist/index.js.map +4 -4
  43. package/dist/registry/app-DQI6WLHR.js +10 -0
  44. package/dist/registry/app-DQI6WLHR.js.map +7 -0
  45. package/dist/registry/app.d.ts +21 -0
  46. package/dist/registry/app.d.ts.map +1 -0
  47. package/dist/registry/build-LGFCCOLR.js +27 -0
  48. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  49. package/dist/registry/build.d.ts +95 -0
  50. package/dist/registry/build.d.ts.map +1 -0
  51. package/dist/registry/chunk-E4JNV7PC.js +155 -0
  52. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  53. package/dist/registry/chunk-NJN4MQAM.js +397 -0
  54. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  55. package/dist/registry/chunk-W2B2G7OE.js +188 -0
  56. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  57. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  58. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  59. package/dist/registry/cli.d.ts +2 -0
  60. package/dist/registry/cli.d.ts.map +1 -0
  61. package/dist/registry/cli.js +176 -0
  62. package/dist/registry/cli.js.map +7 -0
  63. package/dist/registry/find.d.ts +54 -0
  64. package/dist/registry/find.d.ts.map +1 -0
  65. package/dist/registry/index.d.ts +17 -0
  66. package/dist/registry/index.d.ts.map +1 -0
  67. package/dist/registry/index.js +34 -0
  68. package/dist/registry/index.js.map +7 -0
  69. package/dist/registry/schema.d.ts +224 -0
  70. package/dist/registry/schema.d.ts.map +1 -0
  71. package/package.json +15 -2
  72. package/registry.json +4615 -0
  73. package/src/Avatar.tsx +3 -0
  74. package/src/Banner.mdx +17 -4
  75. package/src/Banner.stories.tsx +22 -0
  76. package/src/Banner.test.tsx +35 -0
  77. package/src/Banner.tsx +34 -9
  78. package/src/ChipInput.tsx +3 -0
  79. package/src/CommandPalette.tsx +6 -0
  80. package/src/ContainerHeader.mdx +56 -0
  81. package/src/ContainerHeader.stories.tsx +62 -0
  82. package/src/ContainerHeader.test.tsx +47 -0
  83. package/src/ContainerHeader.tsx +45 -0
  84. package/src/EmptyState.mdx +12 -0
  85. package/src/EmptyState.stories.tsx +6 -1
  86. package/src/EmptyState.test.tsx +18 -1
  87. package/src/EmptyState.tsx +14 -1
  88. package/src/IdentityMenu.tsx +3 -0
  89. package/src/Menu.tsx +7 -0
  90. package/src/Skeleton.tsx +2 -0
  91. package/src/Toast.tsx +5 -0
  92. package/src/Toolbar.tsx +4 -0
  93. package/src/Tooltip.tsx +4 -0
  94. package/src/gates/app-checks.ts +23 -1
  95. package/src/gates/create-app.test.ts +77 -6
  96. package/src/gates/create-app.ts +288 -15
  97. package/src/gates/status.ts +16 -0
  98. package/src/index.ts +1 -0
  99. package/src/registry/app.test.ts +562 -0
  100. package/src/registry/app.ts +854 -0
  101. package/src/registry/build.ts +679 -0
  102. package/src/registry/cli.ts +204 -0
  103. package/src/registry/find.ts +232 -0
  104. package/src/registry/index.ts +31 -0
  105. package/src/registry/registry.test.ts +448 -0
  106. package/src/registry/schema.ts +419 -0
  107. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  108. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { APP_RULE_IDS } from "../eslint/index.js";
9
9
  var here = dirname(fileURLToPath(import.meta.url));
10
10
  var packageRoot = resolve(here, "..", "..");
11
+ var ASKED_OF_NPM = ["@estiva-app/identity", "@estiva-app/interop", "@estiva-app/platform", "@estiva-app/protocol", "eslint-plugin-react-hooks"];
11
12
  function themes() {
12
13
  const css = readFileSync(join(packageRoot, "tokens.css"), "utf8");
13
14
  return ["light", ...new Set([...css.matchAll(/data-theme='([\w-]+)'/g)].map((m) => m[1]))];
@@ -38,12 +39,18 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
38
39
  "lint:rules": "eslint --config eslint.gates.config.js .",
39
40
  "postlint:rules": `estiva-gates count --repo ${name}`,
40
41
  "gates:status": "estiva-gates status",
42
+ "ui:find": "estiva-ui find",
43
+ registry: "estiva-ui build",
44
+ "registry:check": "estiva-ui check",
41
45
  test: "vitest run",
42
46
  storybook: "storybook dev -p 6006",
43
47
  "build-storybook": "storybook build"
44
48
  },
45
49
  dependencies: {
46
50
  "@estiva-app/identity": own("@estiva-app/identity"),
51
+ "@estiva-app/interop": own("@estiva-app/interop"),
52
+ "@estiva-app/platform": own("@estiva-app/platform"),
53
+ "@estiva-app/protocol": own("@estiva-app/protocol"),
47
54
  "@estiva-app/ui": ui ?? `^${pkg.version}`,
48
55
  ...deps(["@tabler/icons-react", "react", "react-dom"])
49
56
  },
@@ -94,7 +101,9 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
94
101
  `;
95
102
  return {
96
103
  "package.json": json(packageJson),
97
- ".gitignore": ["node_modules", "dist", "storybook-static", "*.local", "*.log", "*.tsbuildinfo", ".DS_Store", ""].join("\n"),
104
+ // registry.json: the app's catalogue, written only when `npm run registry` is asked
105
+ // to. It is built fresh from the code every time it is read, so it is never kept.
106
+ ".gitignore": ["node_modules", "dist", "storybook-static", "*.local", "*.log", "*.tsbuildinfo", ".DS_Store", "registry.json", ""].join("\n"),
98
107
  ".env.example": `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.
99
108
  #
100
109
  # Left empty, the app offers no sign-in at all and runs anonymous: it never
@@ -106,6 +115,16 @@ function appFiles({ name, title = name, theme = "light", ui, versions = {} }) {
106
115
  # server; a local one (http://localhost:8787) in its database.
107
116
  VITE_ESTIVA_ID_ORIGIN=
108
117
  VITE_ESTIVA_ID_CLIENT_ID=${name}
118
+
119
+ # The relay: the workspace this app reads and writes.
120
+ #
121
+ # Left empty, the app runs alone. It opens no connection, and its home page says
122
+ # so. It also needs sign-in above: the relay refuses anything before sign-in, so
123
+ # with a relay and no sign-in there is still no connection. And Estiva ID must
124
+ # allow this app to sign in to that relay (the README says how).
125
+ #
126
+ # A relay running on this machine is http://localhost:3000.
127
+ VITE_RELAY_URL=
109
128
  `,
110
129
  "index.html": `<!doctype html>
111
130
  <html lang="en"${themeAttr}>
@@ -312,6 +331,10 @@ jobs:
312
331
  - run: npm ci
313
332
  - name: Gate lint
314
333
  run: npm run lint:rules
334
+ # Every part of the app says in one line what it is for, so the catalogue
335
+ # (\`npm run ui:find\`) can offer it before someone builds it again.
336
+ - name: Every part is described
337
+ run: npm run registry:check
315
338
  `,
316
339
  "src/index.css": `@import '@estiva-app/ui/tokens.css';
317
340
  @import '@estiva-app/ui/base.css';
@@ -331,6 +354,7 @@ body,
331
354
  interface ImportMetaEnv {
332
355
  readonly VITE_ESTIVA_ID_ORIGIN?: string
333
356
  readonly VITE_ESTIVA_ID_CLIENT_ID?: string
357
+ readonly VITE_RELAY_URL?: string
334
358
  }
335
359
  `,
336
360
  "src/config.ts": `/** What the app is called on screen. */
@@ -345,6 +369,22 @@ export const ID_CONFIG: { base: string; clientId: string } | null =
345
369
  import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID
346
370
  ? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }
347
371
  : null
372
+
373
+ /**
374
+ * The relay, the workspace this app reads and writes, or \`null\` when this build
375
+ * has none. Empty is a real mode too: the app runs alone and opens no connection,
376
+ * and a local build can never reach the real relay by accident. See .env.example.
377
+ */
378
+ export const RELAY_URL: string | null = import.meta.env.VITE_RELAY_URL?.trim().replace(/\\/+$/, '') || null
379
+
380
+ /** The relay as a person would name it: host and port, no scheme. */
381
+ export function relayLabel(url: string): string {
382
+ try {
383
+ return new URL(url).host
384
+ } catch {
385
+ return url
386
+ }
387
+ }
348
388
  `,
349
389
  "src/auth/estivaId.ts": `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'
350
390
  import { ID_CONFIG } from '../config'
@@ -529,6 +569,131 @@ export function AuthShell({ state, onContinue }: AuthShellProps) {
529
569
  </div>
530
570
  )
531
571
  }
572
+ `,
573
+ "src/relay/client.ts": `import { signViaEstivaId } from '@estiva-app/identity'
574
+ import { browserOnlineSource, createLiveClientHolder, type LiveClient } from '@estiva-app/platform'
575
+ import { currentToken } from '../auth/estivaId'
576
+ import { ID_CONFIG, RELAY_URL } from '../config'
577
+
578
+ /**
579
+ * The event kinds this app reads. They are the app's own decision, and the one
580
+ * blank a new app fills in: Ship reads seven (projects, issues, changes,
581
+ * messages, comments, deletions and edits), Peek its own. Until there are kinds
582
+ * here the app is connected and reads nothing, which is a correct state.
583
+ */
584
+ export const KINDS: number[] = []
585
+
586
+ /**
587
+ * The tab's one relay client. \`@estiva-app/platform\` hands out a holder and
588
+ * keeps nothing, so this line is where "one connection per tab" lives. The relay
589
+ * signs a connection in once and caps subscriptions per connection, so a
590
+ * connection per component is wrong, not only wasteful; React's StrictMode,
591
+ * which mounts everything twice, is what would show it.
592
+ *
593
+ * Peek and Ship hold theirs the same way. Everything the client needs is handed
594
+ * in and nothing here reads the app's own data, so it could move into
595
+ * \`@estiva-app/platform\` as it is, if that is ever worth doing.
596
+ */
597
+ const holder = createLiveClientHolder()
598
+
599
+ /**
600
+ * The client, or \`null\` when this build cannot have one: no relay is set, or
601
+ * there is no sign-in. The relay refuses anything before sign-in, so a socket
602
+ * without it would only connect, be refused and retry.
603
+ */
604
+ export function relayClient(): LiveClient | null {
605
+ const base = ID_CONFIG?.base
606
+ if (!RELAY_URL || !base) return null
607
+ return holder.get({
608
+ relayUrl: RELAY_URL,
609
+ // Read on every connect, never kept: a token kept from the first connect
610
+ // outlives a silent renewal as a dead one, and the next reconnect fails.
611
+ getCredential: () => {
612
+ const token = currentToken()
613
+ return token ? { accessToken: token.accessToken, pubkey: token.pubkey } : null
614
+ },
615
+ // \`expectedPubkey\` is a check, not a request. Estiva ID signs as the token's
616
+ // owner whatever it is handed, so a mismatch comes back as a valid event
617
+ // signed by somebody else. Always pass it.
618
+ sign: (unsigned, token, expectedPubkey) => signViaEstivaId(unsigned, { base, token, expectedPubkey }),
619
+ online: typeof window === 'undefined' ? undefined : browserOnlineSource(window),
620
+ // Names this app in the relay's own logs.
621
+ subscriptionPrefix: '${name}-',
622
+ log: (message, detail) => console.debug('[relay]', message, detail ?? ''),
623
+ })
624
+ }
625
+ `,
626
+ "src/relay/useRelayState.ts": `import type { RelayState } from '@estiva-app/protocol'
627
+ import { useSyncExternalStore } from 'react'
628
+ import { relayClient } from './client'
629
+
630
+ const subscribe = (onChange: () => void): (() => void) => relayClient()?.onState(onChange) ?? (() => {})
631
+ const snapshot = (): RelayState | 'off' => relayClient()?.state() ?? 'off'
632
+
633
+ /** The connection's state, for a page to show: \`'off'\` when this build has no client. */
634
+ export function useRelayState(): RelayState | 'off' {
635
+ return useSyncExternalStore(subscribe, snapshot, () => 'off')
636
+ }
637
+ `,
638
+ "src/relay/client.test.ts": `import { afterEach, describe, expect, it, vi } from 'vitest'
639
+
640
+ /**
641
+ * The tab's one relay connection. A fake socket stands in for the relay, so this
642
+ * runs with no network, and counts the sockets opened.
643
+ */
644
+ class FakeSocket {
645
+ static opened = 0
646
+ onopen: (() => void) | null = null
647
+ onmessage: ((event: { data: unknown }) => void) | null = null
648
+ onclose: (() => void) | null = null
649
+ onerror: (() => void) | null = null
650
+ constructor() {
651
+ FakeSocket.opened += 1
652
+ }
653
+ send() {}
654
+ close() {}
655
+ }
656
+
657
+ afterEach(() => {
658
+ vi.unstubAllEnvs()
659
+ vi.unstubAllGlobals()
660
+ vi.resetModules()
661
+ FakeSocket.opened = 0
662
+ })
663
+
664
+ describe('the relay client', () => {
665
+ it('opens no connection when no relay is set', async () => {
666
+ vi.stubEnv('VITE_RELAY_URL', '')
667
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
668
+ vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
669
+ vi.stubGlobal('WebSocket', FakeSocket)
670
+ const { relayClient } = await import('./client')
671
+ expect(relayClient()).toBeNull()
672
+ expect(FakeSocket.opened).toBe(0)
673
+ })
674
+
675
+ it('opens no connection with a relay and no sign-in', async () => {
676
+ vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
677
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', '')
678
+ vi.stubGlobal('WebSocket', FakeSocket)
679
+ const { relayClient } = await import('./client')
680
+ expect(relayClient()).toBeNull()
681
+ expect(FakeSocket.opened).toBe(0)
682
+ })
683
+
684
+ it('holds one connection per tab, however often it is asked', async () => {
685
+ vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
686
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
687
+ vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
688
+ vi.stubGlobal('WebSocket', FakeSocket)
689
+ const { relayClient } = await import('./client')
690
+ const first = relayClient()
691
+ expect(first).not.toBeNull()
692
+ expect(relayClient()).toBe(first)
693
+ expect(FakeSocket.opened).toBe(1)
694
+ first?.close()
695
+ })
696
+ })
532
697
  `,
533
698
  "src/main.tsx": `import { StrictMode } from 'react'
534
699
  import { createRoot } from 'react-dom/client'
@@ -562,13 +727,15 @@ void (async () => {
562
727
  import { IconHome } from '@tabler/icons-react'
563
728
  import { useEffect, useState } from 'react'
564
729
  import { beginSignOut, currentToken, whoAmI } from './auth/estivaId'
565
- import { APP_TITLE, ID_CONFIG } from './config'
730
+ import { APP_TITLE, ID_CONFIG, RELAY_URL } from './config'
566
731
  import { HomePage } from './pages/HomePage'
732
+ import { useRelayState } from './relay/useRelayState'
567
733
 
568
734
  /** The frame: the package's AppShell with a sidebar, and the one page. */
569
735
  export function App() {
570
736
  const signedIn = currentToken() !== null
571
737
  const [me, setMe] = useState<Identity>({})
738
+ const relayState = useRelayState()
572
739
 
573
740
  useEffect(() => {
574
741
  if (!signedIn) return
@@ -591,19 +758,53 @@ export function App() {
591
758
  </Sidebar>
592
759
  }
593
760
  >
594
- <HomePage />
761
+ <HomePage relay={RELAY_URL} state={relayState} name={me.name} />
595
762
  </AppShell>
596
763
  )
597
764
  }
598
765
  `,
599
- "src/pages/HomePage.tsx": `import { EmptyState } from '@estiva-app/ui'
766
+ "src/pages/HomePage.tsx": `import type { RelayState } from '@estiva-app/protocol'
767
+ import { EmptyState } from '@estiva-app/ui'
768
+ import { relayLabel } from '../config'
769
+
770
+ export interface HomePageProps {
771
+ /** The relay this build is set to, or \`null\` when the app runs alone. */
772
+ relay: string | null
773
+ /** The connection's state: \`'off'\` when this build has no client. */
774
+ state: RelayState | 'off'
775
+ /** The signed-in person's name, once Estiva ID has said it. */
776
+ name?: string
777
+ }
778
+
779
+ const SAYS: Record<RelayState, (host: string, name?: string) => string> = {
780
+ connecting: (host) => \`Connecting to \${host}\u2026\`,
781
+ authenticating: (host) => \`Signing in to \${host}\u2026\`,
782
+ live: (host, name) => (name ? \`Connected to \${host} as \${name}.\` : \`Connected to \${host}.\`),
783
+ reconnecting: (host) => \`Reconnecting to \${host}\u2026\`,
784
+ failed: (host) => \`Could not connect to \${host}.\`,
785
+ }
600
786
 
601
- /** The first page. What it becomes is this app's own work. */
602
- export function HomePage() {
787
+ /**
788
+ * The first page. What it becomes is this app's own work; until then it says
789
+ * whether the app is connected to the relay, and as whom.
790
+ *
791
+ * The empty state goes straight into the frame's \`main\`, with no box around it:
792
+ * \`main\` is a flex column, so the empty state takes the room left and centres
793
+ * in it both ways, as its own page says. A box around it would place it
794
+ * instead, and no gate reads a box. The connection is a quiet caption after it,
795
+ * at the foot of the page.
796
+ */
797
+ export function HomePage({ relay, state, name }: HomePageProps) {
798
+ const line = !relay
799
+ ? 'Running alone: no relay is set. Set VITE_RELAY_URL in .env.local to connect.'
800
+ : state === 'off'
801
+ ? \`Not connected: \${relayLabel(relay)} needs sign-in, and this build has none.\`
802
+ : SAYS[state](relayLabel(relay), name)
603
803
  return (
604
- <div className="flex justify-center px-6 py-16">
804
+ <>
605
805
  <EmptyState message="Nothing here yet." />
606
- </div>
806
+ <p className="px-6 pb-6 text-center text-caption text-text-muted">{line}</p>
807
+ </>
607
808
  )
608
809
  }
609
810
  `,
@@ -614,12 +815,28 @@ const meta = {
614
815
  title: 'Pages/Home',
615
816
  component: HomePage,
616
817
  parameters: { layout: 'fullscreen' },
818
+ // Stands in for the frame's main: a flex column the height of the screen,
819
+ // which is what the page is drawn into in the app.
820
+ decorators: [
821
+ (Story) => (
822
+ <div className="flex h-screen flex-col">
823
+ <Story />
824
+ </div>
825
+ ),
826
+ ],
617
827
  } satisfies Meta<typeof HomePage>
618
828
 
619
829
  export default meta
620
830
  type Story = StoryObj<typeof meta>
621
831
 
622
- export const Empty: Story = {}
832
+ /** No relay set: the app runs alone, the way it starts. */
833
+ export const RunningAlone: Story = { args: { relay: null, state: 'off' } }
834
+
835
+ /** A relay set, before it has signed the connection in. */
836
+ export const Connecting: Story = { args: { relay: 'http://localhost:3000', state: 'connecting' } }
837
+
838
+ /** Connected, as the person who signed in. */
839
+ export const Connected: Story = { args: { relay: 'http://localhost:3000', state: 'live', name: 'Alex Kim' } }
623
840
  `,
624
841
  "src/App.test.tsx": `import { render, screen } from '@testing-library/react'
625
842
  import { describe, expect, it } from 'vitest'
@@ -631,6 +848,7 @@ describe('${title}', () => {
631
848
  render(<App />)
632
849
  expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)
633
850
  expect(screen.getByText('Nothing here yet.')).toBeTruthy()
851
+ expect(screen.getByText(/^Running alone/)).toBeTruthy()
634
852
  expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()
635
853
  })
636
854
  })
@@ -674,6 +892,40 @@ With no settings it runs **anonymous**: no sign-in is offered, and nothing reach
674
892
  the real Estiva ID. To sign in, copy \`.env.example\` to \`.env.local\` and fill it in.
675
893
  The app must first be registered with that Estiva ID as its own app.
676
894
 
895
+ ## The relay
896
+
897
+ ${title} is connected to the relay, the workspace, from its first commit, as
898
+ whoever signed in. \`src/relay/client.ts\` holds the tab's one connection: ask it
899
+ for \`relayClient()\`, and never open a socket of your own. The home page shows the
900
+ connection's state.
901
+
902
+ With \`VITE_RELAY_URL\` empty it runs alone and opens no connection. The relay
903
+ also needs sign-in, so with a relay and no sign-in there is no connection either.
904
+
905
+ Three packages come with it: \`@estiva-app/protocol\` (the wire: events, ids,
906
+ signing, the relay clients), \`@estiva-app/platform\` (the one connection a tab
907
+ holds) and \`@estiva-app/interop\` (showing another app's objects, from the
908
+ manifest that app publishes).
909
+
910
+ ## What you fill in
911
+
912
+ 1. \`.env.local\`: Estiva ID and the relay (see \`.env.example\`).
913
+ 2. \`KINDS\` in \`src/relay/client.ts\`: the event kinds ${title} reads. Until then
914
+ it is connected and reads nothing.
915
+ 3. The product: its pages, and the fold that turns what arrives on the
916
+ connection into the app's state.
917
+
918
+ Before ${title} can sign in on the real Estiva ID it has to be registered there
919
+ as its own app, with:
920
+
921
+ - its exact redirect address
922
+ - every event kind it will sign, **including 22242**, the relay's sign-in handshake
923
+ - the relay's address, allowed for that handshake. Estiva ID signs a handshake
924
+ only for a relay both the app and the deployment allow.
925
+
926
+ Anything left out fails at the very last step: every screen looks right, and
927
+ nothing arrives.
928
+
677
929
  ## The checks
678
930
 
679
931
  | command | what |
@@ -684,6 +936,8 @@ The app must first be registered with that Estiva ID as its own app.
684
936
  | \`npm test\` | the tests |
685
937
  | \`npm run build\` | the build |
686
938
  | \`npm run gates:status\` | which gates are on, read from the code |
939
+ | \`npm run ui:find <words>\` | what the package and this app already have for it |
940
+ | \`npm run registry:check\` | every part says what it is for \u2014 CI's job \`gate\` |
687
941
  | \`npm run storybook\` | the stories |
688
942
 
689
943
  The gates are the package's, imported rather than copied, so a rule written later
@@ -712,6 +966,16 @@ only with its reason on the line above, \`// @estiva-escape: <reason>\`, never w
712
966
 
713
967
  **Tokens only.** Colours, type, corners and shadows come from the package's preset.
714
968
 
969
+ **One relay connection per tab.** \`relayClient()\` in \`src/relay/client.ts\` is the
970
+ connection. Never open a socket and never make a second holder: the relay signs a
971
+ connection in once, and caps subscriptions per connection.
972
+
973
+ **Look before you build.** \`npm run ui:find <what it does>\` searches the package's
974
+ parts and this app's own. Use what it finds.
975
+
976
+ **Every part says what it is for.** A new part gets a one-line \`/** \u2026 */\` comment
977
+ directly above it. \`npm run registry:check\` and CI's job \`gate\` refuse a part without one.
978
+
715
979
  **The count starts at zero and stays there** (\`.gates-count.json\`, \`docs/GATES-DEBT.md\`).
716
980
 
717
981
  What each gate is and how it is wired: the package's README,
@@ -734,7 +998,7 @@ function createApp(options) {
734
998
  const dir = resolve(options.parent ?? process.cwd(), options.name);
735
999
  if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`);
736
1000
  const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
737
- const missing = ["@estiva-app/identity", "eslint-plugin-react-hooks"].filter((n) => !pkg.devDependencies[n] && !options.versions?.[n]);
1001
+ const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n]);
738
1002
  const versions = { ...missing.length ? askNpm(missing) : {}, ...options.versions };
739
1003
  const files = appFiles({ ...options, versions });
740
1004
  for (const [rel, text] of Object.entries(files)) {
@@ -751,4 +1015,4 @@ export {
751
1015
  askNpm,
752
1016
  createApp
753
1017
  };
754
- //# sourceMappingURL=chunk-ZGJ2J5NU.js.map
1018
+ //# sourceMappingURL=chunk-EA33NP5B.js.map
@@ -0,0 +1,7 @@
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'\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 { TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\n// The token contract on its own (\\`npm run lint:tokens\\`). React's hooks plugin is\n// registered with its rules off, so its directives in the source do not break it.\nexport default defineConfig([\n globalIgnores(TOKEN_LINT_IGNORES),\n { plugins: { 'react-hooks': reactHooks }, linterOptions: { reportUnusedDisableDirectives: 'off' } },\n tokenLint(),\n tokenValues(),\n])\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 *\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 }),\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": ";;;AAgCA,SAAS,oBAAoB;AAC7B,SAAS,YAAY,WAAW,cAAc,qBAAqB;AACnE,SAAS,SAAS,MAAM,eAAe;AACvC,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAsB7B,IAAM,OAAO,QAAQ,cAAc,YAAY,GAAG,CAAC;AACnD,IAAM,cAAc,QAAQ,MAAM,MAAM,IAAI;AAGrC,IAAM,eAAe,CAAC,wBAAwB,uBAAuB,wBAAwB,wBAAwB,2BAA2B;AAGhJ,SAAS,SAAmB;AACjC,QAAM,MAAM,aAAa,KAAK,aAAa,YAAY,GAAG,MAAM;AAChE,SAAO,CAAC,SAAS,GAAG,IAAI,IAAI,CAAC,GAAG,IAAI,SAAS,wBAAwB,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;AAC3F;AAGO,SAAS,SAAS,EAAE,MAAM,QAAQ,MAAM,QAAQ,SAAS,IAAI,WAAW,CAAC,EAAE,GAA6C;AAC7H,MAAI,CAAC,oBAAoB,KAAK,IAAI,EAAG,OAAM,IAAI,MAAM,IAAI,IAAI,+FAA+F;AAC5J,QAAM,QAAQ,OAAO;AACrB,MAAI,CAAC,MAAM,SAAS,KAAK,EAAG,OAAM,IAAI,MAAM,IAAI,KAAK,yCAAyC,MAAM,KAAK,IAAI,CAAC,EAAE;AAEhH,QAAM,MAAM,KAAK,MAAM,aAAa,KAAK,aAAa,cAAc,GAAG,MAAM,CAAC;AAC9E,QAAM,MAAM,CAAC,QAAgB;AAC3B,UAAM,QAAQ,IAAI,gBAAgB,GAAG,KAAK,SAAS,GAAG;AACtD,QAAI,CAAC,MAAO,OAAM,IAAI,MAAM,kBAAkB,GAAG,EAAE;AACnD,WAAO;AAAA,EACT;AACA,QAAM,OAAO,CAAC,UAAoB,OAAO,YAAY,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;AAElF,QAAM,cAAc;AAAA,IAClB;AAAA,IACA,SAAS;AAAA,IACT,SAAS;AAAA,IACT,MAAM;AAAA,IACN,SAAS;AAAA,MACP,KAAK;AAAA,MACL,OAAO;AAAA,MACP,SAAS;AAAA,MACT,WAAW;AAAA,MACX,MAAM;AAAA,MACN,eAAe;AAAA,MACf,cAAc;AAAA,MACd,kBAAkB,6BAA6B,IAAI;AAAA,MACnD,gBAAgB;AAAA,MAChB,WAAW;AAAA,MACX,UAAU;AAAA,MACV,kBAAkB;AAAA,MAClB,MAAM;AAAA,MACN,WAAW;AAAA,MACX,mBAAmB;AAAA,IACrB;AAAA,IACA,cAAc;AAAA,MACZ,wBAAwB,IAAI,sBAAsB;AAAA,MAClD,uBAAuB,IAAI,qBAAqB;AAAA,MAChD,wBAAwB,IAAI,sBAAsB;AAAA,MAClD,wBAAwB,IAAI,sBAAsB;AAAA,MAClD,kBAAkB,MAAM,IAAI,IAAI,OAAO;AAAA,MACvC,GAAG,KAAK,CAAC,uBAAuB,SAAS,WAAW,CAAC;AAAA,IACvD;AAAA,IACA,iBAAiB,KAAK;AAAA,MACpB;AAAA,MAAc;AAAA,MAAyB;AAAA,MAAyB;AAAA,MAAwB;AAAA,MACxF;AAAA,MAAe;AAAA,MAAgB;AAAA,MAAoB;AAAA,MAAwB;AAAA,MAAgB;AAAA,MAC3F;AAAA,MAAoC;AAAA,MAA6B;AAAA,MAAW;AAAA,MAAS;AAAA,MAAW;AAAA,MAChG;AAAA,MAAe;AAAA,MAAc;AAAA,MAAqB;AAAA,MAAQ;AAAA,IAC5D,CAAC;AAAA,EACH;AAEA,QAAM,QAAQ,EAAE,eAAe,GAAG,MAAM,MAAM,cAAa,oBAAI,KAAK,GAAE,YAAY,GAAG,OAAO,OAAO,YAAY,aAAa,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE;AACvL,QAAM,YAAY,gBAAgB,KAAK;AACvC,QAAM,SAAS;AAAA,IACb,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,kBAAkB;AAAA,IAClB,4BAA4B;AAAA,IAC5B,sBAAsB;AAAA,IACtB,iBAAiB;AAAA,IACjB,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,gBAAgB;AAAA,IAChB,oBAAoB;AAAA,IACpB,oBAAoB;AAAA,IACpB,4BAA4B;AAAA,IAC5B,8BAA8B;AAAA,EAChC;AACA,QAAM,OAAO,CAAC,UAAmB,GAAG,KAAK,UAAU,OAAO,MAAM,CAAC,CAAC;AAAA;AAElE,SAAO;AAAA,IACL,gBAAgB,KAAK,WAAW;AAAA;AAAA;AAAA,IAIhC,cAAc,CAAC,gBAAgB,QAAQ,oBAAoB,WAAW,SAAS,iBAAiB,aAAa,iBAAiB,EAAE,EAAE,KAAK,IAAI;AAAA,IAE3I,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAUO,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAa3B,cAAc;AAAA,iBACD,SAAS;AAAA;AAAA;AAAA;AAAA,aAIb,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASd,iBAAiB,KAAK,EAAE,OAAO,CAAC,GAAG,YAAY,CAAC,EAAE,MAAM,sBAAsB,GAAG,EAAE,MAAM,uBAAuB,CAAC,EAAE,CAAC;AAAA,IACpH,qBAAqB,KAAK;AAAA,MACxB,iBAAiB,EAAE,iBAAiB,gDAAgD,GAAG,QAAQ,yBAAyB,MAAM,KAAK,CAAC,UAAU,OAAO,cAAc,GAAG,OAAO,CAAC,aAAa,GAAG,KAAK,aAAa,OAAO,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE;AAAA,MAC9O,SAAS,CAAC,OAAO,YAAY;AAAA,IAC/B,CAAC;AAAA,IACD,sBAAsB,KAAK;AAAA,MACzB,iBAAiB,EAAE,iBAAiB,iDAAiD,GAAG,QAAQ,KAAK,CAAC,QAAQ,GAAG,OAAO,CAAC,MAAM,EAAE;AAAA,MACjI,SAAS,CAAC,gBAAgB;AAAA,IAC5B,CAAC;AAAA,IAED,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAkBlB,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAiBtB,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQrB,oBAAoB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA2BpB,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAa3B,0BAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAe1B,yBAAyB,KAAK;AAAA,MAC5B,OAAO;AAAA,QACL,YAAY,CAAC,EAAE,SAAS,cAAc,OAAO,CAAC,EAAE,MAAM,WAAW,SAAS,gFAAgF,CAAC,EAAE,CAAC;AAAA,MAChK;AAAA,IACF,CAAC;AAAA,IAED,4BAA4B;AAAA;AAAA;AAAA,iCAGC,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA,oBAKjB,IAAI;AAAA;AAAA;AAAA,IAGpB,qBAAqB,KAAK,KAAK;AAAA,IAC/B,sBAAsB,UAAU,KAAK;AAAA;AAAA,WAE9B,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOZ,gCAAgC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAYlB,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAyDlB,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAajB,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQrB,iBAAiB;AAAA,2BACM,KAAK,UAAU,KAAK,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA4B5C,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAmBR,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAmEpB,oBAAoB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAsEpB,0BAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA4B1B,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAgDA,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA,IAK3B,8BAA8B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAY9B,4BAA4B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8CA8Bc,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8CAmBJ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAW9C,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA4BhB,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAwCf,0BAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA6C1B,kCAAkC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IA8BlC,oBAAoB;AAAA;AAAA;AAAA;AAAA;AAAA,YAKZ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWb,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUtB,0BAA0B;AAAA;AAAA;AAAA;AAAA,2CAIa,KAAK,UAAU,KAAK,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAS5D,aAAa,KAAK,KAAK;AAAA;AAAA;AAAA,iDAGsB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBpD,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2DAgBoD,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA,SAKvD,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAoCV,aAAa,KAAK,KAAK;AAAA;AAAA,EAEzB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA4BL;AACF;AAGO,SAAS,OAAO,OAAyC;AAC9D,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,MAA8B,CAAC;AACrC,aAAW,QAAQ,OAAO;AACxB,UAAM,OAAO,CAAC,QAAQ,MAAM,SAAS;AACrC,UAAM,WAAW,MAAM,aAAa,QAAQ,UAAU,CAAC,KAAK,GAAG,IAAI,CAAC,IAAI,aAAa,QAAQ,aAAa,UAAU,YAAY,OAAO,MAAM,EAAE,OAAO,QAAQ,aAAa,QAAQ,CAAC,GACjL,SAAS,EACT,KAAK;AACR,QAAI,CAAC,iBAAiB,KAAK,OAAO,EAAG,OAAM,IAAI,MAAM,oCAAoC,IAAI,iBAAiB,OAAO,GAAG;AACxH,QAAI,IAAI,IAAI,IAAI,OAAO;AAAA,EACzB;AACA,SAAO;AACT;AAGO,SAAS,UAAU,SAAmC;AAC3D,QAAM,MAAM,QAAQ,QAAQ,UAAU,QAAQ,IAAI,GAAG,QAAQ,IAAI;AACjE,MAAI,WAAW,GAAG,EAAG,OAAM,IAAI,MAAM,GAAG,GAAG,6EAA6E;AACxH,QAAM,MAAM,KAAK,MAAM,aAAa,KAAK,aAAa,cAAc,GAAG,MAAM,CAAC;AAC9E,QAAM,UAAU,aAAa,OAAO,CAAC,MAAM,CAAC,IAAI,gBAAgB,CAAC,KAAK,CAAC,QAAQ,WAAW,CAAC,CAAC;AAC5F,QAAM,WAAW,EAAE,GAAI,QAAQ,SAAS,OAAO,OAAO,IAAI,CAAC,GAAI,GAAG,QAAQ,SAAS;AACnF,QAAM,QAAQ,SAAS,EAAE,GAAG,SAAS,SAAS,CAAC;AAC/C,aAAW,CAAC,KAAK,IAAI,KAAK,OAAO,QAAQ,KAAK,GAAG;AAC/C,UAAM,OAAO,KAAK,KAAK,GAAG;AAC1B,cAAU,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AAC5C,kBAAc,MAAM,IAAI;AAAA,EAC1B;AACA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -138,6 +138,20 @@ function helpers(ROOT) {
138
138
  exists,
139
139
  read,
140
140
  listFiles,
141
+ // The registry bundle's own builder, loaded only when a check asks for it:
142
+ // it reads TypeScript with TypeScript, which the other checks never need.
143
+ async catalogue(appDir) {
144
+ try {
145
+ const { buildAppRegistry, validateRegistry } = await import("../registry/index.js");
146
+ const registry = buildAppRegistry({ root: abs(appDir) });
147
+ const problems = validateRegistry(registry);
148
+ if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`);
149
+ return PASS2(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`);
150
+ } catch (error) {
151
+ const lines = String(error instanceof Error ? error.message : error).split("\n");
152
+ return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0]);
153
+ }
154
+ },
141
155
  file(rel) {
142
156
  return exists(rel) ? PASS2(`${rel} exists`) : FAIL(`${rel} does not exist`);
143
157
  },
@@ -475,4 +489,4 @@ export {
475
489
  helpers,
476
490
  runStatus
477
491
  };
478
- //# sourceMappingURL=chunk-AUXD4GCY.js.map
492
+ //# sourceMappingURL=chunk-GTQZEHPC.js.map