@estiva-app/ui 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Kbd.d.ts +7 -1
- package/dist/Kbd.d.ts.map +1 -1
- package/dist/Lightbox.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Toast.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -4
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +14 -2
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/eslint/rule-ids.d.ts +18 -0
- package/dist/eslint/rule-ids.d.ts.map +1 -0
- package/dist/gates/{chunk-CC6L4B5U.js → chunk-DZVDOJGV.js} +16 -2
- package/dist/gates/chunk-DZVDOJGV.js.map +7 -0
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/index.js +23 -19
- package/dist/index.js.map +2 -2
- package/dist/looks.d.ts +2 -2
- package/dist/looks.d.ts.map +1 -1
- package/package.json +2 -1
- package/registry.json +55 -38
- package/src/AppShell.stories.tsx +4 -12
- package/src/AttachmentCard.tsx +0 -1
- package/src/CommandPalette.tsx +1 -1
- package/src/ErrorBoundary.mdx +2 -2
- package/src/Kbd.tsx +10 -1
- package/src/Lightbox.tsx +4 -2
- package/src/Menu.tsx +1 -1
- package/src/MenuItem.stories.tsx +4 -9
- package/src/Panel.stories.tsx +1 -1
- package/src/Popover.stories.tsx +3 -0
- package/src/SectionLabel.tsx +2 -0
- package/src/Select.tsx +2 -2
- package/src/TextInput.test.tsx +1 -1
- package/src/Toast.tsx +7 -6
- package/src/Tooltip.stories.tsx +9 -9
- package/src/Tooltip.test.tsx +1 -1
- package/src/Tooltip.tsx +3 -3
- package/src/cn.ts +1 -1
- package/src/eslint/index.ts +6 -6
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/eslint/rule-ids.ts +29 -0
- package/src/gates/create-app.test.ts +46 -0
- package/src/gates/create-app.ts +1 -1
- package/src/looks.ts +2 -2
- package/stories/TokensPage.tsx +17 -3
- package/tailwind-preset.js +10 -0
- package/tokens.css +144 -0
- package/dist/gates/chunk-CC6L4B5U.js.map +0 -7
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names of the app rules, with nothing imported.
|
|
3
|
+
*
|
|
4
|
+
* `create-estiva-app` needs these names to write a new app's count at zero, and
|
|
5
|
+
* nothing else of the plugin. It runs from `npx` in an empty folder, where only
|
|
6
|
+
* this package's own dependencies are installed, so it must never load the
|
|
7
|
+
* rules themselves: since `no-copied-look` (UIG-25, 0.34.0) they import
|
|
8
|
+
* TypeScript, and that import stopped the command before it wrote a file
|
|
9
|
+
* (UIG-10, reopened 27 September). `index.ts` holds its rule table to exactly
|
|
10
|
+
* these names, so the two cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
|
|
14
|
+
export const PLUGIN_KEY = 'estiva'
|
|
15
|
+
|
|
16
|
+
export const APP_RULE_NAMES = [
|
|
17
|
+
'no-raw-element',
|
|
18
|
+
'no-rebuilt-behaviour',
|
|
19
|
+
'no-restyled-part',
|
|
20
|
+
'no-handmade-header',
|
|
21
|
+
'no-handmade-empty-state',
|
|
22
|
+
'no-native-title',
|
|
23
|
+
'no-copied-look',
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
export type AppRuleName = (typeof APP_RULE_NAMES)[number]
|
|
27
|
+
|
|
28
|
+
/** The ids `recommended` and `strict` carry — what an app's gate runs and counts. */
|
|
29
|
+
export const APP_RULE_IDS = APP_RULE_NAMES.map((name) => `${PLUGIN_KEY}/${name}`)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
2
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { builtinModules } from 'node:module'
|
|
3
4
|
import { dirname, join } from 'node:path'
|
|
5
|
+
import ts from 'typescript'
|
|
4
6
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
7
|
import { buildAppRegistry } from '../registry/app'
|
|
6
8
|
import { validateRegistry } from '../registry/schema'
|
|
@@ -211,3 +213,47 @@ describe('a made app, under the checks every app runs', () => {
|
|
|
211
213
|
expect(failed.filter((f) => !f.startsWith('UIG-30 '))).toEqual([])
|
|
212
214
|
}, 300_000)
|
|
213
215
|
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The command runs from `npx` in an empty folder, where npm has installed this
|
|
219
|
+
* package and its own `dependencies`, nothing else. From 0.34.0 to 0.37.1 it
|
|
220
|
+
* loaded the lint rules, which import TypeScript, and stopped before writing a
|
|
221
|
+
* file (UIG-10, reopened 27 September); the tests above run inside this
|
|
222
|
+
* repository, where TypeScript is always there, so none of them could see it.
|
|
223
|
+
* This walks what is published — the built command and every file it loads —
|
|
224
|
+
* and fails on any package that is not Node's own or a dependency.
|
|
225
|
+
*/
|
|
226
|
+
describe('create-estiva-app, run from npm', () => {
|
|
227
|
+
it('loads nothing that npm does not install with this package', () => {
|
|
228
|
+
const { dependencies = {} } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }
|
|
229
|
+
const builtin = new Set(builtinModules)
|
|
230
|
+
const allowed = (spec: string) => spec.startsWith('node:') || builtin.has(spec) || Object.keys(dependencies).some((d) => spec === d || spec.startsWith(`${d}/`))
|
|
231
|
+
// The import declarations and `import()` calls of a built file: parsed, not
|
|
232
|
+
// grepped, because the command writes app files that hold import lines of
|
|
233
|
+
// their own inside strings.
|
|
234
|
+
const importsOf = (file: string) => {
|
|
235
|
+
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
|
|
236
|
+
const found: string[] = []
|
|
237
|
+
const visit = (node: ts.Node) => {
|
|
238
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) found.push(node.moduleSpecifier.text)
|
|
239
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) found.push(node.arguments[0].text)
|
|
240
|
+
ts.forEachChild(node, visit)
|
|
241
|
+
}
|
|
242
|
+
visit(source)
|
|
243
|
+
return found
|
|
244
|
+
}
|
|
245
|
+
const seen = new Set<string>()
|
|
246
|
+
const outside: string[] = []
|
|
247
|
+
const walk = (file: string) => {
|
|
248
|
+
if (seen.has(file)) return
|
|
249
|
+
seen.add(file)
|
|
250
|
+
for (const spec of importsOf(file)) {
|
|
251
|
+
if (spec.startsWith('.')) walk(join(dirname(file), spec))
|
|
252
|
+
else if (!allowed(spec)) outside.push(`${file.slice(process.cwd().length + 1)} loads ${spec}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
walk(join(process.cwd(), 'dist', 'gates', 'create-app.js'))
|
|
256
|
+
expect(seen.size).toBeGreaterThan(1)
|
|
257
|
+
expect(outside).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
})
|
package/src/gates/create-app.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
34
34
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
35
35
|
import { dirname, join, resolve } from 'node:path'
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
|
-
import { APP_RULE_IDS } from '../eslint/
|
|
37
|
+
import { APP_RULE_IDS } from '../eslint/rule-ids'
|
|
38
38
|
import { loaderText, SEARCH_RULES } from '../registry/skill'
|
|
39
39
|
|
|
40
40
|
interface PackageJson {
|
package/src/looks.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/** A field's box: TextInput and Textarea. The hairline strengthens on hover; focus comes after it, so a focused field keeps its colour under the pointer. */
|
|
15
|
-
export const FIELD_BOX_CLASSES = 'bg-bg-
|
|
15
|
+
export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg'
|
|
16
16
|
|
|
17
17
|
/** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
|
|
18
18
|
export const FIELD_TEXT_CLASSES = 'text-text-primary placeholder:text-text-muted'
|
|
@@ -30,7 +30,7 @@ export const FIELD_SIZE_CLASSES = {
|
|
|
30
30
|
} as const
|
|
31
31
|
|
|
32
32
|
/** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
|
|
33
|
-
export const FIELD_SHELL_CLASSES = 'bg-bg-
|
|
33
|
+
export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors'
|
|
34
34
|
|
|
35
35
|
/** A panel that floats over the page: Menu's panel and Tooltip. */
|
|
36
36
|
export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
|
package/stories/TokensPage.tsx
CHANGED
|
@@ -55,14 +55,14 @@ function resolveVar(name: string): string {
|
|
|
55
55
|
|
|
56
56
|
/* ── The vocabulary (mirrors tailwind-preset.js; tokens.test.ts holds the two together) ── */
|
|
57
57
|
|
|
58
|
-
type Swatch = 'fill' | 'text' | 'text-inverse' | 'border' | 'outline' | 'shadow' | 'drop-shadow'
|
|
58
|
+
type Swatch = 'fill' | 'text' | 'text-inverse' | 'text-tooltip' | 'border' | 'outline' | 'shadow' | 'drop-shadow'
|
|
59
59
|
type Token = { key: string; cssVar: string; cls: string; swatch: Swatch; note?: string }
|
|
60
60
|
type Family = { label: string; blurb: string; utilities: string; tokens: Token[] }
|
|
61
61
|
|
|
62
62
|
const colour = (prefix: string, key: string, swatch: Swatch, note?: string): Token => ({
|
|
63
63
|
key: `${prefix}-${key}`,
|
|
64
64
|
cssVar: `--${prefix}-${key}`,
|
|
65
|
-
cls: `${swatch === 'text' || swatch === 'text-inverse' ? 'text' : swatch === 'border' || swatch === 'outline' ? 'border' : 'bg'}-${prefix}-${key}`,
|
|
65
|
+
cls: `${swatch === 'text' || swatch === 'text-inverse' || swatch === 'text-tooltip' ? 'text' : swatch === 'border' || swatch === 'outline' ? 'border' : 'bg'}-${prefix}-${key}`,
|
|
66
66
|
swatch,
|
|
67
67
|
note,
|
|
68
68
|
})
|
|
@@ -86,6 +86,18 @@ const FAMILIES: Family[] = [
|
|
|
86
86
|
utilities: COLOUR_UTILITIES,
|
|
87
87
|
tokens: ['subtle', 'default', 'strong', 'focus'].map((k) => colour('border', k, 'border')),
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
label: 'Tooltip',
|
|
91
|
+
blurb: "A tooltip's own colours, so a theme can draw it apart from its menus and popups: its ground, its text and its edge, then the same three for a shortcut key shown on it.",
|
|
92
|
+
utilities: COLOUR_UTILITIES,
|
|
93
|
+
tokens: [colour('bg', 'tooltip', 'fill'), colour('text', 'tooltip', 'text-tooltip'), colour('border', 'tooltip', 'border'), colour('bg', 'tooltip-key', 'fill'), colour('text', 'tooltip-key', 'text-tooltip'), colour('border', 'tooltip-key', 'border')],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: 'Field',
|
|
97
|
+
blurb: "A field's own box, so a theme can draw its text boxes apart from the ground it uses for chips and keys: its fill, its line and its shadow.",
|
|
98
|
+
utilities: COLOUR_UTILITIES,
|
|
99
|
+
tokens: [colour('bg', 'field', 'fill'), colour('border', 'field', 'border')],
|
|
100
|
+
},
|
|
89
101
|
{
|
|
90
102
|
label: 'Accent',
|
|
91
103
|
blurb: 'The brand colour: the primary button, the checked box, the brand chip. Muted is its opaque wash, wash the translucent one a row tints with on hover, outline its thin border.',
|
|
@@ -121,6 +133,7 @@ const FAMILIES: Family[] = [
|
|
|
121
133
|
{ key: 'md', cssVar: '--shadow-md', cls: 'shadow-md', swatch: 'shadow' },
|
|
122
134
|
{ key: 'lg', cssVar: '--shadow-lg', cls: 'shadow-lg', swatch: 'shadow' },
|
|
123
135
|
{ key: 'focus-ring', cssVar: '--focus-ring', cls: 'shadow-focus-ring', swatch: 'shadow' },
|
|
136
|
+
{ key: 'field', cssVar: '--shadow-field', cls: 'shadow-field', swatch: 'shadow' },
|
|
124
137
|
{ key: 'glow-warning', cssVar: '--glow-warning', cls: 'shadow-glow-warning', swatch: 'shadow' },
|
|
125
138
|
{ key: 'highlight-inset', cssVar: '--highlight-inset', cls: 'shadow-highlight-inset', swatch: 'shadow' },
|
|
126
139
|
{ key: 'glow-success', cssVar: '--glow-success', cls: 'drop-shadow-glow-success', swatch: 'drop-shadow' },
|
|
@@ -180,8 +193,9 @@ function SwatchBox({ token }: { token: Token }) {
|
|
|
180
193
|
return <div className={`${base} border border-border-subtle`} style={{ background: v }} />
|
|
181
194
|
case 'text':
|
|
182
195
|
case 'text-inverse':
|
|
196
|
+
case 'text-tooltip':
|
|
183
197
|
return (
|
|
184
|
-
<div className={`${base} flex items-center justify-center border border-border-subtle ${token.swatch === 'text-inverse' ? 'bg-accent-primary' : 'bg-bg-surface'}`}>
|
|
198
|
+
<div className={`${base} flex items-center justify-center border border-border-subtle ${token.swatch === 'text-inverse' ? 'bg-accent-primary' : token.swatch === 'text-tooltip' ? 'bg-bg-tooltip' : 'bg-bg-surface'}`}>
|
|
185
199
|
<span className="text-body-2-strong leading-none" style={{ color: v }}>Ag</span>
|
|
186
200
|
</div>
|
|
187
201
|
)
|
package/tailwind-preset.js
CHANGED
|
@@ -70,6 +70,7 @@ export default {
|
|
|
70
70
|
plugin(({ addVariant, addComponents }) => {
|
|
71
71
|
addVariant('signal', '.signal &')
|
|
72
72
|
addVariant('ship', "[data-theme='ship'] &")
|
|
73
|
+
addVariant('leaf', "[data-theme='leaf'] &")
|
|
73
74
|
// The Signal canvas: the control-room dot grid on the app's dark ground,
|
|
74
75
|
// in the Signal theme only. AppShell's floating frame carries
|
|
75
76
|
// `signal-canvas`. Peek's, verbatim (UIG-14, Katerina, 19 September: the
|
|
@@ -149,6 +150,9 @@ export default {
|
|
|
149
150
|
'bg-selected': 'var(--bg-selected)',
|
|
150
151
|
'bg-active': 'var(--bg-active)',
|
|
151
152
|
'bg-disabled': 'var(--bg-disabled)',
|
|
153
|
+
'bg-tooltip': 'var(--bg-tooltip)',
|
|
154
|
+
'bg-tooltip-key': 'var(--bg-tooltip-key)',
|
|
155
|
+
'bg-field': 'var(--bg-field)',
|
|
152
156
|
// text
|
|
153
157
|
'text-primary': 'var(--text-primary)',
|
|
154
158
|
'text-secondary': 'var(--text-secondary)',
|
|
@@ -156,11 +160,16 @@ export default {
|
|
|
156
160
|
'text-disabled': 'var(--text-disabled)',
|
|
157
161
|
'text-inverse': 'var(--text-inverse)',
|
|
158
162
|
'text-interactive': 'var(--text-interactive)',
|
|
163
|
+
'text-tooltip': 'var(--text-tooltip)',
|
|
164
|
+
'text-tooltip-key': 'var(--text-tooltip-key)',
|
|
159
165
|
// border
|
|
160
166
|
'border-subtle': 'var(--border-subtle)',
|
|
161
167
|
'border-default': 'var(--border-default)',
|
|
162
168
|
'border-strong': 'var(--border-strong)',
|
|
163
169
|
'border-focus': 'var(--border-focus)',
|
|
170
|
+
'border-tooltip': 'var(--border-tooltip)',
|
|
171
|
+
'border-tooltip-key': 'var(--border-tooltip-key)',
|
|
172
|
+
'border-field': 'var(--border-field)',
|
|
164
173
|
// accent
|
|
165
174
|
'accent-primary': 'var(--accent-primary)',
|
|
166
175
|
'accent-hover': 'var(--accent-hover)',
|
|
@@ -199,6 +208,7 @@ export default {
|
|
|
199
208
|
// The ring a focused control wears where a theme wants one: Signal's
|
|
200
209
|
// glow. Every theme defines it; the shared inputs use it under `signal:`.
|
|
201
210
|
'focus-ring': 'var(--focus-ring)',
|
|
211
|
+
'field': 'var(--shadow-field)',
|
|
202
212
|
// Signal's glow and inner highlight (D16); the components use them under `signal:`.
|
|
203
213
|
// All three glows are here as box shadows, and the two below are also
|
|
204
214
|
// drop shadows: a glow on a surface is a box shadow, a glow on an icon
|
package/tokens.css
CHANGED
|
@@ -80,6 +80,20 @@
|
|
|
80
80
|
--highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
|
81
81
|
--scrim: rgba(0, 0, 0, 0.5);
|
|
82
82
|
--scrim-strong: rgba(0, 0, 0, 0.8);
|
|
83
|
+
/* Tooltips have their own colours (Katerina, 27 September); here, the ones they
|
|
84
|
+
always had: the elevated surface, primary text, the default border. */
|
|
85
|
+
--bg-tooltip: #FAFAFA;
|
|
86
|
+
--text-tooltip: #18181B;
|
|
87
|
+
--border-tooltip: #E4E4E7;
|
|
88
|
+
/* A shortcut key on a tooltip: what Kbd always drew here. */
|
|
89
|
+
--bg-tooltip-key: #F4F4F5;
|
|
90
|
+
--border-tooltip-key: #D4D4D8;
|
|
91
|
+
--text-tooltip-key: #52525B;
|
|
92
|
+
/* A field has its own colours (Katerina, 28 September); here, the ones it always had:
|
|
93
|
+
the inset ground, the default line, no shadow. */
|
|
94
|
+
--bg-field: #F4F4F5;
|
|
95
|
+
--border-field: #E4E4E7;
|
|
96
|
+
--shadow-field: 0 0 #0000;
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
/* ─── dark ─── Peek's dark theme (peek/src/index.css .dark, 2026-08-28). */
|
|
@@ -144,6 +158,20 @@
|
|
|
144
158
|
--highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
|
145
159
|
--scrim: rgba(0, 0, 0, 0.5);
|
|
146
160
|
--scrim-strong: rgba(0, 0, 0, 0.8);
|
|
161
|
+
/* Tooltips have their own colours (Katerina, 27 September); here, the ones they
|
|
162
|
+
always had: the elevated surface, primary text, the default border. */
|
|
163
|
+
--bg-tooltip: #1C1C1F;
|
|
164
|
+
--text-tooltip: #FAFAFA;
|
|
165
|
+
--border-tooltip: #2D2D31;
|
|
166
|
+
/* A shortcut key on a tooltip: what Kbd always drew here. */
|
|
167
|
+
--bg-tooltip-key: #27272A;
|
|
168
|
+
--border-tooltip-key: #52525B;
|
|
169
|
+
--text-tooltip-key: #AEAEB6;
|
|
170
|
+
/* A field has its own colours (Katerina, 28 September); here, the ones it always had:
|
|
171
|
+
the inset ground, the default line, no shadow. */
|
|
172
|
+
--bg-field: #27272A;
|
|
173
|
+
--border-field: #2D2D31;
|
|
174
|
+
--shadow-field: 0 0 #0000;
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
|
|
@@ -212,6 +240,20 @@
|
|
|
212
240
|
--highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
|
213
241
|
--scrim: rgba(0, 0, 0, 0.5);
|
|
214
242
|
--scrim-strong: rgba(0, 0, 0, 0.8);
|
|
243
|
+
/* Tooltips have their own colours (Katerina, 27 September); here, the ones they
|
|
244
|
+
always had: the elevated surface, primary text, the default border. */
|
|
245
|
+
--bg-tooltip: #191d24;
|
|
246
|
+
--text-tooltip: #eef1f6;
|
|
247
|
+
--border-tooltip: rgba(255, 255, 255, 0.12);
|
|
248
|
+
/* A shortcut key on a tooltip: what Kbd always drew here. */
|
|
249
|
+
--bg-tooltip-key: rgba(255, 255, 255, 0.05);
|
|
250
|
+
--border-tooltip-key: rgba(255, 255, 255, 0.22);
|
|
251
|
+
--text-tooltip-key: #b2b9c6;
|
|
252
|
+
/* A field has its own colours (Katerina, 28 September); here, the ones it always had:
|
|
253
|
+
the inset ground, the default line, no shadow. */
|
|
254
|
+
--bg-field: #20242d;
|
|
255
|
+
--border-field: rgba(255, 255, 255, 0.12);
|
|
256
|
+
--shadow-field: 0 0 #0000;
|
|
215
257
|
}
|
|
216
258
|
|
|
217
259
|
/* ─── ship ─── Ship's palette (Katerina's ruling, 2026-08-28; ship/web/src/index.css). */
|
|
@@ -286,4 +328,106 @@
|
|
|
286
328
|
--highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
|
287
329
|
--scrim: rgba(0, 0, 0, 0.5);
|
|
288
330
|
--scrim-strong: rgba(0, 0, 0, 0.8);
|
|
331
|
+
/* Tooltips have their own colours (Katerina, 27 September); here, the ones they
|
|
332
|
+
always had: the elevated surface, primary text, the default border. */
|
|
333
|
+
--bg-tooltip: #1c1f28;
|
|
334
|
+
--text-tooltip: #e6e8ee;
|
|
335
|
+
--border-tooltip: rgba(255, 255, 255, 0.12);
|
|
336
|
+
/* A shortcut key on a tooltip: what Kbd always drew here. */
|
|
337
|
+
--bg-tooltip-key: rgba(255, 255, 255, 0.05);
|
|
338
|
+
--border-tooltip-key: rgba(255, 255, 255, 0.22);
|
|
339
|
+
--text-tooltip-key: #b3b9c7;
|
|
340
|
+
/* A field has its own colours (Katerina, 28 September); here, the ones it always had:
|
|
341
|
+
the inset ground, the default line, no shadow. */
|
|
342
|
+
--bg-field: #1f2330;
|
|
343
|
+
--border-field: rgba(255, 255, 255, 0.12);
|
|
344
|
+
--shadow-field: 0 0 #0000;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ─── leaf ─── Leaf's theme: Linear's light theme on these same tokens (Katerina,
|
|
348
|
+
27–28 September: "I like Linear's theme more", and "get it right, not by
|
|
349
|
+
screenshot"). Read from the Linear app itself: its named colours
|
|
350
|
+
(--color-bg-*, --color-border-*, --color-text-*, --focus-ring-color), dumped
|
|
351
|
+
from a signed-in tab on 28 September and turned from LCH into sRGB by Chrome.
|
|
352
|
+
Where Linear names no colour for one of these tokens, the light theme's stays
|
|
353
|
+
(marked "light"); menus, the toolbar and tooltips follow her rulings. */
|
|
354
|
+
:root[data-theme='leaf'] {
|
|
355
|
+
color-scheme: light;
|
|
356
|
+
|
|
357
|
+
/* bg */
|
|
358
|
+
--bg-base: #efeff0; /* --color-bg-quaternary, the app's ground and sidebar */
|
|
359
|
+
--bg-surface: #f9f9fa; /* --color-bg-primary, the content panel and header */
|
|
360
|
+
--bg-elevated: #ffffff; /* menus and the toolbar are white (Katerina, 27 September) */
|
|
361
|
+
--bg-inset: #e9e9ea; /* --color-bg-secondary: not the ground, or an inset card and a skeleton vanish on it (Katerina, 28 September) */
|
|
362
|
+
/* See-through, so a hover shows on a row that is itself hovered or grey (Katerina, 28 September):
|
|
363
|
+
6.4% black is Linear's --color-bg-secondary (#e9e9ea) on its content panel. */
|
|
364
|
+
--bg-hover: rgba(0, 0, 0, 0.064);
|
|
365
|
+
--bg-selected: #e5e5e6; /* --color-bg-tertiary */
|
|
366
|
+
--bg-active: #e1e1e2; /* the selected sidebar row (lch 89.54%) */
|
|
367
|
+
--bg-disabled: #efeff0; /* the base, as light has it */
|
|
368
|
+
|
|
369
|
+
/* text */
|
|
370
|
+
--text-primary: #1b1b1b; /* --color-text-primary */
|
|
371
|
+
--text-secondary: #5c5c5e; /* --color-text-tertiary: labels, descriptions, icons */
|
|
372
|
+
--text-muted: #9c9d9f; /* --color-text-quaternary: placeholders, counts */
|
|
373
|
+
--text-disabled: #D4D4D8; /* light */
|
|
374
|
+
--text-inverse: #ffffff;
|
|
375
|
+
--text-interactive: #1b1b1b; /* --app-link-color: a link is the text colour */
|
|
376
|
+
|
|
377
|
+
/* borders */
|
|
378
|
+
/* One step darker than Linear's own faintest line (Katerina, 28 September): our cards, chips and
|
|
379
|
+
toasts draw their edge with subtle, and Linear's #f1f1f1 vanished on white and on the ground. */
|
|
380
|
+
--border-subtle: #e5e5e6; /* --color-bg-tertiary */
|
|
381
|
+
--border-default: #dedede; /* --color-border-secondary */
|
|
382
|
+
--border-strong: #d3d3d3; /* --color-border-tertiary */
|
|
383
|
+
--border-focus: #5e69d1; /* --focus-ring-color */
|
|
384
|
+
|
|
385
|
+
/* accent — Linear's indigo */
|
|
386
|
+
--accent-primary: #5e69d1; /* --focus-ring-color, the selection's colour */
|
|
387
|
+
--accent-hover: #6b75df; /* not named in Linear's set: the indigo beside it in the app's own values */
|
|
388
|
+
--accent-muted: #EDE9FE; /* light */
|
|
389
|
+
|
|
390
|
+
/* semantic — light, until Linear's are read */
|
|
391
|
+
--info-default: #3B82F6;
|
|
392
|
+
--info-muted: #DBEAFE;
|
|
393
|
+
--warning-default: #967000; /* Linear's darkest yellow: readable on the warning wash (Katerina, 28 September) */
|
|
394
|
+
--warning-muted: #FEF3C7;
|
|
395
|
+
--success-default: #0EA06F;
|
|
396
|
+
--success-muted: #D1FAE5;
|
|
397
|
+
--error-default: #EF4444;
|
|
398
|
+
--error-muted: #FEE2E2;
|
|
399
|
+
|
|
400
|
+
/* shadows — light */
|
|
401
|
+
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
|
|
402
|
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -2px rgba(0,0,0,0.10);
|
|
403
|
+
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -4px rgba(0,0,0,0.10);
|
|
404
|
+
--focus-ring: 0 0 0 1px #5e69d1; /* --focus-ring-outline: 1px of the accent */
|
|
405
|
+
/* Transparent colours (D16): light's strengths, on Linear's indigo. */
|
|
406
|
+
--bg-wash: rgba(0, 0, 0, 0.04);
|
|
407
|
+
--accent-outline: rgba(94, 105, 209, 0.3);
|
|
408
|
+
--info-outline: rgba(59, 130, 246, 0.3);
|
|
409
|
+
--warning-outline: rgba(245, 158, 11, 0.3);
|
|
410
|
+
--success-outline: rgba(14, 160, 111, 0.3);
|
|
411
|
+
--error-outline: rgba(239, 68, 68, 0.3);
|
|
412
|
+
--accent-wash: rgba(94, 105, 209, 0.11);
|
|
413
|
+
--success-wash: rgba(14, 160, 111, 0.11);
|
|
414
|
+
--glow-warning: 0 0 5px rgba(245, 158, 11, 0.4);
|
|
415
|
+
--glow-success: 0 0 5px rgba(14, 160, 111, 0.7);
|
|
416
|
+
--glow-accent: 0 0 5px rgba(94, 105, 209, 0.6);
|
|
417
|
+
--highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
|
418
|
+
--scrim: rgba(0, 0, 0, 0.5);
|
|
419
|
+
--scrim-strong: rgba(0, 0, 0, 0.8);
|
|
420
|
+
/* Tooltips are dark on the light page (Katerina, 27 September): Linear's text colour as the ground. */
|
|
421
|
+
--bg-tooltip: #1b1b1b;
|
|
422
|
+
--text-tooltip: #ffffff;
|
|
423
|
+
--border-tooltip: #1b1b1b;
|
|
424
|
+
/* A shortcut key on the dark tooltip: a faint light key. */
|
|
425
|
+
--bg-tooltip-key: rgba(255, 255, 255, 0.1);
|
|
426
|
+
--border-tooltip-key: rgba(255, 255, 255, 0.18);
|
|
427
|
+
--text-tooltip-key: rgba(255, 255, 255, 0.75);
|
|
428
|
+
/* A field is Linear's: white, a faint line, a soft two-layer shadow (read from its
|
|
429
|
+
comment box, 28 September: lch(100), lch(97.3), 0 3px 6px -2px at 2% and 0 1px 1px at 4%). */
|
|
430
|
+
--bg-field: #ffffff;
|
|
431
|
+
--border-field: #dedede; /* the default line, so a field shows on a white dialog (Katerina, 28 September); Linear's own is #f7f7f7 */
|
|
432
|
+
--shadow-field: 0 3px 6px -2px rgba(0, 0, 0, 0.02), 0 1px 1px 0 rgba(0, 0, 0, 0.04);
|
|
289
433
|
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../src/gates/create-app.ts"],
|
|
4
|
-
"sourcesContent": ["/// <reference types=\"node\" />\n/**\n * `create-estiva-app <name>` \u2014 a new Estiva app that runs, with every gate on\n * (UIG-10).\n *\n * npx -p @estiva-app/ui create-estiva-app leaf [--title Leaf] [--theme light]\n *\n * The command is `create-app-cli.ts`; this file is what it runs, and what a test reads.\n *\n * **It reads only this package** (Katerina's ruling of 17 September, docs/GATES.md\n * \u00A723). Never Peek, never Ship: they are private, and reading them would carry\n * their history into every new app. What a new app needs to run a gate is this\n * package's own \u2014 the rules, the token and gate configs, the hook, the count,\n * gates:status and the checks every app runs \u2014 so the app gets short files that\n * import them, and a gate added later arrives with a version bump.\n *\n * What only a new app has is written here: its first page in the sidebar frame\n * (ruled the same day), its one theme, sign-in the way Ship signs in, its one\n * relay connection the way Peek and Ship hold theirs, its tests, its stories,\n * the CI workflow with the job `gate`, and its README and CLAUDE.md.\n *\n * The relay (UIG-10, reopened 18 September): a made app gets `protocol`,\n * `platform` and `interop` and is connected from its first commit, or runs alone\n * when no relay is set. The wiring is the app's own file, using `platform` as it\n * is \u2014 not a helper in `platform` \u2014 because a package takes code a real app has\n * already run (ADR 0002 \u00A710), and Peek and Ship each hold theirs the same way.\n *\n * Tool versions are this package's own (its devDependencies build and test the\n * same tools). The ones it does not use itself ({@link ASKED_OF_NPM}) are asked of\n * the npm registry when the app is made. The lockfile is the app's first\n * `npm install`; make it on Linux.\n */\nimport { execFileSync } from 'node:child_process'\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { dirname, join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { APP_RULE_IDS } from '../eslint/index'\nimport { loaderText, SEARCH_RULES } from '../registry/skill'\n\ninterface PackageJson {\n version: string\n devDependencies: Record<string, string>\n}\n\nexport interface CreateAppOptions {\n /** The folder and npm name: lowercase letters, digits and dashes. */\n name: string\n /** What the app is called on screen. */\n title?: string\n /** One of this package's themes. */\n theme?: string\n /** The folder the app's folder is made in. */\n parent?: string\n /** The `@estiva-app/ui` dependency, when not this package's own version (a tarball, for a proof). */\n ui?: string\n /** Versions of the packages this package does not itself use; asked of npm when absent. */\n versions?: Record<string, string>\n}\n\nconst here = dirname(fileURLToPath(import.meta.url))\nconst packageRoot = resolve(here, '..', '..')\n\n/** What a made app depends on that this package does not use itself: asked of npm when the app is made. */\nexport const ASKED_OF_NPM = ['@estiva-app/identity', '@estiva-app/interop', '@estiva-app/platform', '@estiva-app/protocol', 'eslint-plugin-react-hooks']\n\n/** The package's themes, read from its tokens.css: `light` is the block with no attribute. */\nexport function themes(): string[] {\n const css = readFileSync(join(packageRoot, 'tokens.css'), 'utf8')\n return ['light', ...new Set([...css.matchAll(/data-theme='([\\w-]+)'/g)].map((m) => m[1]))]\n}\n\n/** Every file a new app starts with, by path. Nothing is written. */\nexport function appFiles({ name, title = name, theme = 'light', ui, versions = {} }: CreateAppOptions): Record<string, string> {\n if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`\"${name}\" is not a name an app can have: lowercase letters, digits and dashes, starting with a letter`)\n const known = themes()\n if (!known.includes(theme)) throw new Error(`\"${theme}\" is not one of the package's themes: ${known.join(', ')}`)\n\n const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson\n const own = (dep: string) => {\n const range = pkg.devDependencies[dep] ?? versions[dep]\n if (!range) throw new Error(`no version for ${dep}`)\n return range\n }\n const deps = (names: string[]) => Object.fromEntries(names.map((n) => [n, own(n)]))\n\n const packageJson = {\n name,\n private: true,\n version: '0.0.0',\n type: 'module',\n scripts: {\n dev: 'vite',\n build: 'tsc -b && vite build',\n preview: 'vite preview',\n typecheck: 'tsc -b',\n lint: 'eslint .',\n 'lint:tokens': 'eslint --config eslint.tokens.config.js .',\n 'lint:rules': 'eslint --config eslint.gates.config.js .',\n 'postlint:rules': `estiva-gates count --repo ${name}`,\n 'gates:status': 'estiva-gates status',\n 'ui:find': 'estiva-ui find',\n registry: 'estiva-ui build',\n 'registry:check': 'estiva-ui check',\n test: 'vitest run',\n storybook: 'storybook dev -p 6006',\n 'build-storybook': 'storybook build',\n },\n dependencies: {\n '@estiva-app/identity': own('@estiva-app/identity'),\n '@estiva-app/interop': own('@estiva-app/interop'),\n '@estiva-app/platform': own('@estiva-app/platform'),\n '@estiva-app/protocol': own('@estiva-app/protocol'),\n '@estiva-app/ui': ui ?? `^${pkg.version}`,\n ...deps(['@tabler/icons-react', 'react', 'react-dom']),\n },\n devDependencies: deps([\n '@eslint/js', '@storybook/addon-docs', '@storybook/react-vite', '@testing-library/dom', '@testing-library/react',\n '@types/node', '@types/react', '@types/react-dom', '@vitejs/plugin-react', 'autoprefixer', 'eslint',\n 'eslint-plugin-better-tailwindcss', 'eslint-plugin-react-hooks', 'globals', 'jsdom', 'postcss', 'storybook',\n 'tailwindcss', 'typescript', 'typescript-eslint', 'vite', 'vitest',\n ]),\n }\n\n const count = { schemaVersion: 1, repo: name, generatedAt: new Date().toISOString(), rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }])) }\n const themeAttr = ` data-theme=\"${theme}\"`\n const tsBase = {\n target: 'ES2023',\n module: 'ESNext',\n skipLibCheck: true,\n moduleResolution: 'bundler',\n allowImportingTsExtensions: true,\n verbatimModuleSyntax: true,\n moduleDetection: 'force',\n noEmit: true,\n strict: true,\n noUnusedLocals: false,\n noUnusedParameters: false,\n erasableSyntaxOnly: true,\n noFallthroughCasesInSwitch: true,\n noUncheckedSideEffectImports: true,\n }\n const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\\n`\n\n return {\n 'package.json': json(packageJson),\n\n // registry.json: the app's catalogue, written only when `npm run registry` is asked\n // to. It is built fresh from the code every time it is read, so it is never kept.\n '.gitignore': ['node_modules', 'dist', 'storybook-static', '*.local', '*.log', '*.tsbuildinfo', '.DS_Store', 'registry.json', ''].join('\\n'),\n\n '.env.example': `# Estiva ID, for signing in. Copy this file to .env.local and fill both in.\n#\n# Left empty, the app offers no sign-in at all and runs anonymous: it never\n# reaches the real Estiva ID by accident.\n#\n# The app must be registered with that Estiva ID first, as its own app, with\n# this redirect address: the origin the app runs on, and a slash\n# (http://localhost:5173/). The real Estiva ID registers a new app on the\n# server; a local one (http://localhost:8787) in its database.\nVITE_ESTIVA_ID_ORIGIN=\nVITE_ESTIVA_ID_CLIENT_ID=${name}\n\n# The relay: the workspace this app reads and writes.\n#\n# Left empty, the app runs alone. It opens no connection, and its home page says\n# so. It also needs sign-in above: the relay refuses anything before sign-in, so\n# with a relay and no sign-in there is still no connection. And Estiva ID must\n# allow this app to sign in to that relay (the README says how).\n#\n# A relay running on this machine is http://localhost:3000.\nVITE_RELAY_URL=\n`,\n\n 'index.html': `<!doctype html>\n<html lang=\"en\"${themeAttr}>\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>${title}</title>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n`,\n\n 'tsconfig.json': json({ files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }),\n 'tsconfig.app.json': json({\n compilerOptions: { tsBuildInfoFile: './node_modules/.tmp/tsconfig.app.tsbuildinfo', ...tsBase, useDefineForClassFields: true, lib: ['ES2023', 'DOM', 'DOM.Iterable'], types: ['vite/client'], jsx: 'react-jsx', paths: { '@/*': ['./src/*'] } },\n include: ['src', '.storybook'],\n }),\n 'tsconfig.node.json': json({\n compilerOptions: { tsBuildInfoFile: './node_modules/.tmp/tsconfig.node.tsbuildinfo', ...tsBase, lib: ['ES2023'], types: ['node'] },\n include: ['vite.config.ts'],\n }),\n\n 'vite.config.ts': `import { fileURLToPath } from 'node:url'\nimport react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vitest/config'\n\n// \\`react\\` is deduped because @estiva-app/ui declares it a peer: two copies in one\n// tree is the \"invalid hook call\" crash, and it appears at runtime.\nexport default defineConfig({\n plugins: [react()],\n resolve: {\n alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },\n dedupe: ['react', 'react-dom'],\n },\n test: {\n environment: 'jsdom',\n },\n})\n`,\n\n 'tailwind.config.js': `import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'\n\n/**\n * The package's preset, and nothing of the app's own: a token is added to the\n * package, in every theme, never here.\n *\n * \\`estivaContent\\` is the package's own files. Tailwind does not merge \\`content\\`\n * from a preset, so without it every class used only by a package part is\n * purged, and the app builds clean while rendering at the wrong size.\n *\n * @type {import('tailwindcss').Config}\n */\nexport default {\n presets: [estiva],\n content: [...estivaContent, './index.html', './src/**/*.{ts,tsx}', './.storybook/**/*.{ts,tsx}'],\n}\n`,\n 'postcss.config.js': `export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n}\n`,\n\n 'eslint.config.js': `import js from '@eslint/js'\nimport { gateLint, TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport { defineConfig, globalIgnores } from 'eslint/config'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\n\n/**\n * Everything, in one lint (\\`npm run lint\\`, a CI step): TypeScript's and React's\n * recommended rules, the token contract and the UI Guardrails' rules. A new app\n * has no backlog, so all of it is a gate from the first commit.\n *\n * The token contract and the gate are the package's (\\`@estiva-app/ui/gates\\`),\n * imported, never copied: a rule written later arrives with a version bump.\n */\nexport default defineConfig([\n globalIgnores(TOKEN_LINT_IGNORES),\n {\n files: ['**/*.{ts,tsx}'],\n extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],\n languageOptions: { globals: globals.browser },\n },\n tokenLint(),\n tokenValues(),\n gateLint(),\n])\n`,\n 'eslint.tokens.config.js': `import { tokenConfig } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\n\n// The token contract on its own (\\`npm run lint:tokens\\`), the package's piece.\n// React's hooks plugin is named so its directives do not break it; TypeScript's\n// the package names itself.\nexport default tokenConfig({ quiet: { 'react-hooks': reactHooks } })\n`,\n 'eslint.gates.config.js': `import { gateConfig } from '@estiva-app/ui/gates'\nimport reactHooks from 'eslint-plugin-react-hooks'\n\n/**\n * The UI Guardrails' rules on their own \u2014 \\`npm run lint:rules\\`, CI's job \\`gate\\`\n * (GitHub requires it on main: never rename it), and what the editor hook in\n * \\`.claude/settings.json\\` lints a proposed write with. The package's gate,\n * imported. React's hooks plugin is named so its directives do not break it;\n * TypeScript's the package names itself.\n *\n * A place that keeps something the gate refuses says why, on the line above:\n * \\`// @estiva-escape(<rule>): <reason>\\`, naming the rule it keeps off. Never\n * \\`eslint-disable\\`: the count refuses it.\n */\nexport default gateConfig({ quiet: { 'react-hooks': reactHooks } })\n`,\n\n '.claude/settings.json': json({\n hooks: {\n PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node \"$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js\" hook' }] }],\n },\n permissions: { allow: SEARCH_RULES },\n }),\n\n // The Claude skill (UIG-20): its trigger, and a line reading the installed package's text in.\n '.claude/skills/estiva-ui/SKILL.md': loaderText(resolve('/app'), resolve('/app/node_modules/@estiva-app/ui')),\n\n 'scripts/gates-checks.mjs': `import { appChecks } from '@estiva-app/ui/gates'\n\n/**\n * What gates:status checks in ${name}: the gate checks every app runs, from the\n * package (\\`appChecks\\`), and nothing of its own yet. A check about this app's\n * own code goes beside them.\n */\nexport default function define(h) {\n return { repo: '${name}', tickets: appChecks(h, { page: 'src/pages/HomePage.tsx' }) }\n}\n`,\n '.gates-count.json': json(count),\n 'docs/GATES-DEBT.md': `# What ${title} owes the gates\n\nNothing. ${title} was made with every gate on, at zero.\n\nIt should stay that way. A place that keeps something the gate refuses says why on\nthe line above it, \\`// @estiva-escape(<rule>): <reason>\\`. This page does not list those:\n\\`npm run gates:status -- --escapes\\` does, from the code, with each one's age, and\n\\`npm run gates:status\\` shows the ones older than 30 days, to keep with a new reason or fix.\n\n## Folders the gate does not read\n\nNone. A folder the gate skips is named here, with why; CI's job \\`gate\\` fails on one\nthat is not.\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, the one check a merge requires: the package's gate rules\n # (each refusal names what to use instead), the token contract, and the\n # catalogue. \\`npm run lint:rules\\` also writes .gates-count.json and fails on\n # 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 # Every step of the gate, the same command Peek and Ship run: the gate\n # lint and \\`.gates-count.json\\` (committed, or this fails), the token\n # contract, the catalogue (every part says in one line what it is for),\n # and the wall's own settings: the editor hook in .claude/settings.json\n # refuses a raw button, and every folder the gate skips is named in\n # docs/GATES-DEBT.md.\n - name: The gate (estiva-gates ci)\n run: npx estiva-gates ci\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 // Every story file gets a Docs page, as in Peek and Ship: the catalogue links each\n // part to it, and \\`npm run registry:check\\` in CI's job \\`gate\\` fails a link that\n // leads nowhere.\n tags: ['autodocs'],\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\\` |\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 |\n| \\`npx estiva-gates ci\\` | every step of CI's job \\`gate\\`: the gate, its count committed, the tokens, the catalogue, the hook |\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 code that reaches past the package, and each refusal names what to use\ninstead. What it refuses is the package's to say, and grows with it: the\n\\`estiva-ui\\` skill lists it. 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(<rule>): <reason>\\`, never with\n\\`eslint-disable\\`.\n\n**Tokens only.** Colours, type, corners and shadows come from the package's preset.\n\n**One relay connection per tab.** \\`relayClient()\\` in \\`src/relay/client.ts\\` is the\nconnection. Never open a socket and never make a second holder: the relay signs a\nconnection in once, and caps subscriptions per connection.\n\n**Look before you build.** \\`npm run ui:find <what it does>\\` searches the package's\nparts and this app's own. Use what it finds.\n\n**Every part says what it is for.** A new part gets a one-line \\`/** \u2026 */\\` comment\ndirectly above it. \\`npm run registry:check\\` and CI's job \\`gate\\` refuse a part without one.\n\n**The count starts at zero and stays there** (\\`.gates-count.json\\`, \\`docs/GATES-DEBT.md\\`).\n\nWhat each gate is and how it is wired: the package's README,\n\\`node_modules/@estiva-app/ui/README.md\\`.\n`,\n }\n}\n\n/** The versions of what this package does not use itself, from the npm registry. */\nexport function askNpm(names: string[]): Record<string, string> {\n const npm = process.env.npm_execpath\n const out: Record<string, string> = {}\n for (const name of names) {\n const args = ['view', name, 'version']\n const version = (npm ? execFileSync(process.execPath, [npm, ...args]) : execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { shell: process.platform === 'win32' }))\n .toString()\n .trim()\n if (!/^\\d+\\.\\d+\\.\\d+/.test(version)) throw new Error(`npm did not say which version of ${name} is current: \"${version}\"`)\n out[name] = `^${version}`\n }\n return out\n}\n\n/** Write the app. Refuses a folder that already exists. Returns the folder. */\nexport function createApp(options: CreateAppOptions): string {\n const dir = resolve(options.parent ?? process.cwd(), options.name)\n if (existsSync(dir)) throw new Error(`${dir} already exists: create-estiva-app never writes into a folder that is there`)\n const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson\n const missing = ASKED_OF_NPM.filter((n) => !pkg.devDependencies[n] && !options.versions?.[n])\n const versions = { ...(missing.length ? askNpm(missing) : {}), ...options.versions }\n const files = appFiles({ ...options, versions })\n for (const [rel, text] of Object.entries(files)) {\n const path = join(dir, rel)\n mkdirSync(dirname(path), { recursive: true })\n writeFileSync(path, text)\n }\n return dir\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|