@estiva-app/ui 0.25.0 → 0.27.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 (69) hide show
  1. package/dist/AttachmentCard.d.ts +48 -6
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Lightbox.d.ts +30 -0
  4. package/dist/Lightbox.d.ts.map +1 -0
  5. package/dist/eslint/index.js +4 -4
  6. package/dist/eslint/index.js.map +2 -2
  7. package/dist/gates/app-checks.d.ts.map +1 -1
  8. package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
  9. package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
  10. package/dist/gates/cli.js +1 -1
  11. package/dist/gates/index.js +7 -3
  12. package/dist/gates/index.js.map +2 -2
  13. package/dist/gates/status.d.ts +2 -0
  14. package/dist/gates/status.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +594 -464
  18. package/dist/index.js.map +4 -4
  19. package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
  20. package/dist/registry/app.d.ts.map +1 -1
  21. package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
  22. package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
  23. package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
  24. package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
  25. package/dist/registry/cli.js +69 -8
  26. package/dist/registry/cli.js.map +3 -3
  27. package/dist/registry/contract.d.ts +29 -0
  28. package/dist/registry/contract.d.ts.map +1 -0
  29. package/dist/registry/gated-5CPIUQAW.js +230 -0
  30. package/dist/registry/gated-5CPIUQAW.js.map +7 -0
  31. package/dist/registry/gated.d.ts +31 -0
  32. package/dist/registry/gated.d.ts.map +1 -0
  33. package/dist/registry/index.d.ts +1 -0
  34. package/dist/registry/index.d.ts.map +1 -1
  35. package/dist/registry/index.js +15 -3
  36. package/package.json +1 -1
  37. package/registry.json +94 -4
  38. package/src/AttachmentCard.mdx +32 -8
  39. package/src/AttachmentCard.stories.tsx +54 -0
  40. package/src/AttachmentCard.test.tsx +115 -0
  41. package/src/AttachmentCard.tsx +212 -37
  42. package/src/CollapsibleSection.stories.tsx +6 -0
  43. package/src/Lightbox.mdx +71 -0
  44. package/src/Lightbox.stories.tsx +72 -0
  45. package/src/Lightbox.test.tsx +65 -0
  46. package/src/Lightbox.tsx +87 -0
  47. package/src/ListColumn.stories.tsx +6 -0
  48. package/src/MenuItem.stories.tsx +151 -135
  49. package/src/Person.stories.tsx +7 -0
  50. package/src/PersonTrigger.stories.tsx +7 -0
  51. package/src/SectionHeader.stories.tsx +5 -0
  52. package/src/eslint/no-rebuilt-behaviour.ts +4 -4
  53. package/src/gates/app-checks.ts +6 -2
  54. package/src/gates/status.ts +15 -0
  55. package/src/index.ts +1 -0
  56. package/src/registry/app.test.ts +14 -1
  57. package/src/registry/app.ts +5 -1
  58. package/src/registry/cli.ts +79 -7
  59. package/src/registry/contract.test.ts +158 -0
  60. package/src/registry/contract.ts +108 -0
  61. package/src/registry/gated.test.ts +98 -0
  62. package/src/registry/gated.ts +308 -0
  63. package/src/registry/index.ts +1 -0
  64. package/src/registry/registry.test.ts +4 -4
  65. package/tailwind-preset.js +3 -0
  66. package/tokens.css +4 -0
  67. package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
  68. package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
  69. /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
@@ -0,0 +1,158 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-19: the usage-page contract every app's `gate` job runs, and the story
4
+ * links. Each case is one the three repos actually have, or one that got past
5
+ * the copies this replaces:
6
+ *
7
+ * - a page that lost its `When not` (the ticket's own example);
8
+ * - Peek's pages, which never checked the order or the code under `How`;
9
+ * - a reusable part drawn only inside another story, whose page says so on a
10
+ * **Seen in** line (Katerina, 21 September);
11
+ * - a story file named after a part whose stories draw its `…View` half, in the
12
+ * same file — FilesPanel and TopicDetailsDialog in Peek, which the catalogue
13
+ * used to call drawn nowhere;
14
+ * - a Windows checkout, read with CRLF.
15
+ */
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { dirname, join } from 'node:path'
19
+ import { afterAll, describe, expect, it } from 'vitest'
20
+ import { buildAppRegistry } from './app'
21
+ import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
22
+
23
+ const made: string[] = []
24
+ afterAll(() => {
25
+ for (const dir of made) rmSync(dir, { recursive: true, force: true })
26
+ })
27
+
28
+ function app(files: Record<string, string>): string {
29
+ const dir = mkdtempSync(join(tmpdir(), 'uig19-'))
30
+ made.push(dir)
31
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
32
+ for (const [path, text] of Object.entries(files)) {
33
+ mkdirSync(dirname(join(dir, path)), { recursive: true })
34
+ writeFileSync(join(dir, path), text)
35
+ }
36
+ return dir
37
+ }
38
+
39
+ const PAGE = [
40
+ "import { Meta } from '@storybook/addon-docs/blocks'",
41
+ '',
42
+ "<Meta title='Parts/Badge' />",
43
+ '',
44
+ '# Badge',
45
+ '',
46
+ 'A small count beside a name.',
47
+ '',
48
+ '## When',
49
+ '',
50
+ 'A count.',
51
+ '',
52
+ '## When not',
53
+ '',
54
+ 'A word.',
55
+ '',
56
+ '## How',
57
+ '',
58
+ '```tsx',
59
+ '<Badge count={3} />',
60
+ '```',
61
+ '',
62
+ '## What it owns',
63
+ '',
64
+ 'Nothing. It only draws.',
65
+ '',
66
+ ].join('\n')
67
+
68
+ const part = (name: string, cls = 'reusable') =>
69
+ `/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
70
+
71
+ describe('a usage page', () => {
72
+ it('keeps the contract with an opening line and the four sections in order', () => {
73
+ expect(pageProblem(PAGE)).toBeNull()
74
+ })
75
+
76
+ it('fails without "When not"', () => {
77
+ expect(pageProblem(PAGE.replace('## When not', '## Not when'))).toBe('no "When not" section')
78
+ })
79
+
80
+ it('fails with its sections out of order', () => {
81
+ const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
82
+ expect(pageProblem(swapped)).toMatch(/^sections out of order/)
83
+ })
84
+
85
+ it('fails with no opening line', () => {
86
+ expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
87
+ })
88
+
89
+ it('fails with no code under How', () => {
90
+ expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
91
+ })
92
+
93
+ it('reads a Windows checkout', () => {
94
+ expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
95
+ })
96
+
97
+ it('knows a Seen in line, and only at the start of a line', () => {
98
+ expect(hasSeenIn('**Seen in** — *Messages/ConversationCard*.')).toBe(true)
99
+ expect(hasSeenIn('It is **Seen in** two places.')).toBe(false)
100
+ })
101
+ })
102
+
103
+ describe('an app against the contract', () => {
104
+ it('passes a reusable part with a page and a story, and asks nothing of a one-off', () => {
105
+ const dir = app({
106
+ 'src/Badge.tsx': part('Badge'),
107
+ 'src/Badge.mdx': PAGE,
108
+ 'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
109
+ 'src/Once.tsx': part('Once', 'one-off'),
110
+ })
111
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
112
+ })
113
+
114
+ it('names a reusable part with no page, and the file to write', () => {
115
+ const dir = app({ 'src/Badge.tsx': part('Badge') })
116
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
117
+ 'Badge (reusable, src/Badge.tsx) has no usage page: write src/Badge.mdx, with an opening line, then When, When not, How, What it owns',
118
+ ])
119
+ })
120
+
121
+ it('names a page that lost a section', () => {
122
+ const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE.replace('## When not', '## Not when') })
123
+ const problems = contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)
124
+ expect(problems).toContain('Badge (reusable, src/Badge.tsx): src/Badge.mdx has no "When not" section')
125
+ })
126
+
127
+ it('names a reusable part drawn nowhere, and passes it once its page says where it is seen', () => {
128
+ const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE })
129
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
130
+ 'Badge (reusable, src/Badge.tsx) is drawn nowhere: give it a story, or write a "**Seen in**" line on src/Badge.mdx naming the stories that draw it, or why none can',
131
+ ])
132
+ writeFileSync(join(dir, 'src/Badge.mdx'), PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n'))
133
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
134
+ })
135
+
136
+ it("counts a story file named after the part that draws its view, from the part's own file", () => {
137
+ const dir = app({
138
+ 'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
139
+ 'src/Panel.stories.tsx': "import { PanelView } from './Panel'\nconst meta = { title: 'Parts/Panel', component: PanelView }\nexport default meta\nexport const Open = {}\n",
140
+ })
141
+ const entry = buildAppRegistry({ root: dir, repo: 'fixture' }).entries.find((e) => e.name === 'Panel')
142
+ expect(entry?.storyId).toBe('parts-panel--open')
143
+ })
144
+ })
145
+
146
+ describe('story links', () => {
147
+ it('names a link its Storybook does not have, and passes the ones it has', () => {
148
+ const dir = app({
149
+ 'src/Badge.tsx': part('Badge'),
150
+ 'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
151
+ })
152
+ const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
153
+ expect(linkProblems(registry, new Set(['parts-badge--default', 'parts-badge--docs']))).toEqual([])
154
+ expect(linkProblems(registry, new Set(['parts-badge--docs']))).toEqual([
155
+ 'Badge (src/Badge.tsx) links to a story its Storybook does not have: parts-badge--default',
156
+ ])
157
+ })
158
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The usage-page contract, checked against an app's catalogue (UIG-19).
3
+ *
4
+ * Every part an app classifies **reusable** (or **promote-candidate**) has a
5
+ * page beside it — `<PartName>.mdx` in the component's own folder — that opens
6
+ * with a line saying what the part is, then `When`, `When not`, `How` (with
7
+ * code) and `What it owns`, in that order. And it is drawn somewhere: a story
8
+ * of its own, or a **Seen in** line on its page naming the stories that draw
9
+ * it, or saying why none can (Katerina, 21 and 23 September 2026). A one-off
10
+ * needs only its class and a purpose line, which the catalogue already refuses
11
+ * to build without.
12
+ *
13
+ * This is the one copy. Peek and Ship each used to carry their own, pasted into
14
+ * their `scripts/gates-checks.mjs` and run only by `gates:status`, so a page
15
+ * could lose its `When not` and still merge. `estiva-ui check` runs this in
16
+ * every app's `gate` job (docs/GATES.md §23: one copy, in the package).
17
+ *
18
+ * The package's own pages keep the fuller contract in `src/pages.test.ts`,
19
+ * which also reads `What it owns` back against the lint rule.
20
+ */
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import type { Registry } from './schema'
24
+
25
+ /** The four headings, in the order a page carries them. */
26
+ export const PAGE_SECTIONS = ['When', 'When not', 'How', 'What it owns'] as const
27
+
28
+ /** The classes that owe a page and a picture. */
29
+ export const CONTRACT_KINDS = ['reusable', 'promote-candidate'] as const
30
+
31
+ /** The line that stands in for a story of the part's own. */
32
+ const SEEN_IN = /^\*\*Seen in\*\*/m
33
+
34
+ /**
35
+ * Why a page breaks the contract, or null when it keeps it.
36
+ *
37
+ * Read on normalised text: the apps are checked out with CRLF on Windows.
38
+ */
39
+ export function pageProblem(source: string): string | null {
40
+ const text = source.replace(/\r\n/g, '\n')
41
+ const heads = [...text.matchAll(/^##+\s+(.+?)\s*$/gm)].map((m) => m[1])
42
+ const at = PAGE_SECTIONS.map((s) => heads.indexOf(s))
43
+ const missing = PAGE_SECTIONS.filter((_, i) => at[i] < 0)
44
+ if (missing.length) return `no ${missing.map((s) => `"${s}"`).join(', ')} section`
45
+ if (at.some((i, n) => n > 0 && i < at[n - 1])) return `sections out of order: they go ${PAGE_SECTIONS.join(', ')}`
46
+ // The opening line: the first paragraph under the title that is not markup.
47
+ const opening = text
48
+ .split(/^# .*$/m)[1]
49
+ ?.split(/^##\s/m)[0]
50
+ ?.split(/\n\s*\n/)
51
+ .map((s) => s.trim())
52
+ .find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
53
+ if (!opening) return 'no opening line under the title saying what the part is'
54
+ if (!/^##\s+How\s*\n[\s\S]*?```tsx?\n/m.test(text)) return 'no code under "How"'
55
+ return null
56
+ }
57
+
58
+ /** Whether a page records where the part is drawn, in place of a story of its own. */
59
+ export function hasSeenIn(source: string): boolean {
60
+ return SEEN_IN.test(source.replace(/\r\n/g, '\n'))
61
+ }
62
+
63
+ /**
64
+ * Every catalogue link that names a story or a docs page its Storybook does not
65
+ * have (UIG-19: a dead link is a build failure, not a dead end found six weeks
66
+ * later). `ids` is every entry id in the Storybook's own index — what
67
+ * `storybook index` writes — so this is Storybook's answer, not a guess at how
68
+ * it builds an id.
69
+ */
70
+ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): string[] {
71
+ const problems: string[] = []
72
+ for (const entry of registry.entries) {
73
+ for (const key of ['storyId', 'docsId'] as const) {
74
+ const id = entry[key]
75
+ if (id && !ids.has(id)) problems.push(`${entry.name} (${entry.sourceFile}) links to ${key === 'storyId' ? 'a story' : 'a docs page'} its Storybook does not have: ${id}`)
76
+ }
77
+ }
78
+ return problems
79
+ }
80
+
81
+ /**
82
+ * Every way an app's catalogue breaks the contract, one line each, naming the
83
+ * part, its file and what to do. Empty when it keeps it.
84
+ *
85
+ * `root` is the app's own folder: the one its catalogue's paths are relative to.
86
+ */
87
+ export function contractProblems(registry: Registry, root: string): string[] {
88
+ const problems: string[] = []
89
+ for (const entry of registry.entries) {
90
+ const cls = entry.app?.class
91
+ if (!cls || !(CONTRACT_KINDS as readonly string[]).includes(cls)) continue
92
+ // Where the page must be: beside the component, named after the part —
93
+ // the same place the catalogue looks (src/registry/app.ts).
94
+ const page = join(entry.sourceFile.replace(/[^/\\]+$/, ''), `${entry.name}.mdx`).replace(/\\/g, '/')
95
+ const where = `${entry.name} (${cls}, ${entry.sourceFile})`
96
+ if (!existsSync(join(root, page))) {
97
+ problems.push(`${where} has no usage page: write ${page}, with an opening line, then ${PAGE_SECTIONS.join(', ')}`)
98
+ continue
99
+ }
100
+ const text = readFileSync(join(root, page), 'utf8')
101
+ const problem = pageProblem(text)
102
+ if (problem) problems.push(`${where}: ${page} has ${problem}`)
103
+ if (entry.storyId === null && !hasSeenIn(text)) {
104
+ problems.push(`${where} is drawn nowhere: give it a story, or write a "**Seen in**" line on ${page} naming the stories that draw it, or why none can`)
105
+ }
106
+ }
107
+ return problems
108
+ }
@@ -0,0 +1,98 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-19: what a part draws only behind a prop, and no story passes. Each case
4
+ * is one the scan met in Peek or the package:
5
+ *
6
+ * - a gate on the part itself (SectionHeader's chevron);
7
+ * - a prop handed to a smaller part that gates on it (ConversationCard's
8
+ * `onReply`, which the strip draws — strict, by Katerina's ruling);
9
+ * - a prop handed to a dialog, shut in every story (skipped, her ruling);
10
+ * - a story that spreads another story's args (`...Topic.args`);
11
+ * - a spread it cannot open (a helper's `{...props}`), which keeps it quiet.
12
+ */
13
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { afterAll, describe, expect, it } from 'vitest'
17
+ import { buildAppRegistry } from './app'
18
+ import { gatedFindings } from './gated'
19
+
20
+ const made: string[] = []
21
+ afterAll(() => {
22
+ for (const dir of made) rmSync(dir, { recursive: true, force: true })
23
+ })
24
+
25
+ function app(files: Record<string, string>): string {
26
+ const dir = mkdtempSync(join(tmpdir(), 'uig19-gated-'))
27
+ made.push(dir)
28
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
29
+ for (const [path, text] of Object.entries(files)) {
30
+ mkdirSync(dirname(join(dir, path)), { recursive: true })
31
+ writeFileSync(join(dir, path), text)
32
+ }
33
+ return dir
34
+ }
35
+
36
+ const find = (dir: string) => gatedFindings(buildAppRegistry({ root: dir, repo: 'fixture' }), dir).map((f) => `${f.part}.${f.prop}`)
37
+
38
+ const card = `/**
39
+ * A card.
40
+ * @registry reusable: the fixture says so
41
+ */
42
+ export function Card({ title, onReply, onShare }: { title: string; onReply?: () => void; onShare?: () => void }) {
43
+ return (
44
+ <div>
45
+ {title}
46
+ {onReply && <button type="button">Reply</button>}
47
+ <Strip onShare={onShare} />
48
+ <ShareDialog onShare={onShare} />
49
+ </div>
50
+ )
51
+ }
52
+
53
+ /** The strip under a card. */
54
+ export function Strip({ onShare }: { onShare?: () => void }) {
55
+ return <div>{onShare ? <button type="button">Share</button> : null}</div>
56
+ }
57
+
58
+ /** A dialog, shut until opened. */
59
+ export function ShareDialog({ onShare }: { onShare?: () => void }) {
60
+ return <div>{onShare && <button type="button">Share now</button>}</div>
61
+ }
62
+ `
63
+
64
+ const story = (body: string) => `import { Card } from './Card'\nconst meta = { title: 'Parts/Card', component: Card, args: { title: 'Hello' } }\nexport default meta\n${body}\n`
65
+
66
+ describe('what a part draws behind a prop', () => {
67
+ it('names a gate on the part, and one it hands to a smaller part, when no story passes either', () => {
68
+ const dir = app({ 'src/Card.tsx': card, 'src/Card.stories.tsx': story('export const Default = {}') })
69
+ expect(find(dir).sort()).toEqual(['Card.onReply', 'Card.onShare'])
70
+ })
71
+
72
+ it('is satisfied by a story that passes them, in args or through another story’s args', () => {
73
+ const dir = app({
74
+ 'src/Card.tsx': card,
75
+ 'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }\nexport const Shared = { args: { ...Default.args, onShare: () => {} } }"),
76
+ })
77
+ expect(find(dir)).toEqual([])
78
+ })
79
+
80
+ it('skips a prop that reaches only a dialog', () => {
81
+ const only = card.replace(' <Strip onShare={onShare} />\n', '')
82
+ const dir = app({ 'src/Card.tsx': only, 'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }") })
83
+ expect(find(dir)).toEqual([])
84
+ })
85
+
86
+ it('stays quiet when a story passes props it cannot read', () => {
87
+ const dir = app({
88
+ 'src/Card.tsx': card,
89
+ 'src/Card.stories.tsx': `import { Card } from './Card'\nimport { props } from './fixtures'\nconst meta = { title: 'Parts/Card', component: Card }\nexport default meta\nexport const Default = { render: () => <Card title="x" {...props} /> }\n`,
90
+ })
91
+ expect(find(dir)).toEqual([])
92
+ })
93
+
94
+ it('asks nothing of a part no story draws: the contract says so instead', () => {
95
+ const dir = app({ 'src/Card.tsx': card })
96
+ expect(find(dir)).toEqual([])
97
+ })
98
+ })
@@ -0,0 +1,308 @@
1
+ /**
2
+ * What a part draws only when a prop is passed, and no story passes it (UIG-19,
3
+ * Katerina's ruling 5).
4
+ *
5
+ * `{onReply && <IconButton … />}` works in the app, because the app passes
6
+ * `onReply`. In Storybook nothing does, so the button is simply not there. It
7
+ * reads as a broken behaviour rather than a missing fixture, and it hides the
8
+ * very state a reviewer came to see. UIG-17 found five in Peek by hand: Reply on
9
+ * ConversationCard, Edit and Delete on ReplyCard, Rename and Copy link on
10
+ * TopicMoreMenu, Open original on ThreadPanel.
11
+ *
12
+ * Read with TypeScript, part by part. The gate is looked for only inside the
13
+ * part's own function (and its `…View` half, when the stories draw that),
14
+ * because a file is not a component: UIG-17's first scan blamed ReplyCard for
15
+ * two props that belong to a menu declared beside it. A story passes a prop by
16
+ * naming it in an `args` object or as an attribute on the part's tag, anywhere
17
+ * in a story file that imports the part's file.
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
+ import { basename, dirname, join, relative } from 'node:path'
21
+ import ts from 'typescript'
22
+ import type { Registry } from './schema'
23
+
24
+ export interface GatedFinding {
25
+ part: string
26
+ file: string
27
+ prop: string
28
+ /** The part or view whose body holds the gate. */
29
+ in: string
30
+ }
31
+
32
+ const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
33
+
34
+ /** The function a name is declared as: `function X`, `const X = (…) =>`, `memo(…)`, `forwardRef(…)`. */
35
+ function declared(sf: ts.SourceFile, name: string): ts.FunctionLikeDeclaration | null {
36
+ let found: ts.FunctionLikeDeclaration | null = null
37
+ const unwrap = (e: ts.Expression | undefined): ts.FunctionLikeDeclaration | null => {
38
+ if (!e) return null
39
+ if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return e
40
+ if (ts.isCallExpression(e)) return unwrap(e.arguments[0])
41
+ if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) return unwrap(e.expression)
42
+ return null
43
+ }
44
+ for (const s of sf.statements) {
45
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) found = s
46
+ if (ts.isVariableStatement(s)) for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === name) found = unwrap(d.initializer)
47
+ }
48
+ return found
49
+ }
50
+
51
+ /** The props a function takes, by the name its body uses: `{ onReply, owner: who }` → onReply→onReply, who→owner. */
52
+ function propsOf(fn: ts.FunctionLikeDeclaration): { locals: Map<string, string>; object: string | null } {
53
+ const locals = new Map<string, string>()
54
+ const first = fn.parameters[0]
55
+ if (!first) return { locals, object: null }
56
+ if (ts.isObjectBindingPattern(first.name)) {
57
+ for (const el of first.name.elements) {
58
+ if (el.dotDotDotToken || !ts.isIdentifier(el.name)) continue
59
+ const prop = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text
60
+ locals.set(el.name.text, prop)
61
+ }
62
+ return { locals, object: null }
63
+ }
64
+ return { locals, object: ts.isIdentifier(first.name) ? first.name.text : null }
65
+ }
66
+
67
+ const drawsJsx = (node: ts.Node): boolean => {
68
+ let yes = false
69
+ const walk = (n: ts.Node) => {
70
+ if (yes) return
71
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) yes = true
72
+ else ts.forEachChild(n, walk)
73
+ }
74
+ walk(node)
75
+ return yes
76
+ }
77
+
78
+ /** Every prop that gates something drawn: `{p && <X/>}`, `{p ? <X/> : null}`, `{!!p && …}`, `{p != null && …}`. */
79
+ function gates(fn: ts.FunctionLikeDeclaration): Set<string> {
80
+ const { locals, object } = propsOf(fn)
81
+ const out = new Set<string>()
82
+ const propIn = (e: ts.Expression): string | null => {
83
+ while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression
84
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken && ts.isPrefixUnaryExpression(e.operand)) return propIn(e.operand.operand)
85
+ if (ts.isBinaryExpression(e) && [ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(e.operatorToken.kind)) return propIn(e.left)
86
+ if (ts.isIdentifier(e)) return locals.get(e.text) ?? null
87
+ if (object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object) return e.name.text
88
+ return null
89
+ }
90
+ const walk = (n: ts.Node) => {
91
+ // A function declared inside is its own component or handler: not this part's markup.
92
+ if (n !== fn && ts.isFunctionDeclaration(n)) return
93
+ if (ts.isJsxExpression(n) && n.expression) {
94
+ const e = n.expression
95
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && drawsJsx(e.right)) {
96
+ const p = propIn(e.left)
97
+ if (p) out.add(p)
98
+ }
99
+ if (ts.isConditionalExpression(e) && drawsJsx(e.whenTrue) && !drawsJsx(e.whenFalse)) {
100
+ const p = propIn(e.condition)
101
+ if (p) out.add(p)
102
+ }
103
+ }
104
+ ts.forEachChild(n, walk)
105
+ }
106
+ if (fn.body) walk(fn.body)
107
+ return out
108
+ }
109
+
110
+ /** Stands for "every prop": a spread the reader cannot open might pass any of them. */
111
+ const ANY = '*'
112
+
113
+ /**
114
+ * Every prop name a story file passes: keys of any `args` object, and
115
+ * attributes on the named tags. A spread of an object declared in the same
116
+ * file is opened (`args: { replyCount: 3, ...replyRow }`); one it cannot open
117
+ * — an import, a call — counts as passing everything, so the check stays quiet
118
+ * rather than wrong.
119
+ */
120
+ function passed(sf: ts.SourceFile, tags: string[]): Set<string> {
121
+ const out = new Set<string>()
122
+ const consts = new Map<string, ts.ObjectLiteralExpression>()
123
+ for (const s of sf.statements) {
124
+ if (!ts.isVariableStatement(s)) continue
125
+ for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer && ts.isObjectLiteralExpression(d.initializer)) consts.set(d.name.text, d.initializer)
126
+ }
127
+ const keysOf = (o: ts.ObjectLiteralExpression, seen: Set<string>) => {
128
+ for (const p of o.properties) {
129
+ if (ts.isSpreadAssignment(p)) {
130
+ // `...replyRow`, or another story's args: `...Topic.args`.
131
+ const e = p.expression
132
+ const name = ts.isIdentifier(e) ? e.text : ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.name.text === 'args' ? `${e.expression.text}.args` : null
133
+ let target: ts.ObjectLiteralExpression | undefined
134
+ if (name && !seen.has(name)) {
135
+ if (!name.endsWith('.args')) target = consts.get(name)
136
+ else {
137
+ const story = consts.get(name.slice(0, -'.args'.length))
138
+ const args = story?.properties.find((q) => ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'args')
139
+ if (args && ts.isPropertyAssignment(args) && ts.isObjectLiteralExpression(args.initializer)) target = args.initializer
140
+ }
141
+ }
142
+ if (target) keysOf(target, new Set([...seen, name!]))
143
+ else out.add(ANY)
144
+ } else if (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) out.add(p.name.text)
145
+ }
146
+ }
147
+ const walk = (n: ts.Node) => {
148
+ if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name) && n.name.text === 'args') {
149
+ if (ts.isObjectLiteralExpression(n.initializer)) keysOf(n.initializer, new Set())
150
+ else out.add(ANY)
151
+ }
152
+ if ((ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) && tags.includes(n.tagName.getText(sf))) {
153
+ for (const a of n.attributes.properties) {
154
+ if (ts.isJsxAttribute(a)) out.add(a.name.getText(sf))
155
+ else if (ts.isJsxSpreadAttribute(a)) {
156
+ const target = ts.isIdentifier(a.expression) ? consts.get(a.expression.text) : undefined
157
+ if (target) keysOf(target, new Set())
158
+ // `{...args}` hands on what `args` holds, and those are read where they are written.
159
+ else if (!(ts.isIdentifier(a.expression) && a.expression.text === 'args')) out.add(ANY)
160
+ }
161
+ }
162
+ }
163
+ ts.forEachChild(n, walk)
164
+ }
165
+ walk(sf)
166
+ return out
167
+ }
168
+
169
+ function storyFiles(root: string): string[] {
170
+ const out: string[] = []
171
+ const walk = (dir: string) => {
172
+ for (const name of readdirSync(dir)) {
173
+ if (name === 'node_modules' || name.startsWith('.')) continue
174
+ const path = join(dir, name)
175
+ if (statSync(path).isDirectory()) walk(path)
176
+ else if (/\.stories\.tsx?$/.test(name)) out.push(path)
177
+ }
178
+ }
179
+ if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
180
+ return out
181
+ }
182
+
183
+ /** The props a part hands straight on to another tag: `<QuickActions onReply={onReply} />` → QuickActions, onReply, onReply. */
184
+ function handsOn(fn: ts.FunctionLikeDeclaration, sf: ts.SourceFile): { tag: string; attr: string; prop: string }[] {
185
+ const { locals, object } = propsOf(fn)
186
+ const out: { tag: string; attr: string; prop: string }[] = []
187
+ const walk = (n: ts.Node) => {
188
+ if (n !== fn && ts.isFunctionDeclaration(n)) return
189
+ if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) {
190
+ const tag = n.tagName.getText(sf)
191
+ if (/^[A-Z]/.test(tag)) {
192
+ for (const a of n.attributes.properties) {
193
+ if (!ts.isJsxAttribute(a) || !a.initializer || !ts.isJsxExpression(a.initializer) || !a.initializer.expression) continue
194
+ const e = a.initializer.expression
195
+ const prop = ts.isIdentifier(e) ? locals.get(e.text) : object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object ? e.name.text : undefined
196
+ if (prop) out.push({ tag, attr: a.name.getText(sf), prop })
197
+ }
198
+ }
199
+ }
200
+ ts.forEachChild(n, walk)
201
+ }
202
+ if (fn.body) walk(fn.body)
203
+ return out
204
+ }
205
+
206
+ /** Where a module specifier points, without its extension: `./X`, or Vite's `@/` for `src/`. */
207
+ function target(from: string, spec: string, root: string): string | null {
208
+ if (spec.startsWith('.')) return join(dirname(from), spec).replace(/\.[jt]sx?$/, '')
209
+ if (spec.startsWith('@/')) return join(root, 'src', spec.slice(2)).replace(/\.[jt]sx?$/, '')
210
+ return null
211
+ }
212
+
213
+ /**
214
+ * Every prop that gates something a part draws, where some story draws the
215
+ * part and none of those stories passes the prop.
216
+ *
217
+ * "Draws the part" means a story file imports the part's own file — its own
218
+ * stories, or an umbrella sheet such as Peek's Overlays/Menus, which is how
219
+ * TopicMoreMenu lost Rename and Copy link. A gate a part only hands on counts
220
+ * as its own: ConversationCard hands `onReply` to the strip that draws Reply,
221
+ * so a ConversationCard story that never passes it shows no Reply. Followed
222
+ * three parts deep at most.
223
+ */
224
+ export interface GatedOptions {
225
+ /** The classes checked. The contract's by default. */
226
+ kinds?: readonly string[]
227
+ /**
228
+ * `own`: a part's own stories show everything it can draw — ConversationCard's
229
+ * stories must show Reply. `anywhere`: a handed-on gate counts as shown when
230
+ * the part it is handed to has a story that shows it. Katerina rules which.
231
+ */
232
+ pictured?: 'own' | 'anywhere'
233
+ }
234
+
235
+ export function gatedFindings(registry: Registry, root: string, { kinds = ['reusable', 'promote-candidate'], pictured = 'own' }: GatedOptions = {}): GatedFinding[] {
236
+ const findings: GatedFinding[] = []
237
+ const stories = storyFiles(root).map((file) => ({ file, sf: parse(file, readFileSync(file, 'utf8')) }))
238
+ const sources = new Map<string, ts.SourceFile>()
239
+ const sourceOf = (rel: string): ts.SourceFile | null => {
240
+ const path = join(root, rel)
241
+ if (!sources.has(path)) {
242
+ if (!existsSync(path)) return null
243
+ sources.set(path, parse(path, readFileSync(path, 'utf8')))
244
+ }
245
+ return sources.get(path)!
246
+ }
247
+ const fileOf = new Map(registry.entries.filter((e) => e.kind === 'component').map((e) => [e.name, e.sourceFile]))
248
+
249
+ /** The story files that import a file, and every prop they pass to the part or its view. */
250
+ const givenTo = new Map<string, { drawn: boolean; given: Set<string> }>()
251
+ const storiesOf = (name: string, rel: string) => {
252
+ const key = `${rel}#${name}`
253
+ if (!givenTo.has(key)) {
254
+ const own = join(root, rel).replace(/\.[jt]sx?$/, '')
255
+ const drawing = stories.filter(({ file, sf: s }) =>
256
+ s.statements.some((st) => ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier) && target(file, st.moduleSpecifier.text, root) === own),
257
+ )
258
+ givenTo.set(key, { drawn: drawing.length > 0, given: new Set(drawing.flatMap(({ sf: s }) => [...passed(s, [name, `${name}View`])])) })
259
+ }
260
+ return givenTo.get(key)!
261
+ }
262
+
263
+ const memo = new Map<string, Set<string>>()
264
+ /** The props a named part gates on, its own and those it hands to a part that gates. */
265
+ const gatesOf = (name: string, sf: ts.SourceFile, depth: number): Set<string> => {
266
+ const key = `${sf.fileName}#${name}`
267
+ if (memo.has(key)) return memo.get(key)!
268
+ memo.set(key, new Set())
269
+ const fn = declared(sf, name)
270
+ const out = fn ? gates(fn) : new Set<string>()
271
+ if (fn && depth < 3) {
272
+ for (const { tag, attr, prop } of handsOn(fn, sf)) {
273
+ // A dialog is shut in a story until someone opens it, so nothing it draws
274
+ // is missing from the picture (Katerina, 23 September).
275
+ if (out.has(prop) || /Dialog$/.test(tag)) continue
276
+ const here = declared(sf, tag) ? sf : fileOf.has(tag) ? sourceOf(fileOf.get(tag)!) : null
277
+ if (!here || !gatesOf(tag, here, depth + 1).has(attr)) continue
278
+ // Already pictured one level down: CollapsibleSection hands `trailing` to
279
+ // SectionHeader, and SectionHeader's own story shows the slot.
280
+ const theirs = fileOf.get(tag)
281
+ if (pictured === 'anywhere' && theirs && here !== sf) {
282
+ const { given } = storiesOf(tag, theirs)
283
+ if (given.has(attr) || given.has(ANY)) continue
284
+ }
285
+ out.add(prop)
286
+ }
287
+ }
288
+ memo.set(key, out)
289
+ return out
290
+ }
291
+
292
+ for (const entry of registry.entries) {
293
+ // The package's entries carry no app class: every one of its components is one.
294
+ const cls = entry.app?.class ?? 'component'
295
+ if (!kinds.includes(cls) || entry.kind !== 'component') continue
296
+ const sf = sourceOf(entry.sourceFile)
297
+ if (!sf) continue
298
+ const { drawn, given } = storiesOf(entry.name, entry.sourceFile)
299
+ // No story draws it at all: the contract's "drawn nowhere" says so, not this.
300
+ if (!drawn || given.has(ANY)) continue
301
+ for (const name of [entry.name, `${entry.name}View`]) {
302
+ for (const prop of gatesOf(name, sf, 0)) {
303
+ if (!given.has(prop)) findings.push({ part: entry.name, file: entry.sourceFile.split('\\').join('/'), prop, in: name })
304
+ }
305
+ }
306
+ }
307
+ return findings
308
+ }