@estiva-app/ui 0.35.1 → 0.35.2

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.
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { afterAll, describe, expect, it } from 'vitest'
20
20
  import { buildAppRegistry } from './app'
21
- import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
21
+ import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem } from './contract'
22
22
 
23
23
  const made: string[] = []
24
24
  afterAll(() => {
@@ -86,8 +86,11 @@ describe('a usage page', () => {
86
86
  expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
87
87
  })
88
88
 
89
- it('fails with no code under How', () => {
89
+ // R18: the code must be under How itself; one in a later section passed.
90
+ it('fails with no code under How, even with code further down', () => {
90
91
  expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
92
+ const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
93
+ expect(pageProblem(later)).toBe('no code under "How"')
91
94
  })
92
95
 
93
96
  it('reads a Windows checkout', () => {
@@ -156,3 +159,35 @@ describe('story links', () => {
156
159
  ])
157
160
  })
158
161
  })
162
+
163
+ // R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
164
+ // StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
165
+ // deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
166
+ // named the package's story by a title it had lost.
167
+ describe('nameProblems', () => {
168
+ const page = (whenNot: string, seenIn = '') =>
169
+ PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
170
+ const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
171
+ const known = new Set(['Badge', 'Chip'])
172
+
173
+ it('names a part under When not that no catalogue has, and passes one that exists', () => {
174
+ const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
175
+ expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
176
+ })
177
+
178
+ it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
179
+ const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
180
+ const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
181
+ const titles = new Set(['Parts/Badge'])
182
+ expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
183
+ 'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
184
+ 'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
185
+ ])
186
+ expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
187
+ })
188
+
189
+ it('leaves the Seen in line alone when there is no Storybook index to read', () => {
190
+ const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
191
+ expect(nameProblems(registry(dir), dir, { known })).toEqual([])
192
+ })
193
+ })
@@ -51,7 +51,10 @@ export function pageProblem(source: string): string | null {
51
51
  .map((s) => s.trim())
52
52
  .find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
53
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"'
54
+ // Only the How section: the code block must sit under How, not in a later section
55
+ // (a page with none under How and one under What it owns passed, R18).
56
+ const how = text.split(/^##\s+How\s*$/m)[1]?.split(/^##\s/m)[0] ?? ''
57
+ if (!/```tsx?\n/.test(how)) return 'no code under "How"'
55
58
  return null
56
59
  }
57
60
 
@@ -106,3 +109,48 @@ export function contractProblems(registry: Registry, root: string): string[] {
106
109
  }
107
110
  return problems
108
111
  }
112
+
113
+ /** Storybook's id for a title: lower case, every run of other characters one hyphen. */
114
+ const toStoryId = (title: string) =>
115
+ title
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, '-')
118
+ .replace(/^-+|-+$/g, '')
119
+
120
+ /** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
121
+ function namedParts(section: string): string[] {
122
+ return [...section.matchAll(/\*\*([A-Z][A-Za-z0-9]+)\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
123
+ }
124
+
125
+ /**
126
+ * What an app's usage pages name that is not there (the re-review after the audit
127
+ * before UIG-26, R14): a part named under When or When not that no catalogue has —
128
+ * Peek's StartTopicDialog sent readers to CreateTopicDialog, deleted by FOL-23 —
129
+ * and a story a **Seen in** line names that no Storybook has. `known` is every part
130
+ * of the package and the app; `titles` is the app's Storybook titles, when there
131
+ * is an index to read them from; `packageIds` the package's story and docs ids, so
132
+ * a page may send readers to the package's Storybook too.
133
+ */
134
+ export function nameProblems(registry: Registry, root: string, { known, titles, packageIds = [] }: { known: Set<string>; titles?: Set<string>; packageIds?: string[] }): string[] {
135
+ const problems: string[] = []
136
+ for (const entry of registry.entries) {
137
+ if (!entry.docPage || !existsSync(join(root, entry.docPage))) continue
138
+ const text = readFileSync(join(root, entry.docPage), 'utf8').replace(/\r\n/g, '\n')
139
+ for (const heading of ['When', 'When not']) {
140
+ const section = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))[1]?.split(/^##\s/m)[0] ?? ''
141
+ for (const name of new Set(namedParts(section).filter((n) => !known.has(n)))) {
142
+ problems.push(`${entry.docPage}: "${heading}" names ${name}, which is no part of the package or this app`)
143
+ }
144
+ }
145
+ if (!titles) continue
146
+ const seenIn = text.match(/^\*\*Seen in\*\*[^\n]*(?:\n(?!\n)[^\n]*)*/m)?.[0] ?? ''
147
+ for (const [, raw] of seenIn.matchAll(/\*([A-Z][^*/]*\/[^*]+)\*/g)) {
148
+ // A title may wrap across lines, and a line may break after its slash.
149
+ const title = raw.replace(/\s+/g, ' ').replace(/\s*\/\s*/g, '/').trim()
150
+ const id = toStoryId(title)
151
+ if (titles.has(title) || packageIds.some((p) => p.startsWith(`${id}--`))) continue
152
+ problems.push(`${entry.docPage}: "Seen in" names ${title}, which no Storybook has`)
153
+ }
154
+ }
155
+ return problems
156
+ }
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
11
11
  import { dirname, join } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { afterAll, describe, expect, it } from 'vitest'
14
- import { appName, findSiblings, workspaceOf } from './siblings'
14
+ import { appName, behindBy, findSiblings, workspaceOf } from './siblings'
15
15
 
16
16
  const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
17
17
  afterAll(() => rmSync(workspace, { recursive: true, force: true }))
@@ -83,3 +83,26 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
83
83
  expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
84
84
  })
85
85
  })
86
+
87
+ // The re-review after the audit before UIG-26: a search from one repository read a
88
+ // neighbour's checkout eleven commits behind, and answered with a part it had deleted.
89
+ describe('behindBy', () => {
90
+ it('says how far a checkout is behind its main as last fetched, and null for a folder that is none', () => {
91
+ const base = mkdtempSync(join(tmpdir(), 'behind-'))
92
+ const git = (cwd: string, ...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, stdio: 'pipe' })
93
+ git(base, 'init', '-q', '--bare', '-b', 'main', 'origin.git')
94
+ git(base, 'clone', '-q', 'origin.git', 'writer')
95
+ git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'one')
96
+ git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
97
+ git(base, 'clone', '-q', 'origin.git', 'reader')
98
+ const reader = join(base, 'reader')
99
+ expect(behindBy(reader)).toBe(0)
100
+ git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'two')
101
+ git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
102
+ expect(behindBy(reader)).toBe(0)
103
+ git(reader, 'fetch', '-q')
104
+ expect(behindBy(reader)).toBe(1)
105
+ expect(behindBy(tmpdir())).toBeNull()
106
+ rmSync(base, { recursive: true, force: true })
107
+ })
108
+ })
@@ -16,6 +16,7 @@
16
16
  * somebody's copy) carries the same package name; the app the search runs in
17
17
  * wins, then the one whose top folder has the shortest name.
18
18
  */
19
+ import { execFileSync } from 'node:child_process'
19
20
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
21
  import { basename, dirname, join, resolve } from 'node:path'
21
22
 
@@ -94,3 +95,25 @@ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[]
94
95
  }
95
96
  return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
96
97
  }
98
+
99
+ /**
100
+ * How many commits the checkout that holds `folder` is behind its remote's main,
101
+ * as last fetched: nothing is fetched, so a search stays quick. Null when it is
102
+ * not a git checkout or has no remote main. A search that reads a stale copy
103
+ * answers with parts that are gone (the re-review after the audit before UIG-26:
104
+ * "files panel" found Peek's deleted FilesPanel in an old checkout), so `find`
105
+ * says so beside its answer.
106
+ */
107
+ export function behindBy(folder: string): number | null {
108
+ const git = (...a: string[]) => {
109
+ try {
110
+ return execFileSync('git', ['-C', folder, ...a], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim()
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+ const main = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? (git('rev-parse', '--verify', '--quiet', 'origin/main') ? 'origin/main' : null)
116
+ if (!main) return null
117
+ const count = Number(git('rev-list', '--count', `HEAD..${main}`))
118
+ return Number.isFinite(count) ? count : null
119
+ }
@@ -1,16 +1,18 @@
1
1
  /// <reference types="node" />
2
2
  /**
3
- * The app's hand-written map of its Storybook (audit A3). Every case is Peek's:
4
- * the Huddles heading its Introduction and `HEADINGS` kept after FOL-23 deleted
5
- * the stories, the "49 parts worth reusing" it kept when the catalogue built 41,
6
- * and a Storybook with no map at all, which has nothing to rot.
3
+ * The hand-written map of a Storybook (audit A3, widened by R16). Every case is a
4
+ * real one: Peek's Huddles heading, kept in its Introduction and `HEADINGS` after
5
+ * FOL-23 deleted the stories, and its "49 parts worth reusing" when the catalogue
6
+ * built 41; Ship's `storySort.order` naming a Feedback heading it does not have and
7
+ * leaving out Pages, and its Introduction's bullets; the package's order leaving
8
+ * out Layout; and a Storybook with no map at all, which has nothing to rot.
7
9
  */
8
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
11
  import { tmpdir } from 'node:os'
10
12
  import { dirname, join } from 'node:path'
11
13
  import { afterAll, describe, expect, it } from 'vitest'
12
14
  import type { Registry } from './schema'
13
- import { introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
15
+ import { indexHeadings, introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
14
16
 
15
17
  const made: string[] = []
16
18
  afterAll(() => {
@@ -31,66 +33,85 @@ function app(files: Record<string, string>): string {
31
33
  const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
32
34
 
33
35
  const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
34
- const PREVIEW = (headings: string[]) =>
36
+ const HEADINGS_PREVIEW = (headings: string[]) =>
35
37
  `export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
36
- const INTRO = (count: number, headings: string[]) =>
38
+ const ORDER_PREVIEW = (headings: string[]) =>
39
+ `export default {\n parameters: {\n options: {\n // Docs first; the rest follows\n storySort: {\n order: [${headings.map((h) => (h === 'Docs' ? "'Docs', ['Introduction']" : `'${h}'`)).join(', ')}],\n },\n },\n },\n}\n`
40
+ const TABLE_INTRO = (count: number, headings: string[]) =>
37
41
  `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title="Docs/Introduction" />\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
42
+ const BULLET_INTRO = (groups: string[][]) =>
43
+ `<Meta title="Docs/Introduction" />\n\n## How the sidebar is organised\n\n${groups.map((g) => `- **${g.join(' · ')}** — about them.`).join('\n')}\n\n## Conventions\n\n- **Stories first.** A component exists in Storybook first.\n`
38
44
 
39
45
  const PEEK_TODAY = {
40
- 'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives', 'Huddles']),
46
+ 'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives', 'Huddles']),
41
47
  'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
42
48
  'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title='Primitives/Chip' />\n\n# Chip\n`,
43
- '.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives', 'Huddles']),
49
+ '.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives', 'Huddles']),
44
50
  }
45
51
 
46
52
  describe('reading the map', () => {
47
- it('reads the sidebar from story titles and from page titles in either quote', () => {
48
- expect([...sidebarHeadings(app(PEEK_TODAY))].sort()).toEqual(['Docs', 'Primitives'])
53
+ it('reads the sidebar from story titles and page titles in either quote, in src and stories', () => {
54
+ expect([...sidebarHeadings(app({ ...PEEK_TODAY, 'stories/Tokens.mdx': "<Meta title='Tokens/All' />\n" }))].sort()).toEqual(['Docs', 'Primitives', 'Tokens'])
49
55
  })
50
56
 
51
- it('reads the order with its comments removed, and says when there is none', () => {
52
- expect(orderedHeadings(PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
57
+ it("reads Storybook's own index, whatever way a title was written", () => {
58
+ expect([...indexHeadings({ a: { title: 'Pages/Desk' }, b: { title: 'Docs/Introduction' }, c: {} })].sort()).toEqual(['Docs', 'Pages'])
59
+ })
60
+
61
+ it('reads a HEADINGS list and a storySort order, skipping comments and nested lists', () => {
62
+ expect(orderedHeadings(HEADINGS_PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
63
+ expect(orderedHeadings(ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']))).toEqual(['Docs', 'Primitives', 'Feedback'])
53
64
  expect(orderedHeadings('export default {}')).toBeNull()
54
65
  })
55
66
 
56
- it('reads the Introduction: its bold table rows and its stated count', () => {
57
- const intro = INTRO(49, ['Docs', 'Huddles'])
58
- expect([...introductionHeadings(intro)]).toEqual(['Docs', 'Huddles'])
59
- expect(introductionCount(intro)).toBe(49)
67
+ it("reads the Introduction's table rows and its bullets, and its stated count", () => {
68
+ expect([...introductionHeadings(TABLE_INTRO(49, ['Docs', 'Huddles']))]).toEqual(['Docs', 'Huddles'])
69
+ // A bullet with no dash after its bold words is prose, not a heading.
70
+ expect([...introductionHeadings(BULLET_INTRO([['Docs'], ['Primitives', 'Feedback', 'Inputs']]))]).toEqual(['Docs', 'Primitives', 'Feedback', 'Inputs'])
71
+ expect(introductionCount(TABLE_INTRO(49, ['Docs']))).toBe(49)
60
72
  expect(introductionCount('# No number here')).toBeNull()
61
73
  })
62
74
  })
63
75
 
64
76
  describe('storyMapProblems', () => {
65
77
  it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
66
- const problems = storyMapProblems(catalogue('reusable', 'reusable', 'reusable', 'local'), app(PEEK_TODAY))
67
- expect(problems).toEqual([
68
- '.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
78
+ expect(storyMapProblems(app(PEEK_TODAY), { registry: catalogue('reusable', 'reusable', 'reusable', 'local') })).toEqual([
79
+ '.storybook/preview.tsx: in the heading order but no story uses them: Huddles',
69
80
  'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
70
81
  'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
71
82
  ])
72
83
  })
73
84
 
74
- it('finds a heading the maps forget, which falls to the bottom of the sidebar', () => {
75
- const dir = app({ ...PEEK_TODAY, 'src/pages/Desk.stories.tsx': STORY('Pages/Desk') })
76
- expect(storyMapProblems(catalogue('reusable', 'promote-candidate'), dir)).toEqual([
77
- '.storybook/preview.tsx: not named in HEADINGS, so they fall to the bottom of the sidebar: Pages',
78
- '.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
85
+ it("finds Ship's: an order and bullets naming Feedback, which it has not got, and leaving out Pages", () => {
86
+ const dir = app({
87
+ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']),
88
+ 'src/stories/Introduction.mdx': BULLET_INTRO([['Docs'], ['Primitives', 'Feedback']]),
89
+ })
90
+ expect(storyMapProblems(dir, { real: new Set(['Docs', 'Primitives', 'Pages']) })).toEqual([
91
+ '.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Pages',
92
+ '.storybook/preview.tsx: in the heading order but no story uses them: Feedback',
79
93
  'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
80
- 'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
94
+ 'src/stories/Introduction.mdx: the page names, the sidebar does not have: Feedback',
95
+ ])
96
+ })
97
+
98
+ it("finds the package's order leaving out Layout, from a Storybook index", () => {
99
+ const dir = app({ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives']) })
100
+ expect(storyMapProblems(dir, { real: indexHeadings({ a: { title: 'Layout/ListColumn' }, b: { title: 'Primitives/Button' }, c: { title: 'Docs/Introduction' } }) })).toEqual([
101
+ '.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Layout',
81
102
  ])
82
103
  })
83
104
 
84
105
  it('passes when both maps match the Storybook and the count matches the catalogue', () => {
85
106
  const dir = app({
86
107
  ...PEEK_TODAY,
87
- 'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives']),
88
- '.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives']),
108
+ 'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives']),
109
+ '.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives']),
89
110
  })
90
- expect(storyMapProblems(catalogue('reusable', 'promote-candidate', 'local'), dir)).toEqual([])
111
+ expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
91
112
  })
92
113
 
93
- it('has nothing to check in an app that keeps no map', () => {
94
- expect(storyMapProblems(catalogue('reusable'), app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }))).toEqual([])
114
+ it('has nothing to check in a repository that keeps no map', () => {
115
+ expect(storyMapProblems(app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }), { registry: catalogue('reusable') })).toEqual([])
95
116
  })
96
117
  })
@@ -1,26 +1,30 @@
1
1
  /// <reference types="node" />
2
2
  /**
3
- * The hand-written map of an app's Storybook, held against the Storybook it
4
- * describes (moved here from Peek's own status checks, audit A3).
3
+ * The hand-written map of a Storybook, held against the Storybook it describes
4
+ * (moved here from Peek's own status checks, audit A3; widened by the re-review
5
+ * after it, R16).
5
6
  *
6
- * An app may keep two maps of its sidebar: the order of its top-level headings
7
- * (`const HEADINGS = [...]` in `.storybook/preview.tsx`, read by `storySort`),
8
- * and the Introduction page (`src/stories/Introduction.mdx`), which names each
9
- * heading in a table row and states how many parts are worth reusing. Both are
10
- * written by hand and both describe something that moves, so both rot: Peek's
11
- * named a Huddles heading for a day after its stories were deleted, and said 49
12
- * reusable parts when the catalogue built 41. Its own checks saw it, but they
13
- * ran only in `gates:status`, never in CI.
7
+ * A repository may keep two maps of its sidebar: the order of its top-level
8
+ * headings in `.storybook/preview.tsx` — a `const HEADINGS = [...]` list read by
9
+ * a `storySort` function (Peek), or `storySort: { order: [...] }` (Ship, this
10
+ * package) — and the Introduction page, which names each heading, in a table row
11
+ * (`| **Folders** | … |`, Peek) or a bullet (`- **Primitives · Inputs** — …`,
12
+ * Ship), and in an app states how many parts are worth reusing. Both are written
13
+ * by hand and both describe something that moves, so both rot: Peek's named a
14
+ * Huddles heading for a day after its stories were deleted and said 49 reusable
15
+ * parts when the catalogue built 41; Ship's named a Feedback heading it does not
16
+ * have and left out four it does.
14
17
  *
15
- * Every check here runs only when the app states the thing it checks: an app
16
- * with no `HEADINGS` list, or no Introduction, has nothing to rot.
18
+ * The real headings are read from Storybook's own index when the command has
19
+ * one, so a title written any way at all counts; otherwise from the story files.
20
+ * Every check runs only when the repository states the thing it checks.
17
21
  */
18
22
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
19
23
  import { join } from 'node:path'
20
24
  import type { Registry } from './schema'
21
25
 
22
26
  const PREVIEW = '.storybook/preview.tsx'
23
- const INTRODUCTION = 'src/stories/Introduction.mdx'
27
+ const INTRODUCTIONS = ['src/stories/Introduction.mdx', 'stories/Introduction.mdx']
24
28
  /** The classes an app's Introduction counts as "worth reusing". */
25
29
  const WORTH_REUSING = ['reusable', 'promote-candidate']
26
30
 
@@ -36,10 +40,11 @@ function storyFiles(root: string): string[] {
36
40
  }
37
41
  }
38
42
  walk(join(root, 'src'))
43
+ walk(join(root, 'stories'))
39
44
  return out
40
45
  }
41
46
 
42
- /** Every top-level heading the sidebar has: the first segment of each story file's or page's title. */
47
+ /** Every top-level heading the sidebar has, read from the story files: the first segment of each title. */
43
48
  export function sidebarHeadings(root: string): Set<string> {
44
49
  const real = new Set<string>()
45
50
  for (const file of storyFiles(root)) {
@@ -52,19 +57,50 @@ export function sidebarHeadings(root: string): Set<string> {
52
57
  return real
53
58
  }
54
59
 
55
- /** The headings `preview.tsx` orders, or null when it keeps no list. */
60
+ /** The top-level headings of Storybook's own index (`storybook index`): the first segment of every entry's title. */
61
+ export function indexHeadings(entries: Record<string, { title?: string }>): Set<string> {
62
+ return new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title.split('/')[0].trim()] : [])))
63
+ }
64
+
65
+ /** The strings at the top level of the array that starts at `open` (the index of its `[`), skipping nested arrays. */
66
+ function topLevelStrings(text: string, open: number): string[] {
67
+ const out: string[] = []
68
+ let depth = 0
69
+ for (let i = open; i < text.length; i++) {
70
+ const c = text[i]
71
+ if (c === '/' && text[i + 1] === '*') {
72
+ const end = text.indexOf('*/', i + 2)
73
+ if (end < 0) break
74
+ i = end + 1
75
+ } else if (c === '/' && text[i + 1] === '/') i = text.indexOf('\n', i)
76
+ else if (c === '[') depth++
77
+ else if (c === ']') {
78
+ if (--depth === 0) return out
79
+ } else if (c === "'" || c === '"') {
80
+ const end = text.indexOf(c, i + 1)
81
+ if (depth === 1) out.push(text.slice(i + 1, end))
82
+ i = end
83
+ }
84
+ if (i < 0) break
85
+ }
86
+ return out
87
+ }
88
+
89
+ /** The headings the preview orders — a `HEADINGS` list or `storySort: { order: [...] }` — or null when it keeps no order. */
56
90
  export function orderedHeadings(previewText: string): string[] | null {
57
- const block = previewText.match(/const HEADINGS = \[([\s\S]*?)\]/)
58
- if (!block) return null
59
- // Comments first: the list is commented per heading, and an apostrophe in a
60
- // comment would open a string that swallows the rest.
61
- const listed = block[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
62
- return [...listed.matchAll(/'([^']+)'/g)].map((m) => m[1])
91
+ const list = /const HEADINGS = \[/.exec(previewText) ?? /\border:\s*\[/.exec(previewText)
92
+ if (!list) return null
93
+ return topLevelStrings(previewText, list.index + list[0].length - 1)
63
94
  }
64
95
 
65
- /** The headings the Introduction names, one per table row that opens with a bold name. */
96
+ /**
97
+ * The headings the Introduction names: a table row that opens with a bold name,
98
+ * or a bullet that opens with bold names joined by " · ".
99
+ */
66
100
  export function introductionHeadings(introText: string): Set<string> {
67
- return new Set([...introText.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gm)].map((m) => m[1].trim()))
101
+ const rows = [...introText.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gm)].map((m) => m[1])
102
+ const bullets = [...introText.matchAll(/^[-*]\s+\*\*([^*]+)\*\*\s*[—–-]/gm)].flatMap((m) => m[1].split('·'))
103
+ return new Set([...rows, ...bullets].map((h) => h.trim()).filter(Boolean))
68
104
  }
69
105
 
70
106
  /** The number the Introduction states, or null when it states none. */
@@ -73,34 +109,37 @@ export function introductionCount(introText: string): number | null {
73
109
  return said ? Number(said[1]) : null
74
110
  }
75
111
 
76
- /** What is wrong with the app's hand-written map of its Storybook; empty when nothing is. */
77
- export function storyMapProblems(registry: Registry, root: string): string[] {
112
+ /**
113
+ * What is wrong with the repository's hand-written map of its Storybook; empty
114
+ * when nothing is. `real` is the sidebar's top-level headings from Storybook's
115
+ * index, when the caller has it; `registry` is needed only for an app's count.
116
+ */
117
+ export function storyMapProblems(root: string, { registry, real = sidebarHeadings(root) }: { registry?: Registry; real?: Set<string> } = {}): string[] {
78
118
  const problems: string[] = []
79
- const real = sidebarHeadings(root)
80
119
  const previewPath = join(root, PREVIEW)
81
120
  if (existsSync(previewPath)) {
82
121
  const ordered = orderedHeadings(readFileSync(previewPath, 'utf8'))
83
122
  if (ordered) {
84
123
  const unordered = [...real].filter((r) => !ordered.includes(r))
85
124
  const empty = ordered.filter((o) => !real.has(o))
86
- if (unordered.length) problems.push(`${PREVIEW}: not named in HEADINGS, so they fall to the bottom of the sidebar: ${unordered.join(', ')}`)
87
- if (empty.length) problems.push(`${PREVIEW}: named in HEADINGS but no story uses them: ${empty.join(', ')}`)
125
+ if (unordered.length) problems.push(`${PREVIEW}: not in the heading order, so they fall to the bottom of the sidebar: ${unordered.join(', ')}`)
126
+ if (empty.length) problems.push(`${PREVIEW}: in the heading order but no story uses them: ${empty.join(', ')}`)
88
127
  }
89
128
  }
90
- const introPath = join(root, INTRODUCTION)
91
- if (existsSync(introPath)) {
92
- const intro = readFileSync(introPath, 'utf8')
93
- const named = introductionHeadings(intro)
129
+ const intro = INTRODUCTIONS.find((p) => existsSync(join(root, p)))
130
+ if (intro) {
131
+ const text = readFileSync(join(root, intro), 'utf8')
132
+ const named = introductionHeadings(text)
94
133
  if (named.size) {
95
134
  const missing = [...real].filter((r) => !named.has(r))
96
135
  const stale = [...named].filter((n) => !real.has(n))
97
- if (missing.length) problems.push(`${INTRODUCTION}: the sidebar has, the page does not name: ${missing.join(', ')}`)
98
- if (stale.length) problems.push(`${INTRODUCTION}: the page names, the sidebar does not have: ${stale.join(', ')}`)
136
+ if (missing.length) problems.push(`${intro}: the sidebar has, the page does not name: ${missing.join(', ')}`)
137
+ if (stale.length) problems.push(`${intro}: the page names, the sidebar does not have: ${stale.join(', ')}`)
99
138
  }
100
- const said = introductionCount(intro)
101
- if (said !== null) {
139
+ const said = introductionCount(text)
140
+ if (said !== null && registry) {
102
141
  const reusable = registry.entries.filter((entry) => WORTH_REUSING.includes(entry.app?.class ?? '')).length
103
- if (said !== reusable) problems.push(`${INTRODUCTION}: says ${said} parts worth reusing; the catalogue builds ${reusable}`)
142
+ if (said !== reusable) problems.push(`${intro}: says ${said} parts worth reusing; the catalogue builds ${reusable}`)
104
143
  }
105
144
  }
106
145
  return problems
@@ -68,12 +68,16 @@ fork freely, owe nothing back, mention it on the package's ticket.
68
68
  | One window to find something and act on it, opened by a shortcut | **CommandPalette** — rows, forms and written answers, one level at a time |
69
69
  | Anything with a title, body and footer | **DialogShell** |
70
70
  | "Are you sure?" | **ConfirmDialog** |
71
+ | A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |
71
72
 
72
73
  ## Structure and wayfinding
73
74
 
74
75
  | You need | Reach for |
75
76
  |---|---|
76
77
  | Label–value rows (a details rail) | **Property** — `row` and `stacked` |
78
+ | The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — one per column; the actions as IconButtons in `actions` |
79
+ | A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
80
+ | A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
77
81
  | One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
78
82
  | A section heading, chevron, hover actions | **SectionHeader** |
79
83
  | A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
@@ -95,6 +99,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
95
99
  | It happened, briefly | **Toast**, via `useToast` inside a `ToastProvider` |
96
100
  | It happened, and must not be missed | **Toast** with `durationMs: 0` and an action — it floats and stays until dismissed (D21) |
97
101
  | The whole surface has something to say | **Banner** — the strip under the top bar, full width; `onDismiss` if the reader closes it rather than the app |
102
+ | A part that breaks without taking the page with it | **ErrorBoundary** — around each column, panel or conversation, and once around the app; `label` says what it holds |
98
103
 
99
104
  ## The frame
100
105
 
@@ -109,11 +114,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
109
114
  | A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
110
115
  | A 64px icon strip | **Rail**, filled with **RailItem** tiles |
111
116
  | The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
117
+ | The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
112
118
 
113
119
  ## Deliberately not in the package
114
120
 
115
121
  - Anything that knows your **product's objects** — its documents, threads,
116
122
  files, tickets. Build it in your app, out of these parts, and keep its
117
123
  stories in your app's own Storybook.
118
- - Page layouts — panels inside the content, split views, side rails —
119
- not here yet; they are the next batch.
124
+ - Page layouts beyond the list column (**ListColumn**) and a column's bar
125
+ (**ContainerHeader**) — split views, side rails — not here yet.