@estiva-app/ui 0.22.0 → 0.24.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 (173) hide show
  1. package/README.md +21 -0
  2. package/dist/AppShell.d.ts.map +1 -1
  3. package/dist/AttachmentCard.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/ContainerHeader.d.ts +25 -0
  7. package/dist/ContainerHeader.d.ts.map +1 -0
  8. package/dist/EmptyState.d.ts +10 -1
  9. package/dist/EmptyState.d.ts.map +1 -1
  10. package/dist/Form.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +9 -1
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/PreviewCard.d.ts +9 -2
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +7 -1
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/Select.d.ts +11 -3
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +13 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.js +2 -1
  22. package/dist/eslint/index.js.map +2 -2
  23. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  24. package/dist/gates/app-checks.d.ts.map +1 -1
  25. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  26. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  27. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  28. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  29. package/dist/gates/cli.js +1 -1
  30. package/dist/gates/create-app.d.ts +2 -0
  31. package/dist/gates/create-app.d.ts.map +1 -1
  32. package/dist/gates/create-app.js +1 -1
  33. package/dist/gates/index.js +25 -3
  34. package/dist/gates/index.js.map +2 -2
  35. package/dist/gates/status.d.ts +2 -0
  36. package/dist/gates/status.d.ts.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +196 -156
  40. package/dist/index.js.map +4 -4
  41. package/dist/registry/app-DQI6WLHR.js +10 -0
  42. package/dist/registry/app.d.ts +21 -0
  43. package/dist/registry/app.d.ts.map +1 -0
  44. package/dist/registry/build-LGFCCOLR.js +27 -0
  45. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  46. package/dist/registry/build.d.ts +68 -2
  47. package/dist/registry/build.d.ts.map +1 -1
  48. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  49. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  50. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  51. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  52. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  53. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  54. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  55. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  56. package/dist/registry/cli.js +81 -26
  57. package/dist/registry/cli.js.map +3 -3
  58. package/dist/registry/find.d.ts +12 -1
  59. package/dist/registry/find.d.ts.map +1 -1
  60. package/dist/registry/index.d.ts +5 -2
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js +11 -3
  63. package/dist/registry/schema.d.ts +83 -6
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/registry.json +283 -95
  67. package/src/AppShell.mdx +10 -0
  68. package/src/AppShell.test.tsx +16 -0
  69. package/src/AppShell.tsx +4 -1
  70. package/src/AttachmentCard.mdx +9 -0
  71. package/src/AttachmentCard.test.tsx +10 -0
  72. package/src/AttachmentCard.tsx +8 -5
  73. package/src/Avatar.mdx +13 -3
  74. package/src/AvatarGroup.mdx +4 -0
  75. package/src/Banner.mdx +30 -7
  76. package/src/Banner.stories.tsx +22 -0
  77. package/src/Banner.test.tsx +35 -0
  78. package/src/Banner.tsx +34 -9
  79. package/src/Breadcrumb.mdx +10 -0
  80. package/src/Button.mdx +14 -3
  81. package/src/Card.mdx +9 -0
  82. package/src/Checkbox.mdx +10 -0
  83. package/src/Chip.mdx +4 -0
  84. package/src/ChipInput.mdx +12 -0
  85. package/src/CollapsibleSection.mdx +9 -0
  86. package/src/CommandPalette.mdx +14 -2
  87. package/src/ConfirmDialog.mdx +11 -0
  88. package/src/ContainerHeader.mdx +60 -0
  89. package/src/ContainerHeader.stories.tsx +62 -0
  90. package/src/ContainerHeader.test.tsx +47 -0
  91. package/src/ContainerHeader.tsx +45 -0
  92. package/src/DialogShell.mdx +14 -0
  93. package/src/Divider.mdx +9 -1
  94. package/src/EditableText.mdx +11 -0
  95. package/src/EmptyState.mdx +27 -4
  96. package/src/EmptyState.stories.tsx +6 -1
  97. package/src/EmptyState.test.tsx +18 -1
  98. package/src/EmptyState.tsx +14 -1
  99. package/src/Field.mdx +10 -1
  100. package/src/FieldLine.mdx +9 -1
  101. package/src/FilePicker.mdx +8 -0
  102. package/src/Form.mdx +10 -0
  103. package/src/Form.test.tsx +26 -0
  104. package/src/Form.tsx +7 -0
  105. package/src/IconButton.mdx +16 -2
  106. package/src/IconButton.stories.tsx +3 -0
  107. package/src/IconButton.test.tsx +25 -0
  108. package/src/IconButton.tsx +61 -46
  109. package/src/IdentityMenu.mdx +9 -0
  110. package/src/InlineChip.mdx +8 -0
  111. package/src/Kbd.mdx +4 -0
  112. package/src/Link.mdx +10 -0
  113. package/src/Menu.mdx +16 -1
  114. package/src/MenuItem.mdx +12 -2
  115. package/src/NavItem.mdx +8 -0
  116. package/src/Person.mdx +4 -0
  117. package/src/PersonTrigger.mdx +9 -0
  118. package/src/Popover.mdx +21 -0
  119. package/src/PreviewCard.mdx +19 -4
  120. package/src/PreviewCard.tsx +11 -4
  121. package/src/ProgressBar.mdx +8 -0
  122. package/src/Property.mdx +4 -0
  123. package/src/Rail.mdx +9 -1
  124. package/src/RailItem.mdx +8 -0
  125. package/src/Reaction.mdx +9 -0
  126. package/src/ReactionPicker.mdx +8 -0
  127. package/src/ScrollArea.mdx +13 -2
  128. package/src/SearchInput.mdx +9 -0
  129. package/src/SectionHeader.mdx +13 -0
  130. package/src/SectionHeader.stories.tsx +9 -0
  131. package/src/SectionHeader.test.tsx +9 -0
  132. package/src/SectionHeader.tsx +8 -2
  133. package/src/SectionLabel.mdx +4 -0
  134. package/src/Select.mdx +21 -2
  135. package/src/Select.stories.tsx +4 -1
  136. package/src/Select.test.tsx +17 -0
  137. package/src/Select.tsx +34 -13
  138. package/src/Sidebar.mdx +8 -0
  139. package/src/Skeleton.mdx +4 -0
  140. package/src/Tabs.mdx +12 -1
  141. package/src/TextInput.mdx +8 -0
  142. package/src/Textarea.mdx +8 -0
  143. package/src/Toast.mdx +12 -1
  144. package/src/Toolbar.mdx +11 -0
  145. package/src/Tooltip.mdx +26 -2
  146. package/src/Tooltip.stories.tsx +26 -0
  147. package/src/Tooltip.test.tsx +29 -0
  148. package/src/Tooltip.tsx +17 -4
  149. package/src/TopBar.mdx +4 -0
  150. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  151. package/src/eslint/no-restyled-part.ts +1 -0
  152. package/src/gates/app-checks.ts +23 -1
  153. package/src/gates/create-app.test.ts +77 -6
  154. package/src/gates/create-app.ts +288 -15
  155. package/src/gates/status.ts +16 -0
  156. package/src/index.ts +1 -0
  157. package/src/pages.test.ts +142 -0
  158. package/src/registry/app.test.ts +562 -0
  159. package/src/registry/app.ts +854 -0
  160. package/src/registry/build.ts +114 -25
  161. package/src/registry/cli.ts +105 -42
  162. package/src/registry/find.ts +45 -8
  163. package/src/registry/index.ts +8 -1
  164. package/src/registry/registry.test.ts +5 -5
  165. package/src/registry/schema.ts +163 -12
  166. package/tailwind-preset.js +22 -1
  167. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  168. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  169. package/dist/registry/build-GOVLABI6.js +0 -13
  170. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  171. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  172. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  173. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
@@ -1,14 +1,22 @@
1
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
  import { afterAll, describe, expect, it } from 'vitest'
4
- import { appFiles, createApp, themes } from './create-app'
4
+ import { buildAppRegistry } from '../registry/app'
5
+ import { validateRegistry } from '../registry/schema'
6
+ import { appFiles, ASKED_OF_NPM, createApp, themes } from './create-app'
5
7
 
6
8
  /**
7
9
  * `create-estiva-app` (UIG-10). What the app does when it runs is proved on a
8
10
  * throwaway app (docs/GATES.md, UIG-10: building it); these hold what it is made
9
11
  * of, and that it is made from this package alone.
10
12
  */
11
- const versions = { '@estiva-app/identity': '^0.2.2', 'eslint-plugin-react-hooks': '^7.0.1' }
13
+ const versions = {
14
+ '@estiva-app/identity': '^0.2.2',
15
+ '@estiva-app/interop': '^0.23.0',
16
+ '@estiva-app/platform': '^0.1.0',
17
+ '@estiva-app/protocol': '^0.21.0',
18
+ 'eslint-plugin-react-hooks': '^7.0.1',
19
+ }
12
20
  const files = appFiles({ name: 'probe-app', title: 'Probe', theme: 'dark', versions })
13
21
  const scratch = mkdtempSync(join(process.cwd(), 'node_modules', '.create-app-test-'))
14
22
  afterAll(() => rmSync(scratch, { recursive: true, force: true }))
@@ -25,7 +33,8 @@ describe('create-estiva-app', () => {
25
33
  '.claude/settings.json', '.env.example', '.gates-count.json', '.github/workflows/deploy.yml', '.gitignore', '.storybook/main.ts', '.storybook/preview.tsx',
26
34
  'CLAUDE.md', 'README.md', 'docs/GATES-DEBT.md', 'eslint.config.js', 'eslint.gates.config.js', 'eslint.tokens.config.js', 'index.html', 'package.json',
27
35
  'postcss.config.js', 'scripts/gates-checks.mjs', 'src/App.test.tsx', 'src/App.tsx', 'src/auth/AuthShell.tsx', 'src/auth/boot.ts', 'src/auth/estivaId.ts',
28
- 'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx', 'src/vite-env.d.ts', 'tailwind.config.js',
36
+ 'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx',
37
+ 'src/relay/client.test.ts', 'src/relay/client.ts', 'src/relay/useRelayState.ts', 'src/vite-env.d.ts', 'tailwind.config.js',
29
38
  'tsconfig.app.json', 'tsconfig.json', 'tsconfig.node.json', 'vite.config.ts',
30
39
  ].sort(),
31
40
  )
@@ -53,7 +62,7 @@ describe('create-estiva-app', () => {
53
62
  expect(Object.values(files).join('\n')).not.toContain('id.estiva.app')
54
63
  })
55
64
 
56
- it('takes its tool versions from this package, and the two it does not use from what it is given', () => {
65
+ it('takes its tool versions from this package, and the ones it does not use from what it is given', () => {
57
66
  const own = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
58
67
  const made = JSON.parse(files['package.json'])
59
68
  expect(made.dependencies['@estiva-app/ui']).toBe(`^${own.version}`)
@@ -62,6 +71,68 @@ describe('create-estiva-app', () => {
62
71
  expect(made.dependencies['@estiva-app/identity']).toBe('^0.2.2')
63
72
  })
64
73
 
74
+ it('asks npm for exactly what this package does not use itself', () => {
75
+ const own = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
76
+ const made = JSON.parse(files['package.json'])
77
+ const notOurs = [...Object.keys(made.dependencies), ...Object.keys(made.devDependencies)].filter((n) => n !== '@estiva-app/ui' && !own.devDependencies[n])
78
+ expect(notOurs.sort()).toEqual(ASKED_OF_NPM.filter((n) => !own.devDependencies[n]).sort())
79
+ })
80
+
81
+ it('bakes in protocol, platform and interop', () => {
82
+ const made = JSON.parse(files['package.json'])
83
+ expect(made.dependencies['@estiva-app/protocol']).toBe('^0.21.0')
84
+ expect(made.dependencies['@estiva-app/platform']).toBe('^0.1.0')
85
+ expect(made.dependencies['@estiva-app/interop']).toBe('^0.23.0')
86
+ })
87
+
88
+ it('never points at the real relay by itself', () => {
89
+ expect(files['.env.example']).toContain('VITE_RELAY_URL=\n')
90
+ expect(files['src/config.ts']).toMatch(/export const RELAY_URL: string \| null = [^\n]*\|\| null/)
91
+ expect(Object.values(files).join('\n')).not.toContain('estiva.estiva.app')
92
+ })
93
+
94
+ it('holds one relay client per tab, signs with the wrong-person check, and leaves KINDS blank', () => {
95
+ const client = files['src/relay/client.ts']
96
+ expect(client.match(/^const holder = createLiveClientHolder\(\)$/gm)).toHaveLength(1)
97
+ expect(client).not.toMatch(/\bcreateLiveClient\(|\bcreateLiveRelay\(|new WebSocket\(/)
98
+ expect(client).toContain('signViaEstivaId(unsigned, { base, token, expectedPubkey })')
99
+ expect(client).toContain('export const KINDS: number[] = []')
100
+ expect(client).toContain("subscriptionPrefix: 'probe-app-'")
101
+ expect(files['src/relay/client.test.ts']).toContain('holds one connection per tab')
102
+ })
103
+
104
+ it('describes every part it writes, and refuses one without in the job gate (UIG-13)', () => {
105
+ const made = mkdtempSync(join(scratch, 'described-'))
106
+ for (const [path, text] of Object.entries(files)) {
107
+ mkdirSync(dirname(join(made, path)), { recursive: true })
108
+ writeFileSync(join(made, path), text)
109
+ }
110
+ const registry = buildAppRegistry({ root: made })
111
+ expect(validateRegistry(registry)).toEqual([])
112
+ expect(registry.entries.map((entry) => `${entry.name}: ${entry.app?.class}`).sort()).toEqual(['App: one-off', 'AuthShell: one-off', 'HomePage: one-off'])
113
+ expect(registry.filesWithoutParts.map((file) => file.file)).toEqual(['src/main.tsx'])
114
+ const scripts = JSON.parse(files['package.json']).scripts
115
+ expect(scripts).toMatchObject({ 'ui:find': 'estiva-ui find', registry: 'estiva-ui build', 'registry:check': 'estiva-ui check' })
116
+ expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run registry:check/)
117
+ expect(files['.gitignore'].split('\n')).toContain('registry.json')
118
+ })
119
+
120
+ it("puts the page's empty state straight into the frame, with no box around it", () => {
121
+ // EmptyState's own page: in a flex column (the frame's main) it takes the room left and
122
+ // centres both ways "with nothing to add". A box around it placed it instead: 64px from
123
+ // the top, from UIG-10 until 18 September, and no gate reads a box.
124
+ const page = files['src/pages/HomePage.tsx']
125
+ expect(page).toMatch(/return \(\n {4}<>\n {6}<EmptyState message="Nothing here yet\." \/>/)
126
+ expect(page).not.toMatch(/py-16|justify-center|items-center/)
127
+ expect(files['src/pages/HomePage.stories.tsx']).toContain('<div className="flex h-screen flex-col">')
128
+ })
129
+
130
+ it('shows the connection on the home page, with a story for each state', () => {
131
+ expect(files['src/App.tsx']).toContain('<HomePage relay={RELAY_URL} state={relayState} name={me.name} />')
132
+ expect(files['src/pages/HomePage.tsx']).toContain('Running alone')
133
+ for (const story of ['RunningAlone', 'Connecting', 'Connected']) expect(files['src/pages/HomePage.stories.tsx']).toContain(`export const ${story}: Story`)
134
+ })
135
+
65
136
  it('refuses a name an app cannot have, a theme the package has not got, and a folder that exists', () => {
66
137
  expect(themes()).toEqual(expect.arrayContaining(['light', 'dark', 'signal', 'ship']))
67
138
  expect(() => appFiles({ name: 'Leaf', versions })).toThrow(/not a name/)
@@ -15,13 +15,20 @@
15
15
  * import them, and a gate added later arrives with a version bump.
16
16
  *
17
17
  * What only a new app has is written here: its first page in the sidebar frame
18
- * (ruled the same day), its one theme, sign-in the way Ship signs in, one test,
19
- * one story, the CI workflow with the job `gate`, and its README and CLAUDE.md.
18
+ * (ruled the same day), its one theme, sign-in the way Ship signs in, its one
19
+ * relay connection the way Peek and Ship hold theirs, its tests, its stories,
20
+ * the CI workflow with the job `gate`, and its README and CLAUDE.md.
21
+ *
22
+ * The relay (UIG-10, reopened 18 September): a made app gets `protocol`,
23
+ * `platform` and `interop` and is connected from its first commit, or runs alone
24
+ * when no relay is set. The wiring is the app's own file, using `platform` as it
25
+ * is — not a helper in `platform` — because a package takes code a real app has
26
+ * already run (ADR 0002 §10), and Peek and Ship each hold theirs the same way.
20
27
  *
21
28
  * Tool versions are this package's own (its devDependencies build and test the
22
- * same tools). The two it does not use itself `@estiva-app/identity` and
23
- * `eslint-plugin-react-hooks` are asked of the npm registry when the app is
24
- * made. The lockfile is the app's first `npm install`; make it on Linux.
29
+ * same tools). The ones it does not use itself ({@link ASKED_OF_NPM}) are asked of
30
+ * the npm registry when the app is made. The lockfile is the app's first
31
+ * `npm install`; make it on Linux.
25
32
  */
26
33
  import { execFileSync } from 'node:child_process'
27
34
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -52,6 +59,9 @@ export interface CreateAppOptions {
52
59
  const here = dirname(fileURLToPath(import.meta.url))
53
60
  const packageRoot = resolve(here, '..', '..')
54
61
 
62
+ /** What a made app depends on that this package does not use itself: asked of npm when the app is made. */
63
+ export const ASKED_OF_NPM = ['@estiva-app/identity', '@estiva-app/interop', '@estiva-app/platform', '@estiva-app/protocol', 'eslint-plugin-react-hooks']
64
+
55
65
  /** The package's themes, read from its tokens.css: `light` is the block with no attribute. */
56
66
  export function themes(): string[] {
57
67
  const css = readFileSync(join(packageRoot, 'tokens.css'), 'utf8')
@@ -87,12 +97,18 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
87
97
  'lint:rules': 'eslint --config eslint.gates.config.js .',
88
98
  'postlint:rules': `estiva-gates count --repo ${name}`,
89
99
  'gates:status': 'estiva-gates status',
100
+ 'ui:find': 'estiva-ui find',
101
+ registry: 'estiva-ui build',
102
+ 'registry:check': 'estiva-ui check',
90
103
  test: 'vitest run',
91
104
  storybook: 'storybook dev -p 6006',
92
105
  'build-storybook': 'storybook build',
93
106
  },
94
107
  dependencies: {
95
108
  '@estiva-app/identity': own('@estiva-app/identity'),
109
+ '@estiva-app/interop': own('@estiva-app/interop'),
110
+ '@estiva-app/platform': own('@estiva-app/platform'),
111
+ '@estiva-app/protocol': own('@estiva-app/protocol'),
96
112
  '@estiva-app/ui': ui ?? `^${pkg.version}`,
97
113
  ...deps(['@tabler/icons-react', 'react', 'react-dom']),
98
114
  },
@@ -127,7 +143,9 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
127
143
  return {
128
144
  'package.json': json(packageJson),
129
145
 
130
- '.gitignore': ['node_modules', 'dist', 'storybook-static', '*.local', '*.log', '*.tsbuildinfo', '.DS_Store', ''].join('\n'),
146
+ // registry.json: the app's catalogue, written only when `npm run registry` is asked
147
+ // to. It is built fresh from the code every time it is read, so it is never kept.
148
+ '.gitignore': ['node_modules', 'dist', 'storybook-static', '*.local', '*.log', '*.tsbuildinfo', '.DS_Store', 'registry.json', ''].join('\n'),
131
149
 
132
150
  '.env.example': `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.
133
151
  #
@@ -140,6 +158,16 @@ export function appFiles({ name, title = name, theme = 'light', ui, versions = {
140
158
  # server; a local one (http://localhost:8787) in its database.
141
159
  VITE_ESTIVA_ID_ORIGIN=
142
160
  VITE_ESTIVA_ID_CLIENT_ID=${name}
161
+
162
+ # The relay: the workspace this app reads and writes.
163
+ #
164
+ # Left empty, the app runs alone. It opens no connection, and its home page says
165
+ # so. It also needs sign-in above: the relay refuses anything before sign-in, so
166
+ # with a relay and no sign-in there is still no connection. And Estiva ID must
167
+ # allow this app to sign in to that relay (the README says how).
168
+ #
169
+ # A relay running on this machine is http://localhost:3000.
170
+ VITE_RELAY_URL=
143
171
  `,
144
172
 
145
173
  'index.html': `<!doctype html>
@@ -354,6 +382,10 @@ jobs:
354
382
  - run: npm ci
355
383
  - name: Gate lint
356
384
  run: npm run lint:rules
385
+ # Every part of the app says in one line what it is for, so the catalogue
386
+ # (\`npm run ui:find\`) can offer it before someone builds it again.
387
+ - name: Every part is described
388
+ run: npm run registry:check
357
389
  `,
358
390
 
359
391
  'src/index.css': `@import '@estiva-app/ui/tokens.css';
@@ -374,6 +406,7 @@ body,
374
406
  interface ImportMetaEnv {
375
407
  readonly VITE_ESTIVA_ID_ORIGIN?: string
376
408
  readonly VITE_ESTIVA_ID_CLIENT_ID?: string
409
+ readonly VITE_RELAY_URL?: string
377
410
  }
378
411
  `,
379
412
  'src/config.ts': `/** What the app is called on screen. */
@@ -388,6 +421,22 @@ export const ID_CONFIG: { base: string; clientId: string } | null =
388
421
  import.meta.env.VITE_ESTIVA_ID_ORIGIN && import.meta.env.VITE_ESTIVA_ID_CLIENT_ID
389
422
  ? { base: import.meta.env.VITE_ESTIVA_ID_ORIGIN.replace(/\\/+$/, ''), clientId: import.meta.env.VITE_ESTIVA_ID_CLIENT_ID }
390
423
  : null
424
+
425
+ /**
426
+ * The relay, the workspace this app reads and writes, or \`null\` when this build
427
+ * has none. Empty is a real mode too: the app runs alone and opens no connection,
428
+ * and a local build can never reach the real relay by accident. See .env.example.
429
+ */
430
+ export const RELAY_URL: string | null = import.meta.env.VITE_RELAY_URL?.trim().replace(/\\/+$/, '') || null
431
+
432
+ /** The relay as a person would name it: host and port, no scheme. */
433
+ export function relayLabel(url: string): string {
434
+ try {
435
+ return new URL(url).host
436
+ } catch {
437
+ return url
438
+ }
439
+ }
391
440
  `,
392
441
  'src/auth/estivaId.ts': `import { createEstivaId, type ShellReason, type StoredToken } from '@estiva-app/identity'
393
442
  import { ID_CONFIG } from '../config'
@@ -572,6 +621,131 @@ export function AuthShell({ state, onContinue }: AuthShellProps) {
572
621
  </div>
573
622
  )
574
623
  }
624
+ `,
625
+ 'src/relay/client.ts': `import { signViaEstivaId } from '@estiva-app/identity'
626
+ import { browserOnlineSource, createLiveClientHolder, type LiveClient } from '@estiva-app/platform'
627
+ import { currentToken } from '../auth/estivaId'
628
+ import { ID_CONFIG, RELAY_URL } from '../config'
629
+
630
+ /**
631
+ * The event kinds this app reads. They are the app's own decision, and the one
632
+ * blank a new app fills in: Ship reads seven (projects, issues, changes,
633
+ * messages, comments, deletions and edits), Peek its own. Until there are kinds
634
+ * here the app is connected and reads nothing, which is a correct state.
635
+ */
636
+ export const KINDS: number[] = []
637
+
638
+ /**
639
+ * The tab's one relay client. \`@estiva-app/platform\` hands out a holder and
640
+ * keeps nothing, so this line is where "one connection per tab" lives. The relay
641
+ * signs a connection in once and caps subscriptions per connection, so a
642
+ * connection per component is wrong, not only wasteful; React's StrictMode,
643
+ * which mounts everything twice, is what would show it.
644
+ *
645
+ * Peek and Ship hold theirs the same way. Everything the client needs is handed
646
+ * in and nothing here reads the app's own data, so it could move into
647
+ * \`@estiva-app/platform\` as it is, if that is ever worth doing.
648
+ */
649
+ const holder = createLiveClientHolder()
650
+
651
+ /**
652
+ * The client, or \`null\` when this build cannot have one: no relay is set, or
653
+ * there is no sign-in. The relay refuses anything before sign-in, so a socket
654
+ * without it would only connect, be refused and retry.
655
+ */
656
+ export function relayClient(): LiveClient | null {
657
+ const base = ID_CONFIG?.base
658
+ if (!RELAY_URL || !base) return null
659
+ return holder.get({
660
+ relayUrl: RELAY_URL,
661
+ // Read on every connect, never kept: a token kept from the first connect
662
+ // outlives a silent renewal as a dead one, and the next reconnect fails.
663
+ getCredential: () => {
664
+ const token = currentToken()
665
+ return token ? { accessToken: token.accessToken, pubkey: token.pubkey } : null
666
+ },
667
+ // \`expectedPubkey\` is a check, not a request. Estiva ID signs as the token's
668
+ // owner whatever it is handed, so a mismatch comes back as a valid event
669
+ // signed by somebody else. Always pass it.
670
+ sign: (unsigned, token, expectedPubkey) => signViaEstivaId(unsigned, { base, token, expectedPubkey }),
671
+ online: typeof window === 'undefined' ? undefined : browserOnlineSource(window),
672
+ // Names this app in the relay's own logs.
673
+ subscriptionPrefix: '${name}-',
674
+ log: (message, detail) => console.debug('[relay]', message, detail ?? ''),
675
+ })
676
+ }
677
+ `,
678
+ 'src/relay/useRelayState.ts': `import type { RelayState } from '@estiva-app/protocol'
679
+ import { useSyncExternalStore } from 'react'
680
+ import { relayClient } from './client'
681
+
682
+ const subscribe = (onChange: () => void): (() => void) => relayClient()?.onState(onChange) ?? (() => {})
683
+ const snapshot = (): RelayState | 'off' => relayClient()?.state() ?? 'off'
684
+
685
+ /** The connection's state, for a page to show: \`'off'\` when this build has no client. */
686
+ export function useRelayState(): RelayState | 'off' {
687
+ return useSyncExternalStore(subscribe, snapshot, () => 'off')
688
+ }
689
+ `,
690
+ 'src/relay/client.test.ts': `import { afterEach, describe, expect, it, vi } from 'vitest'
691
+
692
+ /**
693
+ * The tab's one relay connection. A fake socket stands in for the relay, so this
694
+ * runs with no network, and counts the sockets opened.
695
+ */
696
+ class FakeSocket {
697
+ static opened = 0
698
+ onopen: (() => void) | null = null
699
+ onmessage: ((event: { data: unknown }) => void) | null = null
700
+ onclose: (() => void) | null = null
701
+ onerror: (() => void) | null = null
702
+ constructor() {
703
+ FakeSocket.opened += 1
704
+ }
705
+ send() {}
706
+ close() {}
707
+ }
708
+
709
+ afterEach(() => {
710
+ vi.unstubAllEnvs()
711
+ vi.unstubAllGlobals()
712
+ vi.resetModules()
713
+ FakeSocket.opened = 0
714
+ })
715
+
716
+ describe('the relay client', () => {
717
+ it('opens no connection when no relay is set', async () => {
718
+ vi.stubEnv('VITE_RELAY_URL', '')
719
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
720
+ vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
721
+ vi.stubGlobal('WebSocket', FakeSocket)
722
+ const { relayClient } = await import('./client')
723
+ expect(relayClient()).toBeNull()
724
+ expect(FakeSocket.opened).toBe(0)
725
+ })
726
+
727
+ it('opens no connection with a relay and no sign-in', async () => {
728
+ vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
729
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', '')
730
+ vi.stubGlobal('WebSocket', FakeSocket)
731
+ const { relayClient } = await import('./client')
732
+ expect(relayClient()).toBeNull()
733
+ expect(FakeSocket.opened).toBe(0)
734
+ })
735
+
736
+ it('holds one connection per tab, however often it is asked', async () => {
737
+ vi.stubEnv('VITE_RELAY_URL', 'http://localhost:3000')
738
+ vi.stubEnv('VITE_ESTIVA_ID_ORIGIN', 'http://localhost:8787')
739
+ vi.stubEnv('VITE_ESTIVA_ID_CLIENT_ID', '${name}')
740
+ vi.stubGlobal('WebSocket', FakeSocket)
741
+ const { relayClient } = await import('./client')
742
+ const first = relayClient()
743
+ expect(first).not.toBeNull()
744
+ expect(relayClient()).toBe(first)
745
+ expect(FakeSocket.opened).toBe(1)
746
+ first?.close()
747
+ })
748
+ })
575
749
  `,
576
750
  'src/main.tsx': `import { StrictMode } from 'react'
577
751
  import { createRoot } from 'react-dom/client'
@@ -605,13 +779,15 @@ void (async () => {
605
779
  import { IconHome } from '@tabler/icons-react'
606
780
  import { useEffect, useState } from 'react'
607
781
  import { beginSignOut, currentToken, whoAmI } from './auth/estivaId'
608
- import { APP_TITLE, ID_CONFIG } from './config'
782
+ import { APP_TITLE, ID_CONFIG, RELAY_URL } from './config'
609
783
  import { HomePage } from './pages/HomePage'
784
+ import { useRelayState } from './relay/useRelayState'
610
785
 
611
786
  /** The frame: the package's AppShell with a sidebar, and the one page. */
612
787
  export function App() {
613
788
  const signedIn = currentToken() !== null
614
789
  const [me, setMe] = useState<Identity>({})
790
+ const relayState = useRelayState()
615
791
 
616
792
  useEffect(() => {
617
793
  if (!signedIn) return
@@ -634,19 +810,53 @@ export function App() {
634
810
  </Sidebar>
635
811
  }
636
812
  >
637
- <HomePage />
813
+ <HomePage relay={RELAY_URL} state={relayState} name={me.name} />
638
814
  </AppShell>
639
815
  )
640
816
  }
641
817
  `,
642
- 'src/pages/HomePage.tsx': `import { EmptyState } from '@estiva-app/ui'
818
+ 'src/pages/HomePage.tsx': `import type { RelayState } from '@estiva-app/protocol'
819
+ import { EmptyState } from '@estiva-app/ui'
820
+ import { relayLabel } from '../config'
821
+
822
+ export interface HomePageProps {
823
+ /** The relay this build is set to, or \`null\` when the app runs alone. */
824
+ relay: string | null
825
+ /** The connection's state: \`'off'\` when this build has no client. */
826
+ state: RelayState | 'off'
827
+ /** The signed-in person's name, once Estiva ID has said it. */
828
+ name?: string
829
+ }
643
830
 
644
- /** The first page. What it becomes is this app's own work. */
645
- export function HomePage() {
831
+ const SAYS: Record<RelayState, (host: string, name?: string) => string> = {
832
+ connecting: (host) => \`Connecting to \${host}…\`,
833
+ authenticating: (host) => \`Signing in to \${host}…\`,
834
+ live: (host, name) => (name ? \`Connected to \${host} as \${name}.\` : \`Connected to \${host}.\`),
835
+ reconnecting: (host) => \`Reconnecting to \${host}…\`,
836
+ failed: (host) => \`Could not connect to \${host}.\`,
837
+ }
838
+
839
+ /**
840
+ * The first page. What it becomes is this app's own work; until then it says
841
+ * whether the app is connected to the relay, and as whom.
842
+ *
843
+ * The empty state goes straight into the frame's \`main\`, with no box around it:
844
+ * \`main\` is a flex column, so the empty state takes the room left and centres
845
+ * in it both ways, as its own page says. A box around it would place it
846
+ * instead, and no gate reads a box. The connection is a quiet caption after it,
847
+ * at the foot of the page.
848
+ */
849
+ export function HomePage({ relay, state, name }: HomePageProps) {
850
+ const line = !relay
851
+ ? 'Running alone: no relay is set. Set VITE_RELAY_URL in .env.local to connect.'
852
+ : state === 'off'
853
+ ? \`Not connected: \${relayLabel(relay)} needs sign-in, and this build has none.\`
854
+ : SAYS[state](relayLabel(relay), name)
646
855
  return (
647
- <div className="flex justify-center px-6 py-16">
856
+ <>
648
857
  <EmptyState message="Nothing here yet." />
649
- </div>
858
+ <p className="px-6 pb-6 text-center text-caption text-text-muted">{line}</p>
859
+ </>
650
860
  )
651
861
  }
652
862
  `,
@@ -657,12 +867,28 @@ const meta = {
657
867
  title: 'Pages/Home',
658
868
  component: HomePage,
659
869
  parameters: { layout: 'fullscreen' },
870
+ // Stands in for the frame's main: a flex column the height of the screen,
871
+ // which is what the page is drawn into in the app.
872
+ decorators: [
873
+ (Story) => (
874
+ <div className="flex h-screen flex-col">
875
+ <Story />
876
+ </div>
877
+ ),
878
+ ],
660
879
  } satisfies Meta<typeof HomePage>
661
880
 
662
881
  export default meta
663
882
  type Story = StoryObj<typeof meta>
664
883
 
665
- export const Empty: Story = {}
884
+ /** No relay set: the app runs alone, the way it starts. */
885
+ export const RunningAlone: Story = { args: { relay: null, state: 'off' } }
886
+
887
+ /** A relay set, before it has signed the connection in. */
888
+ export const Connecting: Story = { args: { relay: 'http://localhost:3000', state: 'connecting' } }
889
+
890
+ /** Connected, as the person who signed in. */
891
+ export const Connected: Story = { args: { relay: 'http://localhost:3000', state: 'live', name: 'Alex Kim' } }
666
892
  `,
667
893
  'src/App.test.tsx': `import { render, screen } from '@testing-library/react'
668
894
  import { describe, expect, it } from 'vitest'
@@ -674,6 +900,7 @@ describe('${title}', () => {
674
900
  render(<App />)
675
901
  expect(screen.getAllByText(APP_TITLE).length).toBeGreaterThan(0)
676
902
  expect(screen.getByText('Nothing here yet.')).toBeTruthy()
903
+ expect(screen.getByText(/^Running alone/)).toBeTruthy()
677
904
  expect(screen.getByRole('link', { name: 'Home' })).toBeTruthy()
678
905
  })
679
906
  })
@@ -719,6 +946,40 @@ With no settings it runs **anonymous**: no sign-in is offered, and nothing reach
719
946
  the real Estiva ID. To sign in, copy \`.env.example\` to \`.env.local\` and fill it in.
720
947
  The app must first be registered with that Estiva ID as its own app.
721
948
 
949
+ ## The relay
950
+
951
+ ${title} is connected to the relay, the workspace, from its first commit, as
952
+ whoever signed in. \`src/relay/client.ts\` holds the tab's one connection: ask it
953
+ for \`relayClient()\`, and never open a socket of your own. The home page shows the
954
+ connection's state.
955
+
956
+ With \`VITE_RELAY_URL\` empty it runs alone and opens no connection. The relay
957
+ also needs sign-in, so with a relay and no sign-in there is no connection either.
958
+
959
+ Three packages come with it: \`@estiva-app/protocol\` (the wire: events, ids,
960
+ signing, the relay clients), \`@estiva-app/platform\` (the one connection a tab
961
+ holds) and \`@estiva-app/interop\` (showing another app's objects, from the
962
+ manifest that app publishes).
963
+
964
+ ## What you fill in
965
+
966
+ 1. \`.env.local\`: Estiva ID and the relay (see \`.env.example\`).
967
+ 2. \`KINDS\` in \`src/relay/client.ts\`: the event kinds ${title} reads. Until then
968
+ it is connected and reads nothing.
969
+ 3. The product: its pages, and the fold that turns what arrives on the
970
+ connection into the app's state.
971
+
972
+ Before ${title} can sign in on the real Estiva ID it has to be registered there
973
+ as its own app, with:
974
+
975
+ - its exact redirect address
976
+ - every event kind it will sign, **including 22242**, the relay's sign-in handshake
977
+ - the relay's address, allowed for that handshake. Estiva ID signs a handshake
978
+ only for a relay both the app and the deployment allow.
979
+
980
+ Anything left out fails at the very last step: every screen looks right, and
981
+ nothing arrives.
982
+
722
983
  ## The checks
723
984
 
724
985
  | command | what |
@@ -729,6 +990,8 @@ The app must first be registered with that Estiva ID as its own app.
729
990
  | \`npm test\` | the tests |
730
991
  | \`npm run build\` | the build |
731
992
  | \`npm run gates:status\` | which gates are on, read from the code |
993
+ | \`npm run ui:find <words>\` | what the package and this app already have for it |
994
+ | \`npm run registry:check\` | every part says what it is for — CI's job \`gate\` |
732
995
  | \`npm run storybook\` | the stories |
733
996
 
734
997
  The gates are the package's, imported rather than copied, so a rule written later
@@ -757,6 +1020,16 @@ only with its reason on the line above, \`// @estiva-escape: <reason>\`, never w
757
1020
 
758
1021
  **Tokens only.** Colours, type, corners and shadows come from the package's preset.
759
1022
 
1023
+ **One relay connection per tab.** \`relayClient()\` in \`src/relay/client.ts\` is the
1024
+ connection. Never open a socket and never make a second holder: the relay signs a
1025
+ connection in once, and caps subscriptions per connection.
1026
+
1027
+ **Look before you build.** \`npm run ui:find <what it does>\` searches the package's
1028
+ parts and this app's own. Use what it finds.
1029
+
1030
+ **Every part says what it is for.** A new part gets a one-line \`/** … */\` comment
1031
+ directly above it. \`npm run registry:check\` and CI's job \`gate\` refuse a part without one.
1032
+
760
1033
  **The count starts at zero and stays there** (\`.gates-count.json\`, \`docs/GATES-DEBT.md\`).
761
1034
 
762
1035
  What each gate is and how it is wired: the package's README,
@@ -785,7 +1058,7 @@ export function createApp(options: CreateAppOptions): string {
785
1058
  const dir = resolve(options.parent ?? process.cwd(), options.name)
786
1059
  if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`)
787
1060
  const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson
788
- const missing = ['@estiva-app/identity', 'eslint-plugin-react-hooks'].filter((n) => !pkg.devDependencies[n] && !options.versions?.[n])
1061
+ const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n])
789
1062
  const versions = { ...(missing.length ? askNpm(missing) : {}), ...options.versions }
790
1063
  const files = appFiles({ ...options, versions })
791
1064
  for (const [rel, text] of Object.entries(files)) {
@@ -71,6 +71,8 @@ export interface GateHelpers {
71
71
  protectedBranch(pattern: RegExp): CheckResult
72
72
  gh(args: string[], label: string): CheckResult
73
73
  share(files: string[], test: (file: string) => boolean, label: string): CheckResult
74
+ /** Build the catalogue of the app in `appDir` (UIG-13): passes when every part is described and sorted. */
75
+ catalogue(appDir: string): Promise<CheckResult>
74
76
  }
75
77
 
76
78
  export interface GateCheck {
@@ -161,6 +163,20 @@ export function helpers(ROOT: string): GateHelpers {
161
163
 
162
164
  return {
163
165
  PASS, FAIL, PART, UNKNOWN, exists, read, listFiles,
166
+ // The registry bundle's own builder, loaded only when a check asks for it:
167
+ // it reads TypeScript with TypeScript, which the other checks never need.
168
+ async catalogue(appDir) {
169
+ try {
170
+ const { buildAppRegistry, validateRegistry } = await import('../registry/index')
171
+ const registry = buildAppRegistry({ root: abs(appDir) })
172
+ const problems = validateRegistry(registry)
173
+ if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`)
174
+ return PASS(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`)
175
+ } catch (error) {
176
+ const lines = String(error instanceof Error ? error.message : error).split('\n')
177
+ return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0])
178
+ }
179
+ },
164
180
 
165
181
  file(rel) {
166
182
  return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export { Property, type PropertyProps } from './Property'
80
80
  export { Reaction, type ReactionProps } from './Reaction'
81
81
  export { SearchInput, type SearchInputProps } from './SearchInput'
82
82
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
83
+ export { ContainerHeader, type ContainerHeaderProps } from './ContainerHeader'
83
84
  export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
84
85
  export { SectionLabel } from './SectionLabel'
85
86
  export { Tabs, type TabDef, type TabsProps } from './Tabs'