@estiva-app/ui 0.46.0 → 0.48.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 (42) hide show
  1. package/dist/BlockNodes.d.ts +73 -0
  2. package/dist/BlockNodes.d.ts.map +1 -0
  3. package/dist/editor.d.ts +1 -0
  4. package/dist/editor.d.ts.map +1 -1
  5. package/dist/editor.js +141 -0
  6. package/dist/editor.js.map +4 -4
  7. package/dist/gates/app-checks.d.ts.map +1 -1
  8. package/dist/gates/{chunk-P5W6O5TF.js → chunk-BS5JASHV.js} +14 -9
  9. package/dist/gates/{chunk-P5W6O5TF.js.map → chunk-BS5JASHV.js.map} +3 -3
  10. package/dist/gates/cli.js +1 -1
  11. package/dist/gates/index.js +2 -1
  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/registry/{chunk-XK747STJ.js → chunk-4QWD3EBY.js} +5 -1
  16. package/dist/registry/chunk-4QWD3EBY.js.map +7 -0
  17. package/dist/registry/cli.js +4 -3
  18. package/dist/registry/cli.js.map +2 -2
  19. package/dist/registry/contract.d.ts +7 -0
  20. package/dist/registry/contract.d.ts.map +1 -1
  21. package/dist/registry/index.d.ts +1 -1
  22. package/dist/registry/index.d.ts.map +1 -1
  23. package/dist/registry/index.js +3 -1
  24. package/package.json +1 -1
  25. package/registry.json +79 -3
  26. package/skill/estiva-ui.md +14 -11
  27. package/src/BlockNodes.mdx +82 -0
  28. package/src/BlockNodes.stories.tsx +79 -0
  29. package/src/BlockNodes.test.tsx +117 -0
  30. package/src/BlockNodes.tsx +242 -0
  31. package/src/editor.ts +10 -0
  32. package/src/gates/app-checks.ts +1 -0
  33. package/src/gates/gates.test.ts +22 -0
  34. package/src/gates/status.ts +19 -8
  35. package/src/registry/cli.ts +2 -2
  36. package/src/registry/contract.test.ts +18 -2
  37. package/src/registry/contract.ts +10 -0
  38. package/src/registry/index.ts +1 -1
  39. package/src/registry/registry.test.ts +10 -8
  40. package/src/registry/skill.test.ts +2 -0
  41. package/stories/Choosing.mdx +1 -0
  42. package/dist/registry/chunk-XK747STJ.js.map +0 -7
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { AttachmentCard } from './AttachmentCard'
5
+ import { InlineChip } from './InlineChip'
6
+ import { richTextClassName } from './RichText'
7
+ import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type AttachmentViewProps, type ReferenceViewProps } from './BlockNodes'
8
+
9
+ /**
10
+ * The nodes an editor of a block document adds to StarterKit: an id on every
11
+ * block, a block it cannot edit kept whole, a reference and a file. The app
12
+ * draws the reference and the file with its own views.
13
+ */
14
+ const meta = {
15
+ title: 'Primitives/BlockNodes',
16
+ parameters: { layout: 'padded' },
17
+ } satisfies Meta
18
+
19
+ export default meta
20
+ type Story = StoryObj
21
+
22
+ /** What an app hands `ReferenceNode`: here a chip with a fixed label. */
23
+ function Chip({ uri }: ReferenceViewProps) {
24
+ return <InlineChip data-uri={uri}>Item one</InlineChip>
25
+ }
26
+
27
+ /** What an app hands `AttachmentNode`: here the package's card. */
28
+ function File({ attrs }: AttachmentViewProps) {
29
+ return <AttachmentCard name={String(attrs.filename ?? 'File')} size={Number(attrs.size ?? 0)} contentType={String(attrs.m ?? '')} />
30
+ }
31
+
32
+ /*
33
+ The editor's own JSON, as `@estiva-app/protocol`'s `toEditorDocument` hands
34
+ it over: an id on each block, and an unknown block carrying its text.
35
+ */
36
+ const DOCUMENT = {
37
+ type: 'doc',
38
+ content: [
39
+ {
40
+ type: 'paragraph',
41
+ attrs: { blockId: 'b1' },
42
+ content: [
43
+ { type: 'text', text: 'A paragraph, with a reference in it: ' },
44
+ { type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: [] } },
45
+ ],
46
+ },
47
+ {
48
+ type: 'unknownBlock',
49
+ attrs: { blockId: 'b2', source: { type: 'widget', id: 'b2' }, text: 'A block this editor has no design for, kept as it is.' },
50
+ },
51
+ { type: 'attachment', attrs: { blockId: 'b3', url: '', m: 'application/pdf', x: '', size: 20480, filename: 'Label.pdf' } },
52
+ { type: 'paragraph', attrs: { blockId: 'b4' }, content: [{ type: 'text', text: 'Type here: every block keeps its id.' }] },
53
+ ],
54
+ }
55
+
56
+ function WithAnEditor({ views }: { views: boolean }) {
57
+ const editor = useEditor({
58
+ extensions: [
59
+ StarterKit,
60
+ BlockId,
61
+ UnknownBlock,
62
+ ReferenceNode.configure({ view: views ? Chip : null }),
63
+ AttachmentNode.configure({ view: views ? File : null }),
64
+ ],
65
+ content: DOCUMENT,
66
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
67
+ })
68
+ return <EditorContent editor={editor} className="max-w-[560px]" />
69
+ }
70
+
71
+ /** A reference and a file drawn by the app's views, and a block kept whole. */
72
+ export const Default: Story = {
73
+ render: () => <WithAnEditor views />,
74
+ }
75
+
76
+ /** No views given: the reference is its URI and the file its name, as text. */
77
+ export const WithoutViews: Story = {
78
+ render: () => <WithAnEditor views={false} />,
79
+ }
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The block nodes (MAN-9, moved from Ship's `editorSchema.tsx`): what the
4
+ * editor must hand back unchanged for a save to keep every anchored comment.
5
+ *
6
+ * The JSON loaded here is what `@estiva-app/protocol`'s `toEditorDocument`
7
+ * produces, and what `getJSON()` returns is what its `fromEditorDocument`
8
+ * reads — so each test is a load, an edit or none, and a read back.
9
+ */
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import { render, screen, cleanup } from '@testing-library/react'
12
+ import { Editor } from '@tiptap/core'
13
+ import { EditorContent, useEditor } from '@tiptap/react'
14
+ import StarterKit from '@tiptap/starter-kit'
15
+ import { AttachmentNode, BlockId, ReferenceNode, UnknownBlock, type ReferenceViewProps } from './BlockNodes'
16
+
17
+ const EXTENSIONS = [StarterKit, BlockId, UnknownBlock, ReferenceNode, AttachmentNode]
18
+
19
+ const SOURCE = { type: 'widget', id: 'w1', content: [{ type: 'text', text: 'kept' }] }
20
+ const FILE = { url: '/media/f.png', m: 'image/png', x: 'f'.repeat(64), size: 4096 }
21
+
22
+ const DOCUMENT = {
23
+ type: 'doc',
24
+ content: [
25
+ { type: 'heading', attrs: { level: 1, blockId: 'h1' }, content: [{ type: 'text', text: 'Title' }] },
26
+ {
27
+ type: 'paragraph',
28
+ attrs: { blockId: 'p1' },
29
+ content: [
30
+ { type: 'text', text: 'see ' },
31
+ { type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } },
32
+ ],
33
+ },
34
+ { type: 'unknownBlock', attrs: { blockId: 'w1', source: SOURCE, text: 'kept' } },
35
+ { type: 'attachment', attrs: { blockId: 'a1', ...FILE, filename: 'shot.png' } },
36
+ { type: 'bulletList', attrs: { blockId: 'l1' }, content: [{ type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }] }] },
37
+ ],
38
+ }
39
+
40
+ type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
41
+
42
+ let editor: Editor | undefined
43
+ afterEach(() => {
44
+ editor?.destroy()
45
+ editor = undefined
46
+ cleanup()
47
+ })
48
+
49
+ describe('what the editor hands back', () => {
50
+ it('keeps every block id through a load and a read', () => {
51
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
52
+ const back = editor.getJSON() as Json
53
+ expect(back.content?.map((n) => n.attrs?.blockId)).toEqual(['h1', 'p1', 'w1', 'a1', 'l1'])
54
+ expect(back.content?.[4].content?.[0].attrs?.blockId).toBe('i1')
55
+ })
56
+
57
+ it('keeps a block id when its text is typed into', () => {
58
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
59
+ // The end of the heading's text: 1 opens the heading, 5 characters of "Title".
60
+ editor.chain().setTextSelection(6).insertContent(', edited').run()
61
+ const heading = (editor.getJSON() as Json).content?.[0]
62
+ expect(heading?.attrs?.blockId).toBe('h1')
63
+ expect(JSON.stringify(heading)).toContain('Title, edited')
64
+ })
65
+
66
+ it('carries an unknown block whole, source and all', () => {
67
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
68
+ const kept = (editor.getJSON() as Json).content?.[2]
69
+ expect(kept?.type).toBe('unknownBlock')
70
+ expect(kept?.attrs?.source).toEqual(SOURCE)
71
+ })
72
+
73
+ it('keeps a reference an atom, with its uri and the marks it carries', () => {
74
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
75
+ const reference = (editor.getJSON() as Json).content?.[1].content?.[1]
76
+ expect(reference).toEqual({ type: 'reference', attrs: { uri: 'nostr:naddr1example', marks: ['bold'] } })
77
+ })
78
+
79
+ it('keeps every imeta field on a file, which ProseMirror drops when undeclared', () => {
80
+ const rich = { ...FILE, dim: '800x600', thumb: '/media/f.thumb.jpg', alt: 'a picture', filename: 'shot.png' }
81
+ editor = new Editor({ extensions: EXTENSIONS, content: { type: 'doc', content: [{ type: 'attachment', attrs: { blockId: 'a1', ...rich } }] } })
82
+ expect((editor.getJSON() as Json).content?.[0].attrs).toEqual({ blockId: 'a1', ...rich })
83
+ })
84
+ })
85
+
86
+ describe('what it draws', () => {
87
+ function WithAnEditor({ view }: { view: ((props: ReferenceViewProps) => React.ReactNode) | null }) {
88
+ const e = useEditor({
89
+ extensions: [StarterKit, BlockId, UnknownBlock, ReferenceNode.configure({ view }), AttachmentNode],
90
+ content: DOCUMENT,
91
+ })
92
+ return <EditorContent editor={e} />
93
+ }
94
+
95
+ it('draws a reference with the view it is given', async () => {
96
+ render(<WithAnEditor view={({ uri }) => <span data-testid="chip">{uri}</span>} />)
97
+ expect((await screen.findByTestId('chip')).textContent).toBe('nostr:naddr1example')
98
+ })
99
+
100
+ it('draws a reference as its uri without a view', async () => {
101
+ const { container } = render(<WithAnEditor view={null} />)
102
+ await screen.findByText('Title')
103
+ expect(container.querySelector('[data-reference]')?.textContent).toBe('nostr:naddr1example')
104
+ })
105
+
106
+ it('draws an unknown block as its text, addressed by its id', async () => {
107
+ const { container } = render(<WithAnEditor view={null} />)
108
+ expect((await screen.findByText('kept')).closest('[data-block-id]')?.getAttribute('data-block-id')).toBe('w1')
109
+ expect(container.querySelector('[data-block-type="widget"]')).not.toBeNull()
110
+ })
111
+
112
+ it('puts data-block-id on each block, as RichText does', async () => {
113
+ const { container } = render(<WithAnEditor view={null} />)
114
+ await screen.findByText('Title')
115
+ expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
116
+ })
117
+ })
@@ -0,0 +1,242 @@
1
+ import type { ComponentType } from 'react'
2
+ import { Extension, Node } from '@tiptap/core'
3
+ import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
4
+
5
+ /*
6
+ * The four nodes an editor of a block document needs that a stock schema does
7
+ * not have (SPEC §13.3) — moved here from Ship's `editorSchema.tsx` (RIC-14,
8
+ * SHI-2) when Peek became the second editor of the same documents (MAN-9).
9
+ *
10
+ * They are one half of a pair. The other half is `@estiva-app/protocol`'s
11
+ * `toEditorDocument` / `fromEditorDocument`, which turn a stored document into
12
+ * the JSON these nodes read and back: the node names (`unknownBlock`,
13
+ * `reference`, `attachment`) and the `blockId` attribute are that translation's,
14
+ * and must not drift from it. This package does not import protocol, as
15
+ * `RichText` does not: the translation hands over everything a view draws.
16
+ *
17
+ * Everything else a document can contain is already a ProseMirror node of the
18
+ * same name — StarterKit's and TableKit's — which is why this file is short.
19
+ */
20
+
21
+ /**
22
+ * The block types §13.3 puts an id on, as the editor's node names —
23
+ * protocol's `EDITOR_BLOCK_TYPES`, less `unknownBlock`, which declares its own.
24
+ */
25
+ const BLOCK_TYPES = [
26
+ 'paragraph',
27
+ 'heading',
28
+ 'bulletList',
29
+ 'orderedList',
30
+ 'listItem',
31
+ 'blockquote',
32
+ 'codeBlock',
33
+ 'horizontalRule',
34
+ // A table and everything inside it (UIG-18): a row or a cell whose id was
35
+ // re-minted on each save would detach any comment anchored to it.
36
+ 'table',
37
+ 'tableRow',
38
+ 'tableHeader',
39
+ 'tableCell',
40
+ // Without this an attachment's id is re-minted on every save, and every
41
+ // comment anchored to it stops resolving.
42
+ 'attachment',
43
+ ]
44
+
45
+ /**
46
+ * `id` on every block, carried as the `blockId` attribute.
47
+ *
48
+ * A global attribute rather than one per node type, because §13.3 puts an id
49
+ * on **every** block and a type that quietly lacked one would mint a fresh id
50
+ * on every save — detaching each anchored comment on it (RFC 0.4 §6) with
51
+ * nothing on screen to say so.
52
+ *
53
+ * Rendered as `data-block-id`, which is what `RichText` already emits, so the
54
+ * reading and the editing surface address a block the same way.
55
+ */
56
+ export const BlockId = Extension.create({
57
+ name: 'blockId',
58
+ addGlobalAttributes() {
59
+ return [
60
+ {
61
+ types: BLOCK_TYPES,
62
+ attributes: {
63
+ blockId: {
64
+ default: null,
65
+ parseHTML: (element: HTMLElement) => element.getAttribute('data-block-id'),
66
+ renderHTML: (attributes: Record<string, unknown>) =>
67
+ attributes.blockId ? { 'data-block-id': String(attributes.blockId) } : {},
68
+ },
69
+ },
70
+ },
71
+ ]
72
+ },
73
+ })
74
+
75
+ /** What a reference's view is handed: the `nostr:` URI it stands for. */
76
+ export interface ReferenceViewProps {
77
+ uri: string
78
+ }
79
+
80
+ export interface ReferenceNodeOptions {
81
+ /**
82
+ * Draws the reference as the thing it points at — the app's own chip, which
83
+ * knows how to resolve it. Without one, the URI is drawn as text.
84
+ */
85
+ view: ComponentType<ReferenceViewProps> | null
86
+ }
87
+
88
+ /**
89
+ * A `nostr:` reference, drawn as the thing it points at — SPEC §13.1.
90
+ *
91
+ * An **atom**: it has no editable interior, because its text is the URI and
92
+ * nobody edits half a `naddr`. Selecting it selects the whole reference, which
93
+ * is also how deleting one works. `marks` carries the run's other marks, so a
94
+ * bold reference comes back bold.
95
+ */
96
+ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
97
+ name: 'reference',
98
+ group: 'inline',
99
+ inline: true,
100
+ atom: true,
101
+ selectable: true,
102
+ draggable: false,
103
+ addOptions() {
104
+ return { view: null }
105
+ },
106
+ addAttributes() {
107
+ return {
108
+ uri: { default: '' },
109
+ marks: { default: [] as string[] },
110
+ }
111
+ },
112
+ parseHTML() {
113
+ return [{ tag: 'span[data-reference]' }]
114
+ },
115
+ renderHTML({ node }) {
116
+ // A copy to the clipboard as HTML, and the whole drawing when no view is given.
117
+ return ['span', { 'data-reference': String(node.attrs.uri) }, String(node.attrs.uri)]
118
+ },
119
+ addNodeView() {
120
+ const View = this.options.view
121
+ if (!View) return null
122
+ return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
123
+ <NodeViewWrapper as="span" className="inline">
124
+ <View uri={String(node.attrs.uri ?? '')} />
125
+ </NodeViewWrapper>
126
+ ))
127
+ },
128
+ })
129
+
130
+ /**
131
+ * A block this editor has no design for, kept whole.
132
+ *
133
+ * §13.3: a reader MUST render an unknown block's inline text and MUST NOT drop
134
+ * it silently. **An editor is the dangerous case** — ProseMirror discards a
135
+ * node its schema does not know, and the save after that would delete somebody
136
+ * else's block with no error anywhere on the way. So the block's own JSON
137
+ * rides in `source`, and protocol's `fromEditorDocument` hands it back
138
+ * untouched; `text` is its inline text, worked out by `toEditorDocument`.
139
+ *
140
+ * Not editable, deliberately: an affordance that lets a person change
141
+ * something they cannot see is worse than one that says the content is there
142
+ * and belongs to another surface.
143
+ */
144
+ export const UnknownBlock = Node.create({
145
+ name: 'unknownBlock',
146
+ group: 'block',
147
+ atom: true,
148
+ selectable: true,
149
+ addAttributes() {
150
+ return {
151
+ blockId: { default: null },
152
+ source: { default: null },
153
+ text: { default: '' },
154
+ }
155
+ },
156
+ parseHTML() {
157
+ return [{ tag: 'div[data-unknown-block]' }]
158
+ },
159
+ renderHTML({ node }) {
160
+ return ['div', { 'data-unknown-block': String(node.attrs.blockId ?? '') }, String(node.attrs.text ?? '')]
161
+ },
162
+ addNodeView() {
163
+ return ReactNodeViewRenderer(UnknownBlockView)
164
+ },
165
+ })
166
+
167
+ function UnknownBlockView({ node }: ReactNodeViewProps) {
168
+ const source = node.attrs.source as { id?: string; type?: string } | null
169
+ const text = String(node.attrs.text ?? '')
170
+ return (
171
+ <NodeViewWrapper
172
+ data-block-id={source?.id}
173
+ data-block-type={source?.type}
174
+ className="rounded-md border border-border-default bg-bg-inset px-2 py-1 text-text-secondary"
175
+ >
176
+ {text || `A ${source?.type ?? 'block'} this app cannot edit yet.`}
177
+ </NodeViewWrapper>
178
+ )
179
+ }
180
+
181
+ /** What an attachment's view is handed: the block's `imeta` fields. */
182
+ export interface AttachmentViewProps {
183
+ attrs: Record<string, unknown>
184
+ }
185
+
186
+ export interface AttachmentNodeOptions {
187
+ /** Draws the file — the app's own attachment card. Without one, its name is drawn as text. */
188
+ view: ComponentType<AttachmentViewProps> | null
189
+ }
190
+
191
+ /**
192
+ * A file placed in a document — SPEC §13.3's `attachment` block (SHI-2).
193
+ *
194
+ * An atom: the file is the block, there is nothing inside it to edit, and a
195
+ * caret that could sit "inside" an image is a way to produce documents no
196
+ * reader has a rule for. Selectable and deletable, so removing one is Backspace
197
+ * like anything else.
198
+ *
199
+ * Its attributes are the `imeta` fields exactly, because that is what §13.3
200
+ * specifies. Each is declared, and that is not boilerplate: ProseMirror **drops
201
+ * an attribute its schema does not name**, so an undeclared `thumb` would
202
+ * survive the editor and vanish on save — the silent deletion `UnknownBlock`
203
+ * exists to prevent.
204
+ */
205
+ export const AttachmentNode = Node.create<AttachmentNodeOptions>({
206
+ name: 'attachment',
207
+ group: 'block',
208
+ atom: true,
209
+ selectable: true,
210
+ draggable: false,
211
+ addOptions() {
212
+ return { view: null }
213
+ },
214
+ addAttributes() {
215
+ return {
216
+ url: { default: '' },
217
+ m: { default: '' },
218
+ x: { default: '' },
219
+ size: { default: 0 },
220
+ dim: { default: null },
221
+ thumb: { default: null },
222
+ alt: { default: null },
223
+ filename: { default: null },
224
+ }
225
+ },
226
+ parseHTML() {
227
+ return [{ tag: 'div[data-attachment-block]' }]
228
+ },
229
+ renderHTML({ node }) {
230
+ // A copy to the clipboard, and the whole drawing when no view is given.
231
+ return ['div', { 'data-attachment-block': String(node.attrs.x ?? '') }, String(node.attrs.filename ?? 'attachment')]
232
+ },
233
+ addNodeView() {
234
+ const View = this.options.view
235
+ if (!View) return null
236
+ return ReactNodeViewRenderer(({ node }: ReactNodeViewProps) => (
237
+ <NodeViewWrapper className="my-2">
238
+ <View attrs={node.attrs} />
239
+ </NodeViewWrapper>
240
+ ))
241
+ },
242
+ })
package/src/editor.ts CHANGED
@@ -9,3 +9,13 @@
9
9
  */
10
10
  export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
11
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
12
+ export {
13
+ BlockId,
14
+ UnknownBlock,
15
+ ReferenceNode,
16
+ AttachmentNode,
17
+ type ReferenceNodeOptions,
18
+ type ReferenceViewProps,
19
+ type AttachmentNodeOptions,
20
+ type AttachmentViewProps,
21
+ } from './BlockNodes'
@@ -56,6 +56,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
56
56
  'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
57
57
  'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
58
58
  'UIG-32': 'Peek and Ship take their gate pieces from the package',
59
+ 'UIG-33': 'Link — a whole row that is one link',
59
60
  'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
60
61
  'UIG-42': "What the gate can't see yet — a box around a part, a screen drawn by hand, two blind spots in the token check",
61
62
  }
@@ -4,6 +4,7 @@ import { join } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { ESLint, type Linter } from 'eslint'
6
6
  import { afterAll, describe, expect, it } from 'vitest'
7
+ import { appChecks } from './app-checks'
7
8
  import { countCommitted, hookRuns, skippedFolders, skipsListed } from './ci'
8
9
  import { writeGateCount } from './count'
9
10
  import { gateConfig, gateLint } from './gate-config'
@@ -425,4 +426,25 @@ describe('gates:status', () => {
425
426
  expect(out).toContain('Tickets shipish owns (1):')
426
427
  expect(out).toContain('✅ UIG-4')
427
428
  })
429
+
430
+ // UIG-26, Katerina's ruling of 30 September: eight open tickets moved to the next phase, and the close runs.
431
+ it('shows a ticket moved to the next phase, still checks it, and the aggregate does not wait for it', async () => {
432
+ const checks = (later: string) => `export default (h) => ({ repo: 'estiva-ui', siblings: [],\n all: [\n { ref: 'UIG-1', owner: 'estiva-ui', title: 'Count' },\n { ref: 'UIG-26', owner: 'estiva-ui', aggregate: true, title: 'Close' },\n { ref: 'UIG-39', owner: 'estiva-ui', title: 'Later'${later} },\n ],\n tickets: [\n { ref: 'UIG-1', owner: true, checks: [{ what: 'counted', run: () => h.PASS('yes') }] },\n { ref: 'UIG-26', owner: true, checks: [] },\n { ref: 'UIG-39', owner: true, checks: [{ what: 'written', run: () => h.FAIL('not yet') }] },\n ] })\n`
433
+ const moved = await runStatus({ root: app('status-later', { 'scripts/gates-checks.mjs': checks(", later: 'Katerina, 30 September: the next phase'") }), detail: true })
434
+ expect(moved).toContain('⏭ UIG-39')
435
+ expect(moved).toContain('Katerina, 30 September: the next phase')
436
+ expect(moved).toContain('✗ estiva-ui written — not yet')
437
+ expect(moved).toContain('✅ UIG-26')
438
+ expect(moved).toContain('1 of 1 done; 1 moved to the next phase')
439
+ expect(moved).toContain('⏭ 1 next phase')
440
+ const waiting = await runStatus({ root: app('status-waiting', { 'scripts/gates-checks.mjs': checks('') }) })
441
+ expect(waiting).toContain('⬜ UIG-26')
442
+ expect(waiting).not.toContain('next phase')
443
+ })
444
+
445
+ // A made app's UIG-33 row printed with no title (UIG-26): every row the package checks in an app has one.
446
+ it('gives every ticket an app checks a title', () => {
447
+ const tickets = appChecks(helpers(app('titles', {})), { page: 'src/pages/HomePage.tsx' })
448
+ expect(tickets.filter((t) => !t.title).map((t) => t.ref)).toEqual([])
449
+ })
428
450
  })
@@ -101,6 +101,8 @@ export interface TicketListEntry {
101
101
  title: string
102
102
  parts?: string[]
103
103
  aggregate?: boolean
104
+ /** Moved to the next phase, and by whose ruling: still checked, shown ⏭, and not waited for by the aggregate. */
105
+ later?: string
104
106
  }
105
107
  export interface GateSpec {
106
108
  repo: string
@@ -119,6 +121,7 @@ interface Row {
119
121
  checks: (CheckResult & { repo: string; what: string })[]
120
122
  status?: string
121
123
  aggregate?: boolean
124
+ later?: string
122
125
  }
123
126
  interface Report {
124
127
  schema: 1
@@ -199,9 +202,11 @@ export function helpers(ROOT: string): GateHelpers {
199
202
 
200
203
  async contract(appDir) {
201
204
  try {
202
- const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import('../registry/index')
205
+ const { buildAppRegistry, contractProblems, nameableParts, CONTRACT_KINDS } = await import('../registry/index')
203
206
  const registry = buildAppRegistry({ root: abs(appDir) })
204
- const problems = contractProblems(registry, abs(appDir))
207
+ // The package's catalogue, beside its package.json: a page may name its parts too, as in CI.
208
+ const pkg = createRequire(import.meta.url)('../../registry.json') as Parameters<typeof nameableParts>[1]
209
+ const problems = contractProblems(registry, abs(appDir), nameableParts(registry, pkg))
205
210
  const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
206
211
  if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`)
207
212
  return PASS(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`)
@@ -445,7 +450,7 @@ export function helpers(ROOT: string): GateHelpers {
445
450
 
446
451
  // ── running the checks ─────────────────────────────────────────────────────
447
452
 
448
- const STATUS: Record<string, string> = { done: '✅', started: '🚧', none: '⬜', unknown: '❔' }
453
+ const STATUS: Record<string, string> = { done: '✅', started: '🚧', none: '⬜', unknown: '❔', later: '⏭' }
449
454
 
450
455
  function statusOf(checks: CheckResult[]) {
451
456
  const n = (r: CheckResult['result']) => checks.filter((c) => c.result === r).length
@@ -553,6 +558,7 @@ function printRows(rows: Row[], { showOwner, detail }: { showOwner: boolean; det
553
558
  const pass = r.checks.filter((c) => c.result === 'pass').length
554
559
  const count = r.checks.length ? `${pass} of ${r.checks.length}` : 'no checks here'
555
560
  lines.push(`${STATUS[r.status ?? 'unknown']} ${pad(r.ref, 7)} ${pad(r.title ?? '', width)} ${showOwner ? pad(r.ownerRepo ?? '', 10) : ''}${count}`)
561
+ if (r.later) lines.push(` ${r.later}`)
556
562
  if (detail || r.status === 'started' || r.status === 'unknown') {
557
563
  for (const c of r.checks) {
558
564
  const mark = { pass: '✓', part: '½', fail: '✗', unknown: '?' }[c.result]
@@ -565,7 +571,8 @@ function printRows(rows: Row[], { showOwner, detail }: { showOwner: boolean; det
565
571
 
566
572
  function summary(rows: Row[]) {
567
573
  const n = (s: string) => rows.filter((r) => r.status === s).length
568
- return `${STATUS.done} ${n('done')} done · ${STATUS.started} ${n('started')} started · ${STATUS.none} ${n('none')} not started · ${STATUS.unknown} ${n('unknown')} could not check`
574
+ const later = n('later') ? ` · ${STATUS.later} ${n('later')} next phase` : ''
575
+ return `${STATUS.done} ${n('done')} done · ${STATUS.started} ${n('started')} started · ${STATUS.none} ${n('none')} not started · ${STATUS.unknown} ${n('unknown')} could not check${later}`
569
576
  }
570
577
 
571
578
  export interface StatusOptions {
@@ -667,13 +674,17 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
667
674
  for (const f of found.filter((f) => f.missing)) {
668
675
  if ((t.parts ?? []).includes(f.name) || t.owner === f.name) checks.push({ repo: f.name, what: `${f.name}'s part`, result: 'unknown', detail: f.note })
669
676
  }
670
- return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate }
677
+ return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate, later: t.later }
671
678
  })
672
- for (const row of rows) row.status = statusOf(row.checks)
679
+ // A ticket moved to the next phase is still checked (--detail shows how far it is), but it
680
+ // reads ⏭ whatever its checks say, and the aggregate does not wait for it.
681
+ for (const row of rows) row.status = row.later ? 'later' : statusOf(row.checks)
673
682
  for (const row of rows.filter((r) => r.aggregate)) {
674
- const others = rows.filter((r) => r !== row)
683
+ const others = rows.filter((r) => r !== row && !r.later)
675
684
  const done = others.filter((r) => r.status === 'done').length
676
- row.checks.push({ repo: 'all', what: 'every other ticket is done', ...(done === others.length ? PASS(`${done} of ${others.length} done`) : FAIL(`${done} of ${others.length} done`)) })
685
+ const moved = rows.filter((r) => r.later).length
686
+ const said = `${done} of ${others.length} done${moved ? `; ${moved} moved to the next phase` : ''}`
687
+ row.checks.push({ repo: 'all', what: 'every other ticket of this phase is done', ...(done === others.length ? PASS(said) : FAIL(said)) })
677
688
  row.status = statusOf(row.checks)
678
689
  }
679
690
 
@@ -36,7 +36,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
36
36
  import { tmpdir } from 'node:os'
37
37
  import { basename, dirname, join, relative, resolve } from 'node:path'
38
38
  import { fileURLToPath } from 'node:url'
39
- import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
39
+ import { CONTRACT_KINDS, contractProblems, linkProblems, nameableParts, nameProblems } from './contract'
40
40
  import { findInRegistries, formatFindings } from './find'
41
41
  import { behindBy, findSiblings, workspaceOf } from './siblings'
42
42
  import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
@@ -280,7 +280,7 @@ async function main(): Promise<number> {
280
280
  // The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
281
281
  // A When not may name a part of the package or of this app (B8).
282
282
  const pkg = readPackageRegistry()
283
- const known = new Set([...pkg.entries, ...registry.entries].map((e) => e.name))
283
+ const known = nameableParts(registry, pkg)
284
284
  const broken = contractProblems(registry, root, known)
285
285
  if (broken.length) {
286
286
  process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
@@ -13,12 +13,14 @@
13
13
  * used to call drawn nowhere;
14
14
  * - a Windows checkout, read with CRLF.
15
15
  */
16
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
17
17
  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, nameProblems, pageProblem, whenNotProblem } from './contract'
21
+ import { helpers } from '../gates/status'
22
+ import { contractProblems, hasSeenIn, linkProblems, nameableParts, nameProblems, pageProblem, whenNotProblem } from './contract'
23
+ import type { Registry } from './schema'
22
24
 
23
25
  const made: string[] = []
24
26
  afterAll(() => {
@@ -128,6 +130,20 @@ describe('an app against the contract', () => {
128
130
  expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
129
131
  })
130
132
 
133
+ // UIG-26: gates:status knew only the app's own parts, and read six of Peek's pages as broken that CI passed.
134
+ it('lets a page name a package part, in estiva-ui check and in gates:status alike', async () => {
135
+ const dir = app({
136
+ 'src/Badge.tsx': part('Badge'),
137
+ 'src/Badge.mdx': PAGE.replace('No alternative: a count has only this badge.', 'A framed column is `Panel`.'),
138
+ 'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
139
+ })
140
+ const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
141
+ expect(contractProblems(registry, dir)).toEqual([expect.stringContaining('"When not" names no other part')])
142
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'registry.json'), 'utf8')) as Registry
143
+ expect(contractProblems(registry, dir, nameableParts(registry, pkg))).toEqual([])
144
+ expect(await helpers(dir).contract('.')).toMatchObject({ result: 'pass' })
145
+ })
146
+
131
147
  it('names a reusable part with no page, and the file to write', () => {
132
148
  const dir = app({ 'src/Badge.tsx': part('Badge') })
133
149
  expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
@@ -81,6 +81,16 @@ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): stri
81
81
  return problems
82
82
  }
83
83
 
84
+ /**
85
+ * The parts an app's page may name under When and When not: the package's and
86
+ * the app's own (B8). The one copy: `estiva-ui check` and `gates:status` both
87
+ * read it, so they cannot disagree. Status once knew the app's parts only, and
88
+ * read six of Peek's pages as broken that CI passed (UIG-26).
89
+ */
90
+ export function nameableParts(app: Registry, pkg: Registry): Set<string> {
91
+ return new Set([...pkg.entries, ...app.entries].map((e) => e.name))
92
+ }
93
+
84
94
  /**
85
95
  * Every way an app's catalogue breaks the contract, one line each, naming the
86
96
  * part, its file and what to do. Empty when it keeps it.
@@ -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, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, namedParts, pageProblem, whenNotProblem } from './contract'
15
+ export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, nameableParts, namedParts, pageProblem, whenNotProblem } from './contract'
16
16
  export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
17
17
  export {
18
18
  CLASSES,