@estiva-app/ui 0.35.1 → 0.36.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/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +70 -9
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +41 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +93 -34
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +27 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/dist/registry/siblings.d.ts +9 -0
- package/dist/registry/siblings.d.ts.map +1 -1
- package/dist/registry/storymap.d.ts +19 -5
- package/dist/registry/storymap.d.ts.map +1 -1
- package/package.json +4 -1
- package/registry.json +293 -17
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +4 -0
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +2 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +4 -3
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +6 -2
- package/src/gates/gates.test.ts +93 -1
- package/src/gates/hook.ts +44 -6
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/pages.test.ts +17 -0
- package/src/registry/app.test.ts +34 -0
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +37 -18
- package/src/registry/contract.test.ts +63 -3
- package/src/registry/contract.ts +75 -2
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +14 -0
- package/src/registry/siblings.test.ts +24 -1
- package/src/registry/siblings.ts +23 -0
- package/src/registry/storymap.test.ts +52 -31
- package/src/registry/storymap.ts +76 -37
- package/stories/Choosing.mdx +8 -2
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/gates/chunk-GI6M4P7H.js +0 -606
- package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
package/src/registry/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -84,7 +87,7 @@ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): stri
|
|
|
84
87
|
*
|
|
85
88
|
* `root` is the app's own folder: the one its catalogue's paths are relative to.
|
|
86
89
|
*/
|
|
87
|
-
export function contractProblems(registry: Registry, root: string): string[] {
|
|
90
|
+
export function contractProblems(registry: Registry, root: string, known: ReadonlySet<string> = new Set(registry.entries.map((e) => e.name))): string[] {
|
|
88
91
|
const problems: string[] = []
|
|
89
92
|
for (const entry of registry.entries) {
|
|
90
93
|
const cls = entry.app?.class
|
|
@@ -100,9 +103,79 @@ export function contractProblems(registry: Registry, root: string): string[] {
|
|
|
100
103
|
const text = readFileSync(join(root, page), 'utf8')
|
|
101
104
|
const problem = pageProblem(text)
|
|
102
105
|
if (problem) problems.push(`${where}: ${page} has ${problem}`)
|
|
106
|
+
const whenNot = whenNotProblem(text, [entry.name], known)
|
|
107
|
+
if (whenNot) problems.push(`${where}: ${page}: ${whenNot}`)
|
|
103
108
|
if (entry.storyId === null && !hasSeenIn(text)) {
|
|
104
109
|
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
110
|
}
|
|
111
|
+
// A reusable part links to its page or a story (Katerina's ruling B10, 25 September):
|
|
112
|
+
// the catalogue reads the page's `<Meta title>`, so a page without one leaves it unlinked.
|
|
113
|
+
if (entry.storyId === null && entry.docsId === null) {
|
|
114
|
+
problems.push(`${where} links to nothing in Storybook: give ${page} a \`<Meta title="…" />\`, or give the part a story`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return problems
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Storybook's id for a title: lower case, every run of other characters one hyphen. */
|
|
121
|
+
const toStoryId = (title: string) =>
|
|
122
|
+
title
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
125
|
+
.replace(/^-+|-+$/g, '')
|
|
126
|
+
|
|
127
|
+
/** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
|
|
128
|
+
export function namedParts(section: string): string[] {
|
|
129
|
+
return [...section.matchAll(/\*\*`?([A-Z][A-Za-z0-9]+)`?\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The line a "When not" starts where no part stands in (Katerina's ruling B8). */
|
|
133
|
+
export const NO_ALTERNATIVE = /^No alternative:/m
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Why a page's `When not` breaks the ruling B8 (25 September), or null. The plan
|
|
137
|
+
* asked for "When not — naming the alternative"; CI checked only that the
|
|
138
|
+
* heading was there, and 29 pages across the three repos named nothing. Now the
|
|
139
|
+
* section names a real part other than the page's own (`own`), from `known`, or
|
|
140
|
+
* a line of it starts "No alternative:" and says why there is none.
|
|
141
|
+
*/
|
|
142
|
+
export function whenNotProblem(source: string, own: readonly string[], known: ReadonlySet<string>): string | null {
|
|
143
|
+
const section = source.replace(/\r\n/g, '\n').split(/^##\s+When not\s*$/m)[1]?.split(/^##\s/m)[0]
|
|
144
|
+
// A missing section is pageProblem's to report.
|
|
145
|
+
if (section === undefined || NO_ALTERNATIVE.test(section)) return null
|
|
146
|
+
if (namedParts(section).some((name) => known.has(name) && !own.includes(name))) return null
|
|
147
|
+
return '"When not" names no other part: name the part to use instead, as **Name** or `Name`, or start a line "No alternative:" that says why there is none'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What an app's usage pages name that is not there (the re-review after the audit
|
|
152
|
+
* before UIG-26, R14): a part named under When or When not that no catalogue has —
|
|
153
|
+
* Peek's StartTopicDialog sent readers to CreateTopicDialog, deleted by FOL-23 —
|
|
154
|
+
* and a story a **Seen in** line names that no Storybook has. `known` is every part
|
|
155
|
+
* of the package and the app; `titles` is the app's Storybook titles, when there
|
|
156
|
+
* is an index to read them from; `packageIds` the package's story and docs ids, so
|
|
157
|
+
* a page may send readers to the package's Storybook too.
|
|
158
|
+
*/
|
|
159
|
+
export function nameProblems(registry: Registry, root: string, { known, titles, packageIds = [] }: { known: Set<string>; titles?: Set<string>; packageIds?: string[] }): string[] {
|
|
160
|
+
const problems: string[] = []
|
|
161
|
+
for (const entry of registry.entries) {
|
|
162
|
+
if (!entry.docPage || !existsSync(join(root, entry.docPage))) continue
|
|
163
|
+
const text = readFileSync(join(root, entry.docPage), 'utf8').replace(/\r\n/g, '\n')
|
|
164
|
+
for (const heading of ['When', 'When not']) {
|
|
165
|
+
const section = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))[1]?.split(/^##\s/m)[0] ?? ''
|
|
166
|
+
for (const name of new Set(namedParts(section).filter((n) => !known.has(n)))) {
|
|
167
|
+
problems.push(`${entry.docPage}: "${heading}" names ${name}, which is no part of the package or this app`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!titles) continue
|
|
171
|
+
const seenIn = text.match(/^\*\*Seen in\*\*[^\n]*(?:\n(?!\n)[^\n]*)*/m)?.[0] ?? ''
|
|
172
|
+
for (const [, raw] of seenIn.matchAll(/\*([A-Z][^*/]*\/[^*]+)\*/g)) {
|
|
173
|
+
// A title may wrap across lines, and a line may break after its slash.
|
|
174
|
+
const title = raw.replace(/\s+/g, ' ').replace(/\s*\/\s*/g, '/').trim()
|
|
175
|
+
const id = toStoryId(title)
|
|
176
|
+
if (titles.has(title) || packageIds.some((p) => p.startsWith(`${id}--`))) continue
|
|
177
|
+
problems.push(`${entry.docPage}: "Seen in" names ${title}, which no Storybook has`)
|
|
178
|
+
}
|
|
106
179
|
}
|
|
107
180
|
return problems
|
|
108
181
|
}
|
package/src/registry/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
|
|
14
14
|
export { buildAppRegistry, type AppBuildOptions } from './app'
|
|
15
|
-
export { CONTRACT_KINDS, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
15
|
+
export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, namedParts, pageProblem, whenNotProblem } from './contract'
|
|
16
16
|
export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
|
|
17
17
|
export {
|
|
18
18
|
CLASSES,
|
|
@@ -80,6 +80,20 @@ describe('the registry builds', () => {
|
|
|
80
80
|
expect(entry('Card').ownsBehaviours).toEqual([])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
// B9: worked out from what a part draws and what it is, not only UIG-8's owners.
|
|
84
|
+
it('gives a part what the part it is owns, and a Base UI piece only what it is', () => {
|
|
85
|
+
const ids = (name: string) => entry(name).ownsBehaviours.map((owned) => owned.id)
|
|
86
|
+
// ConfirmDialog returns a DialogShell: it keeps focus inside and stops the page scrolling.
|
|
87
|
+
expect(ids('ConfirmDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock', 'press-outside']))
|
|
88
|
+
// MenuItem draws Menu.Item: a menu item, not a floating menu.
|
|
89
|
+
expect(ids('MenuItem')).toEqual(expect.arrayContaining(['base-ui', 'role']))
|
|
90
|
+
expect(ids('MenuItem')).not.toContain('portal')
|
|
91
|
+
// A tooltip's floating stays the tooltip's.
|
|
92
|
+
expect(ids('NavItem')).not.toContain('portal')
|
|
93
|
+
// Being built on Base UI, a role and a Tab stop are not handed on.
|
|
94
|
+
expect(ids('EmptyState')).not.toContain('tab-stop')
|
|
95
|
+
})
|
|
96
|
+
|
|
83
97
|
it('reads the variants from the props type, following a local alias', () => {
|
|
84
98
|
// ButtonVariant and ButtonSize are aliases; the props type points at them.
|
|
85
99
|
expect(entry('Button').variants).toEqual([
|
|
@@ -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
|
+
})
|
package/src/registry/siblings.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
* the stories,
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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':
|
|
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':
|
|
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
|
|
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(
|
|
52
|
-
expect(
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(
|
|
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
|
-
|
|
67
|
-
|
|
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(
|
|
75
|
-
const dir = app({
|
|
76
|
-
|
|
77
|
-
'
|
|
78
|
-
|
|
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:
|
|
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':
|
|
88
|
-
'.storybook/preview.tsx':
|
|
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')
|
|
111
|
+
expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
|
|
91
112
|
})
|
|
92
113
|
|
|
93
|
-
it('has nothing to check in
|
|
94
|
-
expect(storyMapProblems(
|
|
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
|
})
|
package/src/registry/storymap.ts
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/**
|
|
3
|
-
* The hand-written map of
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
58
|
-
if (!
|
|
59
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
|
87
|
-
if (empty.length) problems.push(`${PREVIEW}:
|
|
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
|
|
91
|
-
if (
|
|
92
|
-
const
|
|
93
|
-
const named = introductionHeadings(
|
|
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(`${
|
|
98
|
-
if (stale.length) problems.push(`${
|
|
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(
|
|
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(`${
|
|
142
|
+
if (said !== reusable) problems.push(`${intro}: says ${said} parts worth reusing; the catalogue builds ${reusable}`)
|
|
104
143
|
}
|
|
105
144
|
}
|
|
106
145
|
return problems
|
package/stories/Choosing.mdx
CHANGED
|
@@ -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
|
|
119
|
-
|
|
124
|
+
- Page layouts beyond the list column (**ListColumn**) and a column's bar
|
|
125
|
+
(**ContainerHeader**) — split views, side rails — not here yet.
|