From 6f6968b52df133ba1b75b5561fbee92283b543c8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 14:39:43 -0700 Subject: [PATCH 01/10] docs(dogfood): set block-authored bearing --- docs/BEARING.md | 50 +++---- docs/design/DF-069-block-authored-dogfood.md | 129 +++++++++++++++++++ docs/legends/DF-dogfood-field-guide.md | 1 + 3 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 docs/design/DF-069-block-authored-dogfood.md diff --git a/docs/BEARING.md b/docs/BEARING.md index b1aacd55..2c36f2be 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -15,6 +15,8 @@ Current direction and active tensions. Historical ship data is in `CHANGELOG.md` command-intent, lifecycle, active-binding, first-party definition, DOGFOOD documentation, and fixture-demo proof. The visible product layer is still emerging; the architecture is ahead of the rendered catalog. +- `LX-019` — DOGFOOD text lookup now goes through an application-facing + localization port instead of view code reaching for the concrete runtime. - `4.4.1` — framed-shell polish and background-fill recovery after `4.4.0`. - `4.2.0` — [RE-007](./design/RE-007-migrate-framed-shell-onto-runtime-engine-seams.md) lands the framed shell on the runtime-engine seams and ships @@ -43,18 +45,19 @@ Current direction and active tensions. Historical ship data is in `CHANGELOG.md` - New Block work should consume the existing contracts, not invent provider, lifecycle, callback, or localization policy while rendering. -### 3. Localization Port Before More View-Level Lookup +### 3. DOGFOOD Becomes Block-Authored -- English fallback is separate from selected-locale catalogs. -- Missing selected-locale strings are now real missing data, not disguised - English. -- DOGFOOD now needs an app-facing localization port so views ask for localized - objects instead of depending directly on the concrete i18n runtime. +- DOGFOOD should prove Bijou's Block system by using Blocks for semantic app + surfaces, not merely previewing Blocks in one section. +- Components remain the leaf rendering vocabulary inside Blocks. +- Storybook should move onto the shared framed shell path and expose a + `StorybookWorkbenchBlock` contract instead of staying a parallel bespoke TUI. ## Tensions -- **View-Level Runtime Leakage**: DOGFOOD still passes the concrete i18n - runtime through rendering helpers for ordinary text lookup. +- **Block Boundary Drift**: It is tempting to wrap every component in a Block. + That would blur the useful boundary; Blocks should own product semantics, + while components own leaf rendering. - **Product Proof Lag**: Blocks have better contracts than visible rendered proof. AppShell rendering must consume existing data-flow contracts instead of creating a parallel prop/callback path. @@ -67,26 +70,27 @@ Current direction and active tensions. Historical ship data is in `CHANGELOG.md` ## Next Target The immediate focus is -[LX-019 — Localization Port Contract](./design/LX-019-localization-port-contract.md). +[DF-069 — Block-Authored DOGFOOD](./design/DF-069-block-authored-dogfood.md). -LX-019 should stay boundary-focused: +DF-069 should stay product-composition focused: -- define the application-facing `LocalizationPort` in `@flyingrobots/bijou-i18n` -- return structured localized objects instead of naked strings -- keep catalog/file loading in adapters -- keep DOGFOOD runtime composition at the app boundary -- migrate DOGFOOD text helpers to the port +- move Storybook toward the framed shell path +- introduce DOGFOOD Blocks for semantic app/page surfaces +- keep low-level components as rendering primitives inside Blocks +- preserve unidirectional data-binding and localization-port boundaries Non-goals for the next cycle: -- no localization dashboard -- no translation workbench -- no portable preference system -- no broad catalog expansion -- no remote localization service +- no full visual redesign of DOGFOOD +- no conversion of every leaf component into a Block +- no hidden global block registry +- no new provider lifecycle system +- no localization runtime rewrite Expected sequence: -1. `LX-019` puts application-facing localization lookup behind a port. -2. Remaining DOGFOOD text surfaces continue moving into catalogs. -3. Blocks product proof resumes with less view-level localization leakage. +1. `DF-069` makes the next DOGFOOD proof block-authored at semantic boundaries. +2. Storybook moves onto the same framed-shell model. +3. DOGFOOD pages migrate to Blocks incrementally, starting with title, + navigation, documentation article, settings, block preview, and inspector + surfaces. diff --git a/docs/design/DF-069-block-authored-dogfood.md b/docs/design/DF-069-block-authored-dogfood.md new file mode 100644 index 00000000..fcfebc1f --- /dev/null +++ b/docs/design/DF-069-block-authored-dogfood.md @@ -0,0 +1,129 @@ +# DF-069 - Block-Authored DOGFOOD + +Linked legend: [DF - DOGFOOD Field Guide](../legends/DF-dogfood-field-guide.md) + +## Sponsor human + +DOGFOOD should prove that Bijou applications can be authored from Blocks, not +only that Blocks can be previewed inside DOGFOOD. + +## Sponsor agent + +An agent should be able to inspect DOGFOOD and identify the semantic blocks +that make up the product surface without reverse-engineering bespoke render +helpers. + +## Hill + +DOGFOOD becomes a block-authored application at the semantic surface boundary: +title screen, shell regions, navigation, articles, settings, story previews, +block previews, and inspector panes are declared as Blocks. Components remain +the lower-level rendering vocabulary inside those Blocks. + +## Core Rule + +Blocks own product semantics. Components own leaf rendering. + +That means this work should create and consume Blocks for surfaces such as: + +- `TitleScreenBlock` +- `NavigationListBlock` +- `DocumentationArticleBlock` +- `SettingsMenuBlock` +- `StorybookWorkbenchBlock` +- `BlockPreviewBlock` +- `GuideInspectorBlock` + +It should not wrap every `boxSurface()`, `markdown()`, or list row in a Block. + +## Architecture + +The target composition is: + +```text +DOGFOOD app + -> AppFrame / AppShell composition + -> page and region Blocks + -> components + -> surfaces +``` + +The existing unidirectional data-binding rule still applies: + +```text +business logic / providers + -> immutable snapshots + -> binding frames + -> blocks and views render + -> command intents + -> business logic owns the next state +``` + +Block-authored DOGFOOD must not introduce hidden provider registries, callback +backchannels, render-time refresh hooks, mutable view stores, or direct provider +reads from rendering code. + +## Storybook Implication + +The standalone Storybook app should stop being a parallel bespoke TUI. It +should use the same framed shell path as DOGFOOD and expose a +`StorybookWorkbenchBlock` contract so story preview work exercises the product +composition model. + +## Non-goals + +- no full visual redesign of DOGFOOD +- no attempt to convert every low-level component into a Block +- no new provider lifecycle system +- no new localization runtime +- no hidden global block registry +- no rendered AppShell policy invented outside existing contracts + +## Accessibility / Assistive Posture + +Block-authored DOGFOOD should make semantic regions easier to lower across +interactive, static, pipe, and accessible modes. Blocks should preserve facts +about navigation, content, settings, previews, and inspector state without +requiring agents or assistive modes to parse terminal art. + +## Localization / Directionality Posture + +Blocks should receive localized labels and copy through DOGFOOD's localization +port, not through direct catalog access. Block metadata may describe the +surface, but runtime-visible labels remain localized app data. + +## Agent Inspectability / Explainability Posture + +Agents should be able to ask which DOGFOOD Blocks are active, what data +requirements they declare, which command intents they expose, and which surface +they render into. + +## Linked Invariants + +- [Docs Are the Demo](../invariants/docs-are-the-demo.md) +- [Runtime Truth Wins](../invariants/runtime-truth-wins.md) +- [Commands Change State, Effects Do Not](../invariants/commands-change-state-effects-do-not.md) +- [Tests Are the Spec](../invariants/tests-are-the-spec.md) + +## Implementation Outline + +1. Update BEARING so the next product gravity is block-authored DOGFOOD. +2. Add DOGFOOD-local block registry helpers for semantic app surfaces. +3. Add `StorybookWorkbenchBlock` and move Storybook toward the framed shell. +4. Add DOGFOOD Blocks for title, navigation, documentation article, settings, + block preview, and guide inspector surfaces. +5. Prove the block-authored registry without rendering every block during + discovery. +6. Keep visual changes incremental and scoped. + +## Tests To Write First + +- behavior tests proving the Storybook app is framed rather than bespoke +- behavior tests proving DOGFOOD block registry discovery does not call render +- behavior tests proving core DOGFOOD surfaces have Block definitions +- behavior tests proving localization and command intent boundaries stay + declarative + +## Retrospective + +Started in the block-authored DOGFOOD stack. diff --git a/docs/legends/DF-dogfood-field-guide.md b/docs/legends/DF-dogfood-field-guide.md index b5ee8a75..c0182c40 100644 --- a/docs/legends/DF-dogfood-field-guide.md +++ b/docs/legends/DF-dogfood-field-guide.md @@ -76,6 +76,7 @@ before DOGFOOD counts as a real terminal docs product, and can see that - latest DOGFOOD docs-surface closure: - [DF-025 — Make DOGFOOD The Only Human-Facing Docs Surface](../design/DF-025-make-dogfood-the-only-human-facing-docs-surface.md) - latest supporting closure: + - [DF-069 — Block-Authored DOGFOOD](../design/DF-069-block-authored-dogfood.md) - [DF-067 — Prove Responsive DOGFOOD Layout Variants](../design/DF-067-prove-responsive-dogfood-layout-variants.md) - [DF-027 — Storybook-Style Tool for Bijou](../design/DF-027-storybook-style-tool-for-bijou.md) - [DF-062 — Audit Notification System Family Across Real Surfaces](../design/DF-062-audit-notification-system-family-across-real-surfaces.md) From 97c9918034c45d7dbe060e73669dc5fa4f10adff Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 14:57:21 -0700 Subject: [PATCH 02/10] feat(dogfood): add block registry primitives --- examples/docs/dogfood-blocks.ts | 256 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 141 ++++++++++ 2 files changed, 397 insertions(+) create mode 100644 examples/docs/dogfood-blocks.ts create mode 100644 tests/cycles/DF-069/dogfood-block-registry.test.ts diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts new file mode 100644 index 00000000..51be8fff --- /dev/null +++ b/examples/docs/dogfood-blocks.ts @@ -0,0 +1,256 @@ +import { + isBlockDefinition, + type BlockDefinition, +} from '@flyingrobots/bijou'; + +const DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND: unique symbol = Symbol('DogfoodBlockRegistryEntry'); +const DOGFOOD_BLOCK_REGISTRY_BRAND: unique symbol = Symbol('DogfoodBlockRegistry'); + +export const DOGFOOD_BLOCK_PACKAGE = '@flyingrobots/bijou-dogfood'; + +export type DogfoodBlockRole = + | 'app-shell' + | 'title' + | 'navigation' + | 'article' + | 'settings' + | 'inspector' + | 'preview' + | 'workbench' + | 'fixture'; + +export interface DogfoodBlockRegistryEntryInput { + readonly block: BlockDefinition; + readonly role: DogfoodBlockRole; + readonly surfaceId: string; + readonly description?: string; + readonly tags?: readonly string[]; +} + +export interface DogfoodBlockRegistryEntry { + readonly [DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND]: true; + readonly block: BlockDefinition; + readonly blockName: string; + readonly packageName: string; + readonly role: DogfoodBlockRole; + readonly surfaceId: string; + readonly description?: string; + readonly tags: readonly string[]; +} + +export class DogfoodBlockRegistry { + readonly [DOGFOOD_BLOCK_REGISTRY_BRAND]!: true; + readonly #entries: readonly DogfoodBlockRegistryEntry[]; + readonly #entriesByBlockKey: ReadonlyMap; + readonly #entriesBySurfaceId: ReadonlyMap; + + constructor(entries: readonly DogfoodBlockRegistryEntry[]) { + if (!Array.isArray(entries)) { + throw new Error('dogfood block registry: entries must be an array'); + } + + Object.defineProperty(this, DOGFOOD_BLOCK_REGISTRY_BRAND, { value: true }); + + const entriesByBlockKey = new Map(); + const entriesBySurfaceId = new Map(); + const normalizedEntries: DogfoodBlockRegistryEntry[] = []; + + entries.forEach((entry, index) => { + if (!isDogfoodBlockRegistryEntry(entry)) { + throw new Error( + `dogfood block registry: entry at index ${index} was not created by dogfoodBlockRegistryEntry()`, + ); + } + + const blockKey = dogfoodBlockKey(entry.packageName, entry.blockName); + if (entriesByBlockKey.has(blockKey)) { + throw new Error(`dogfood block registry: duplicate block ${blockKey}`); + } + if (entriesBySurfaceId.has(entry.surfaceId)) { + throw new Error(`dogfood block registry: duplicate surface id ${entry.surfaceId}`); + } + + entriesByBlockKey.set(blockKey, entry); + entriesBySurfaceId.set(entry.surfaceId, entry); + normalizedEntries.push(entry); + }); + + this.#entries = Object.freeze(normalizedEntries); + this.#entriesByBlockKey = entriesByBlockKey; + this.#entriesBySurfaceId = entriesBySurfaceId; + Object.freeze(this); + } + + entries(): readonly DogfoodBlockRegistryEntry[] { + return Object.freeze([...this.#entries]); + } + + blocks(): readonly BlockDefinition[] { + return Object.freeze(this.#entries.map((entry) => entry.block)); + } + + blockNames(): readonly string[] { + return Object.freeze(this.#entries.map((entry) => entry.blockName)); + } + + surfaceIds(): readonly string[] { + return Object.freeze(this.#entries.map((entry) => entry.surfaceId)); + } + + roles(): readonly DogfoodBlockRole[] { + const roles = new Set(); + this.#entries.forEach((entry) => roles.add(entry.role)); + return Object.freeze([...roles]); + } + + forSurface(surfaceId: string): DogfoodBlockRegistryEntry | undefined { + return this.#entriesBySurfaceId.get(normalizeRequiredText({ + scope: 'dogfood block registry', + field: 'surfaceId', + value: surfaceId, + })); + } + + forBlock(blockName: string, packageName = DOGFOOD_BLOCK_PACKAGE): DogfoodBlockRegistryEntry | undefined { + return this.#entriesByBlockKey.get(dogfoodBlockKey( + normalizeRequiredText({ + scope: 'dogfood block registry', + field: 'packageName', + value: packageName, + }), + normalizeRequiredText({ + scope: 'dogfood block registry', + field: 'blockName', + value: blockName, + }), + )); + } + + with(entry: DogfoodBlockRegistryEntry): DogfoodBlockRegistry { + return new DogfoodBlockRegistry([...this.#entries, entry]); + } +} + +export function dogfoodBlockRegistryEntry( + input: DogfoodBlockRegistryEntryInput, +): DogfoodBlockRegistryEntry { + if (!isBlockDefinition(input.block)) { + throw new Error('dogfood block registry entry: block was not created by defineBlock()'); + } + + const role = normalizeDogfoodBlockRole(input.role); + const surfaceId = normalizeRequiredText({ + scope: 'dogfood block registry entry', + field: 'surfaceId', + value: input.surfaceId, + }); + const description = optionalTrimmedText(input.description); + const tags = Object.freeze((input.tags ?? []).map((tag) => normalizeRequiredText({ + scope: 'dogfood block registry entry', + field: 'tag', + value: tag, + }))); + + const entry = { + block: input.block, + blockName: input.block.metadata.blockName, + packageName: input.block.metadata.packageName, + role, + surfaceId, + ...(description === undefined ? {} : { description }), + tags, + } as DogfoodBlockRegistryEntry; + + Object.defineProperty(entry, DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND, { value: true }); + return Object.freeze(entry); +} + +export function isDogfoodBlockRegistryEntry( + value: unknown, +): value is DogfoodBlockRegistryEntry { + return Boolean( + value + && typeof value === 'object' + && Object.prototype.hasOwnProperty.call(value, DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND) + && (value as DogfoodBlockRegistryEntryBrandCarrier)[DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND] === true, + ); +} + +export function dogfoodBlockRegistry( + entries: readonly DogfoodBlockRegistryEntry[], +): DogfoodBlockRegistry { + return new DogfoodBlockRegistry(entries); +} + +export function isDogfoodBlockRegistry(value: unknown): value is DogfoodBlockRegistry { + return Boolean( + value + && typeof value === 'object' + && Object.prototype.hasOwnProperty.call(value, DOGFOOD_BLOCK_REGISTRY_BRAND) + && (value as DogfoodBlockRegistryBrandCarrier)[DOGFOOD_BLOCK_REGISTRY_BRAND] === true, + ); +} + +function dogfoodBlockKey(packageName: string, blockName: string): string { + return `${packageName}/${blockName}`; +} + +function normalizeDogfoodBlockRole(role: DogfoodBlockRole): DogfoodBlockRole { + const normalized = normalizeRequiredText({ + scope: 'dogfood block registry entry', + field: 'role', + value: role, + }) as DogfoodBlockRole; + + if (!DOGFOOD_BLOCK_ROLES.includes(normalized)) { + throw new Error(`dogfood block registry entry: unsupported role ${String(role)}`); + } + + return normalized; +} + +function normalizeRequiredText(input: { + readonly scope: string; + readonly field: string; + readonly value: unknown; +}): string { + if (typeof input.value !== 'string') { + throw new Error(`${input.scope}: ${input.field} must be a string`); + } + + const value = input.value.trim(); + if (value === '') { + throw new Error(`${input.scope}: ${input.field} is required`); + } + + return value; +} + +function optionalTrimmedText(value: unknown): string | undefined { + if (value === undefined) return undefined; + return normalizeRequiredText({ + scope: 'dogfood block registry entry', + field: 'description', + value, + }); +} + +const DOGFOOD_BLOCK_ROLES: readonly DogfoodBlockRole[] = Object.freeze([ + 'app-shell', + 'title', + 'navigation', + 'article', + 'settings', + 'inspector', + 'preview', + 'workbench', + 'fixture', +]); + +interface DogfoodBlockRegistryEntryBrandCarrier { + readonly [DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND]?: true; +} + +interface DogfoodBlockRegistryBrandCarrier { + readonly [DOGFOOD_BLOCK_REGISTRY_BRAND]?: true; +} diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts new file mode 100644 index 00000000..afd810ee --- /dev/null +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { + defineBlock, + type BlockDefinition, +} from '@flyingrobots/bijou'; +import { + DOGFOOD_BLOCK_PACKAGE, + dogfoodBlockRegistry, + dogfoodBlockRegistryEntry, + isDogfoodBlockRegistry, + isDogfoodBlockRegistryEntry, + type DogfoodBlockRegistryEntry, +} from '../../../examples/docs/dogfood-blocks.js'; + +describe('DF-069 DOGFOOD block registry primitives', () => { + it('creates frozen registry entries around branded block definitions without rendering', () => { + let renderCalls = 0; + const block = testBlock('NavigationListBlock', () => { + renderCalls += 1; + return 'navigation'; + }); + const entry = dogfoodBlockRegistryEntry({ + block, + role: 'navigation', + surfaceId: 'docs.nav', + tags: ['primary', 'docs'], + }); + const registry = dogfoodBlockRegistry([entry]); + + expect(isDogfoodBlockRegistryEntry(entry)).toBe(true); + expect(isDogfoodBlockRegistry(registry)).toBe(true); + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.tags)).toBe(true); + expect(Object.isFrozen(registry)).toBe(true); + expect(renderCalls).toBe(0); + expect(registry.blockNames()).toEqual(['NavigationListBlock']); + expect(registry.surfaceIds()).toEqual(['docs.nav']); + expect(registry.roles()).toEqual(['navigation']); + expect(registry.forSurface(' docs.nav ')).toBe(entry); + expect(registry.forBlock(' NavigationListBlock ')).toBe(entry); + }); + + it('rejects loose block-shaped objects and unsupported roles from untyped callers', () => { + const block = testBlock('DocumentationArticleBlock'); + + expect(() => dogfoodBlockRegistryEntry({ + block: { ...block } as BlockDefinition, + role: 'article', + surfaceId: 'docs.article', + })).toThrow(); + + expect(() => dogfoodBlockRegistryEntry({ + block, + role: 'prop-soup' as never, + surfaceId: 'docs.article', + })).toThrow(); + }); + + it('rejects duplicate block and surface ownership', () => { + const article = dogfoodBlockRegistryEntry({ + block: testBlock('DocumentationArticleBlock'), + role: 'article', + surfaceId: 'docs.article', + }); + const articleAgain = dogfoodBlockRegistryEntry({ + block: testBlock('DocumentationArticleBlock'), + role: 'article', + surfaceId: 'docs.article.alt', + }); + const conflictingSurface = dogfoodBlockRegistryEntry({ + block: testBlock('GuideInspectorBlock'), + role: 'inspector', + surfaceId: 'docs.article', + }); + + expect(() => dogfoodBlockRegistry([article, articleAgain])).toThrow(); + expect(() => dogfoodBlockRegistry([article, conflictingSurface])).toThrow(); + }); + + it('returns immutable snapshots and keeps block discovery free of provider handles', () => { + const entry = dogfoodBlockRegistryEntry({ + block: testBlock('SettingsMenuBlock'), + role: 'settings', + surfaceId: 'docs.settings', + description: 'DOGFOOD settings menu surface.', + }); + const registry = dogfoodBlockRegistry([entry]); + const entries = registry.entries() as DogfoodBlockRegistryEntry[]; + + expect(() => entries.push(entry)).toThrow(); + expect(() => (registry.surfaceIds() as string[]).push('docs.other')).toThrow(); + expect(Object.keys(entry)).not.toContain('provider'); + expect(Object.keys(entry)).not.toContain('providerHandle'); + expect(Object.keys(entry)).not.toContain('subscription'); + expect(Object.keys(entry)).not.toContain('refresh'); + expect(Object.keys(entry)).not.toContain('dispatch'); + expect(Object.keys(entry)).not.toContain('render'); + }); + + it('creates new registries through with() without mutating previous collections', () => { + const title = dogfoodBlockRegistryEntry({ + block: testBlock('TitleScreenBlock'), + role: 'title', + surfaceId: 'landing.title', + }); + const settings = dogfoodBlockRegistryEntry({ + block: testBlock('SettingsMenuBlock'), + role: 'settings', + surfaceId: 'docs.settings', + }); + const registry = dogfoodBlockRegistry([title]); + const next = registry.with(settings); + + expect(registry.blockNames()).toEqual(['TitleScreenBlock']); + expect(next.blockNames()).toEqual(['TitleScreenBlock', 'SettingsMenuBlock']); + expect(next.forSurface('docs.settings')).toBe(settings); + }); +}); + +function testBlock( + blockName: string, + render: () => string = () => blockName, +): BlockDefinition { + return defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName, + family: 'dogfood-fixtures', + scale: 'section', + modes: ['interactive', 'static', 'pipe', 'accessible'], + docs: { + summary: `${blockName} test block.`, + }, + slots: [ + { id: 'content', required: true }, + ], + semanticFacts: [{ kind: 'entity', key: 'block', value: blockName }], + }, + render: () => ({ output: render() }), + }); +} From 259901c0c2f2115d412683e3a5cfee0b08371e4e Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:07:55 -0700 Subject: [PATCH 03/10] feat(dogfood): add storybook workbench block --- examples/docs/dogfood-blocks.ts | 200 ++++++++++++++++-- .../DF-069/dogfood-block-registry.test.ts | 31 +++ 2 files changed, 214 insertions(+), 17 deletions(-) diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index 51be8fff..f706b2ce 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -1,12 +1,38 @@ import { + commandIntent, + defineBlock, + defineDataRequirement, + defineViewData, isBlockDefinition, type BlockDefinition, + type BlockRenderInput, + type BlockRenderResult, + type OutputMode, } from '@flyingrobots/bijou'; const DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND: unique symbol = Symbol('DogfoodBlockRegistryEntry'); const DOGFOOD_BLOCK_REGISTRY_BRAND: unique symbol = Symbol('DogfoodBlockRegistry'); export const DOGFOOD_BLOCK_PACKAGE = '@flyingrobots/bijou-dogfood'; +const DOGFOOD_BLOCK_MODES: readonly OutputMode[] = Object.freeze([ + 'interactive', + 'static', + 'pipe', + 'accessible', +]); +const DOGFOOD_BLOCK_ROLES: readonly DogfoodBlockRole[] = Object.freeze([ + 'app-shell', + 'title', + 'navigation', + 'article', + 'settings', + 'inspector', + 'preview', + 'workbench', + 'fixture', +]); + +export type DogfoodBlockDefinition = BlockDefinition; export type DogfoodBlockRole = | 'app-shell' @@ -20,7 +46,7 @@ export type DogfoodBlockRole = | 'fixture'; export interface DogfoodBlockRegistryEntryInput { - readonly block: BlockDefinition; + readonly block: DogfoodBlockDefinition; readonly role: DogfoodBlockRole; readonly surfaceId: string; readonly description?: string; @@ -29,7 +55,7 @@ export interface DogfoodBlockRegistryEntryInput { export interface DogfoodBlockRegistryEntry { readonly [DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND]: true; - readonly block: BlockDefinition; + readonly block: DogfoodBlockDefinition; readonly blockName: string; readonly packageName: string; readonly role: DogfoodBlockRole; @@ -85,7 +111,7 @@ export class DogfoodBlockRegistry { return Object.freeze([...this.#entries]); } - blocks(): readonly BlockDefinition[] { + blocks(): readonly DogfoodBlockDefinition[] { return Object.freeze(this.#entries.map((entry) => entry.block)); } @@ -131,10 +157,137 @@ export class DogfoodBlockRegistry { } } +export interface StorybookWorkbenchBlockConfig { + readonly storyCount?: number; + readonly selectedStoryLabel?: string; + readonly profileLabel?: string; +} + +export const storybookStoriesRequirement = defineDataRequirement({ + id: 'storybook.stories', + resource: 'dogfood.storybook.stories', + label: 'Story catalog', + description: 'Available component stories for the DOGFOOD Storybook workbench.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'StorybookWorkbenchBlock' }], +}); + +export const storybookSelectionRequirement = defineDataRequirement({ + id: 'storybook.selection', + resource: 'dogfood.storybook.selection', + label: 'Selected story', + description: 'The active story, variant, and profile in the Storybook workbench.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'StorybookWorkbenchBlock' }], +}); + +export const storybookWorkbenchData = defineViewData({ + id: 'storybook-workbench.data', + label: 'StorybookWorkbenchBlock data', + description: 'DOGFOOD Storybook catalog and selection data.', + requirements: [ + { name: 'stories', requirement: storybookStoriesRequirement }, + { name: 'selection', requirement: storybookSelectionRequirement }, + ], +}); + +export const storybookSelectStoryIntent = commandIntent<{ readonly storyId: string }>( + 'storybook.selectStory', + { + label: 'Select story', + description: 'Request focus for a component story.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'StorybookWorkbenchBlock' }], + }, +); + +export const storybookCycleVariantIntent = commandIntent<{ readonly direction: -1 | 1 }>( + 'storybook.cycleVariant', + { + label: 'Cycle variant', + description: 'Request the next or previous variant for the selected story.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'StorybookWorkbenchBlock' }], + }, +); + +export const storybookSetProfileIntent = commandIntent<{ readonly profileIndex: number }>( + 'storybook.setProfile', + { + label: 'Set profile', + description: 'Request a viewport/profile preset for the selected story.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'StorybookWorkbenchBlock' }], + }, +); + +export const storybookWorkbenchBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'StorybookWorkbenchBlock', + family: 'dogfood-workbench', + scale: 'app', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Frames the component story catalog, live preview, testing notes, and footer controls.', + useWhen: [ + 'DOGFOOD needs to present component stories inside the same app frame posture as docs.', + ], + avoidWhen: [ + 'A production app needs to embed a single component preview without the DOGFOOD story catalog.', + ], + relatedDocs: ['docs/design/DF-069-block-authored-dogfood.md'], + }, + sourcePath: 'examples/docs/storybook-app.ts', + slots: [ + { id: 'catalog', required: true, description: 'Story catalog navigation.' }, + { id: 'preview', required: true, description: 'Live component story preview.' }, + { id: 'testing', required: false, description: 'Mode-lowering and interaction notes.' }, + { id: 'footer', required: false, description: 'Workbench key hints and status text.' }, + ], + variants: [ + { + id: 'wide', + label: 'Wide', + requiredSlots: ['catalog', 'preview'], + optionalSlots: ['testing', 'footer'], + facts: [{ kind: 'state', key: 'dogfood.storybook.layout', value: 'wide' }], + }, + { + id: 'narrow', + label: 'Narrow', + requiredSlots: ['preview'], + optionalSlots: ['catalog', 'testing', 'footer'], + facts: [{ kind: 'state', key: 'dogfood.storybook.layout', value: 'narrow' }], + }, + ], + composedComponents: ['createFramedApp()', 'viewportSurface()', 'browsableListSurface()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'StorybookWorkbenchBlock' }], + storyIds: ['storybook.workbench.wide', 'storybook.workbench.narrow'], + examples: [{ id: 'storybook.dogfood', label: 'DOGFOOD Storybook workbench' }], + tags: ['dogfood', 'storybook', 'workbench', 'app-frame'], + }, + data: storybookWorkbenchData, + commands: [ + storybookSelectStoryIntent, + storybookCycleVariantIntent, + storybookSetProfileIntent, + ], + render: renderStorybookWorkbenchBlock, +}); + +export const storybookWorkbenchBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: storybookWorkbenchBlock, + role: 'workbench', + surfaceId: 'storybook.workbench', + description: 'Storybook component workstation entrypoint.', + tags: ['storybook', 'workbench'], +}); + +export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ + storybookWorkbenchBlockRegistryEntry, +]); + export function dogfoodBlockRegistryEntry( input: DogfoodBlockRegistryEntryInput, ): DogfoodBlockRegistryEntry { - if (!isBlockDefinition(input.block)) { + const candidateBlock: unknown = input.block; + if (!isBlockDefinition(candidateBlock)) { throw new Error('dogfood block registry entry: block was not created by defineBlock()'); } @@ -159,7 +312,7 @@ export function dogfoodBlockRegistryEntry( surfaceId, ...(description === undefined ? {} : { description }), tags, - } as DogfoodBlockRegistryEntry; + } as unknown as DogfoodBlockRegistryEntry; Object.defineProperty(entry, DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND, { value: true }); return Object.freeze(entry); @@ -191,6 +344,31 @@ export function isDogfoodBlockRegistry(value: unknown): value is DogfoodBlockReg ); } +function renderStorybookWorkbenchBlock( + input: BlockRenderInput, +): BlockRenderResult { + const storyCount = input.config?.storyCount ?? 0; + const selectedStoryLabel = input.config?.selectedStoryLabel ?? 'none'; + const profileLabel = input.config?.profileLabel ?? 'default'; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `StorybookWorkbench stories: ${storyCount}; selected: ${selectedStoryLabel}; profile: ${profileLabel}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'StorybookWorkbenchBlock' }], + }; + } + + return { + output: [ + 'StorybookWorkbench', + `stories: ${storyCount}`, + `selected: ${selectedStoryLabel}`, + `profile: ${profileLabel}`, + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'StorybookWorkbenchBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } @@ -235,18 +413,6 @@ function optionalTrimmedText(value: unknown): string | undefined { }); } -const DOGFOOD_BLOCK_ROLES: readonly DogfoodBlockRole[] = Object.freeze([ - 'app-shell', - 'title', - 'navigation', - 'article', - 'settings', - 'inspector', - 'preview', - 'workbench', - 'fixture', -]); - interface DogfoodBlockRegistryEntryBrandCarrier { readonly [DOGFOOD_BLOCK_REGISTRY_ENTRY_BRAND]?: true; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index afd810ee..cae9d929 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -5,10 +5,13 @@ import { } from '@flyingrobots/bijou'; import { DOGFOOD_BLOCK_PACKAGE, + defaultDogfoodBlockRegistry, dogfoodBlockRegistry, dogfoodBlockRegistryEntry, isDogfoodBlockRegistry, isDogfoodBlockRegistryEntry, + storybookWorkbenchBlock, + storybookWorkbenchBlockRegistryEntry, type DogfoodBlockRegistryEntry, } from '../../../examples/docs/dogfood-blocks.js'; @@ -115,6 +118,34 @@ describe('DF-069 DOGFOOD block registry primitives', () => { expect(next.blockNames()).toEqual(['TitleScreenBlock', 'SettingsMenuBlock']); expect(next.forSurface('docs.settings')).toBe(settings); }); + + it('publishes Storybook as an inspectable DOGFOOD workbench block', () => { + expect(storybookWorkbenchBlockRegistryEntry.block).toBe(storybookWorkbenchBlock); + expect(storybookWorkbenchBlockRegistryEntry.role).toBe('workbench'); + expect(defaultDogfoodBlockRegistry.forSurface('storybook.workbench')).toBe( + storybookWorkbenchBlockRegistryEntry, + ); + expect(defaultDogfoodBlockRegistry.blockNames()).toEqual(['StorybookWorkbenchBlock']); + expect(storybookWorkbenchBlock.data?.names()).toEqual(['stories', 'selection']); + expect(storybookWorkbenchBlock.commands?.map((intent) => intent.id)).toEqual([ + 'storybook.selectStory', + 'storybook.cycleVariant', + 'storybook.setProfile', + ]); + + const output = storybookWorkbenchBlock.render({ + config: { + storyCount: 12, + selectedStoryLabel: 'Button / Primary', + profileLabel: 'desktop', + }, + mode: 'pipe', + }).output; + + expect(output).toBe( + 'StorybookWorkbench stories: 12; selected: Button / Primary; profile: desktop', + ); + }); }); function testBlock( From 790b487256a86e2d1237d396134b672390fae4d0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:30:00 -0700 Subject: [PATCH 04/10] feat(dogfood): frame storybook with app shell --- examples/docs/storybook-app.ts | 179 ++++++++++++++---- examples/docs/storybook.ts | 4 +- .../DF-027/storybook-workstation.test.ts | 18 +- 3 files changed, 160 insertions(+), 41 deletions(-) diff --git a/examples/docs/storybook-app.ts b/examples/docs/storybook-app.ts index 0fe783ac..c8d5f159 100644 --- a/examples/docs/storybook-app.ts +++ b/examples/docs/storybook-app.ts @@ -10,7 +10,9 @@ import { } from '@flyingrobots/bijou'; import { browsableListSurface, + createFramedApp, createBrowsableListState, + createKeyMap, isKeyMsg, isMouseMsg, isResizeMsg, @@ -24,6 +26,12 @@ import { type BrowsableListItem, type BrowsableListState, type Cmd, + type FrameLayoutNode, + type FramePage, + type FramePageMsg, + type FramedApp, + type KeyMsg, + type ResizeMsg, } from '../../packages/bijou-tui/src/index.js'; import { createStoryProfileContext, @@ -61,10 +69,17 @@ export interface StorybookModel { readonly previewTimeMs: number; } +export interface StorybookPageMsg { + readonly type: 'storybook-key'; + readonly key: string; +} + +type StorybookRuntimeMsg = FramePageMsg | KeyMsg | ResizeMsg; + export function createStorybookApp( ctx: BijouContext, options: StorybookAppOptions = {}, -): App { +): App { const stories = options.stories ?? COMPONENT_STORIES; const title = options.title ?? DEFAULT_TITLE; @@ -74,33 +89,7 @@ export function createStorybookApp( }, update(msg, model) { - if (isResizeMsg(msg)) { - return [{ - ...model, - columns: Math.max(1, msg.columns), - rows: Math.max(1, msg.rows), - storyState: syncStoryListHeight(model.storyState, storyListHeight(msg.rows)), - }, []]; - } - - if (msg.type === 'pulse') { - return [{ - ...model, - previewTimeMs: model.previewTimeMs + Math.round(Math.max(0, msg.dt) * 1000), - }, []]; - } - - if (isMouseMsg(msg)) { - if (msg.action === 'scroll-down') return [focusStory(model, 1), []]; - if (msg.action === 'scroll-up') return [focusStory(model, -1), []]; - return [model, []]; - } - - if (!isKeyMsg(msg)) { - return [model, []]; - } - - return updateKey(msg.key, model, stories); + return updateStorybookMessage(msg, model, stories); }, view(model) { @@ -109,6 +98,50 @@ export function createStorybookApp( }; } +export function createStorybookFrameApp( + ctx: BijouContext, + options: StorybookAppOptions = {}, +): FramedApp { + const stories = options.stories ?? COMPONENT_STORIES; + const title = options.title ?? DEFAULT_TITLE; + + return createFramedApp({ + ctx, + title, + initialColumns: ctx.runtime.columns, + initialRows: ctx.runtime.rows, + helpLineSource: () => FOOTER_HINT, + pages: [createStorybookPage(ctx, stories, title, options.initialStoryId)], + }); +} + +export function createStorybookPage( + ctx: BijouContext, + stories: readonly ComponentStory[] = COMPONENT_STORIES, + title = DEFAULT_TITLE, + initialStoryId?: string, +): FramePage { + return { + id: 'storybook', + title: 'Storybook', + init: () => [createInitialStorybookModel(ctx, stories, title, initialStoryId), []], + update(msg: FramePageMsg, model) { + return updateStorybookMessage(msg, model, stories); + }, + keyMap: storybookPageKeys, + layout: (model) => ({ + kind: 'pane', + paneId: 'storybook-workbench', + overflowX: 'scroll', + render: (width, height) => renderStorybookBody({ + ...model, + columns: width, + rows: height, + }, ctx, stories), + }) satisfies FrameLayoutNode, + }; +} + export function createInitialStorybookModel( ctx: BijouContext, stories: readonly ComponentStory[] = COMPONENT_STORIES, @@ -145,11 +178,49 @@ export function selectedStorybookStory( return stories.find((story) => story.id === storyId) ?? stories[0]!; } +function updateStorybookMessage( + msg: StorybookRuntimeMsg, + model: StorybookModel, + stories: readonly ComponentStory[], +): [StorybookModel, Cmd[]] { + if (isResizeMsg(msg)) { + return [{ + ...model, + columns: Math.max(1, msg.columns), + rows: Math.max(1, msg.rows), + storyState: syncStoryListHeight(model.storyState, storyListHeight(msg.rows)), + }, []]; + } + + if (msg.type === 'pulse') { + return [{ + ...model, + previewTimeMs: model.previewTimeMs + Math.round(Math.max(0, msg.dt) * 1000), + }, []]; + } + + if (isMouseMsg(msg)) { + if (msg.action === 'scroll-down') return [focusStory(model, 1), []]; + if (msg.action === 'scroll-up') return [focusStory(model, -1), []]; + return [model, []]; + } + + if (msg.type === 'storybook-key') { + return updateKey(msg.key, model, stories); + } + + if (!isKeyMsg(msg)) { + return [model, []]; + } + + return updateKey(msg.key, model, stories); +} + function updateKey( key: string, model: StorybookModel, stories: readonly ComponentStory[], -): [StorybookModel, Cmd[]] { +): [StorybookModel, Cmd[]] { switch (key) { case 'q': case 'escape': @@ -182,6 +253,25 @@ function updateKey( } } +const storybookPageKeys = createKeyMap() + .group('Storybook', (group) => group + .bind('down', 'Next story', { type: 'storybook-key', key: 'down' }) + .bind('j', 'Next story', { type: 'storybook-key', key: 'j' }) + .bind(']', 'Next story', { type: 'storybook-key', key: ']' }) + .bind('up', 'Previous story', { type: 'storybook-key', key: 'up' }) + .bind('k', 'Previous story', { type: 'storybook-key', key: 'k' }) + .bind('[', 'Previous story', { type: 'storybook-key', key: '[' }) + .bind('pagedown', 'Page down', { type: 'storybook-key', key: 'pagedown' }) + .bind('d', 'Page down', { type: 'storybook-key', key: 'd' }) + .bind('pageup', 'Page up', { type: 'storybook-key', key: 'pageup' }) + .bind('u', 'Page up', { type: 'storybook-key', key: 'u' }) + .bind('.', 'Next variant', { type: 'storybook-key', key: '.' }) + .bind(',', 'Previous variant', { type: 'storybook-key', key: ',' }) + .bind('1', 'Profile 1', { type: 'storybook-key', key: '1' }) + .bind('2', 'Profile 2', { type: 'storybook-key', key: '2' }) + .bind('3', 'Profile 3', { type: 'storybook-key', key: '3' }) + .bind('4', 'Profile 4', { type: 'storybook-key', key: '4' })); + function renderStorybook( model: StorybookModel, ctx: BijouContext, @@ -196,23 +286,36 @@ function renderStorybook( const bodyTop = 1; const bodyHeight = Math.max(1, model.rows - 2); - if (model.columns >= 116 && bodyHeight >= 12) { + screen.blit(renderStorybookBody({ ...model, rows: bodyHeight }, ctx, stories), 0, bodyTop); + + screen.blit(line(fit(FOOTER_HINT, model.columns), model.columns), 0, model.rows - 1); + return screen; +} + +function renderStorybookBody( + model: StorybookModel, + ctx: BijouContext, + stories: readonly ComponentStory[], +): Surface { + const screen = createSurface(model.columns, model.rows); + const story = selectedStorybookStory(model, stories); + + if (model.columns >= 116 && model.rows >= 12) { const catalogWidth = 34; const testingWidth = 36; const previewWidth = Math.max(20, model.columns - catalogWidth - testingWidth); - screen.blit(renderCatalogPane(model, catalogWidth, bodyHeight, ctx), 0, bodyTop); - screen.blit(renderPreviewPane(model, story, previewWidth, bodyHeight, ctx), catalogWidth, bodyTop); - screen.blit(renderTestingPane(model, story, testingWidth, bodyHeight, ctx), catalogWidth + previewWidth, bodyTop); - } else if (model.columns >= 76 && bodyHeight >= 10) { + screen.blit(renderCatalogPane(model, catalogWidth, model.rows, ctx), 0, 0); + screen.blit(renderPreviewPane(model, story, previewWidth, model.rows, ctx), catalogWidth, 0); + screen.blit(renderTestingPane(model, story, testingWidth, model.rows, ctx), catalogWidth + previewWidth, 0); + } else if (model.columns >= 76 && model.rows >= 10) { const catalogWidth = 30; const previewWidth = Math.max(20, model.columns - catalogWidth); - screen.blit(renderCatalogPane(model, catalogWidth, bodyHeight, ctx), 0, bodyTop); - screen.blit(renderPreviewPane(model, story, previewWidth, bodyHeight, ctx), catalogWidth, bodyTop); + screen.blit(renderCatalogPane(model, catalogWidth, model.rows, ctx), 0, 0); + screen.blit(renderPreviewPane(model, story, previewWidth, model.rows, ctx), catalogWidth, 0); } else { - screen.blit(renderPreviewPane(model, story, model.columns, bodyHeight, ctx), 0, bodyTop); + screen.blit(renderPreviewPane(model, story, model.columns, model.rows, ctx), 0, 0); } - screen.blit(line(fit(FOOTER_HINT, model.columns), model.columns), 0, model.rows - 1); return screen; } diff --git a/examples/docs/storybook.ts b/examples/docs/storybook.ts index 56b836d6..4cac76fb 100644 --- a/examples/docs/storybook.ts +++ b/examples/docs/storybook.ts @@ -1,11 +1,11 @@ import { initDefaultContext } from '../../packages/bijou-node/src/index.js'; import { run } from '../../packages/bijou-tui/src/index.js'; -import { createStorybookApp } from './storybook-app.js'; +import { createStorybookFrameApp } from './storybook-app.js'; const ctx = initDefaultContext(); const initialStoryId = valueAfter(process.argv.slice(2), '--story'); -await run(createStorybookApp(ctx, { initialStoryId }), { ctx, mouse: true }); +await run(createStorybookFrameApp(ctx, { initialStoryId }), { ctx, mouse: true }); function valueAfter(argv: readonly string[], flag: string): string | undefined { const index = argv.indexOf(flag); diff --git a/tests/cycles/DF-027/storybook-workstation.test.ts b/tests/cycles/DF-027/storybook-workstation.test.ts index e568ebd8..a96acc0b 100644 --- a/tests/cycles/DF-027/storybook-workstation.test.ts +++ b/tests/cycles/DF-027/storybook-workstation.test.ts @@ -5,6 +5,7 @@ import { storyCaptureMatrixText, stripAnsi, surfaceToString } from '@flyingrobot import { createTestContext } from '@flyingrobots/bijou/adapters/test'; import { createStorybookApp, + createStorybookFrameApp, selectedStorybookStory, } from '../../../examples/docs/storybook-app.js'; import { @@ -90,7 +91,7 @@ describe('DF-027 Storybook-style DOGFOOD workstation', () => { const entrypoint = readRepoFile('examples/docs/storybook.ts'); expect(packageJson.scripts.storybook).toBe('node --import tsx examples/docs/storybook.ts'); - expect(entrypoint).toContain('createStorybookApp'); + expect(entrypoint).toContain('createStorybookFrameApp'); expect(entrypoint).not.toContain('createDocsApp'); }); @@ -110,4 +111,19 @@ describe('DF-027 Storybook-style DOGFOOD workstation', () => { expect(text).toContain('all required modes'); expect(text).not.toContain('Press [Enter]'); }); + + it('runs the interactive Storybook entrypoint through the AppFrame shell', () => { + const ctx = createTestContext({ mode: 'interactive', runtime: { columns: 120, rows: 40 } }); + const app = createStorybookFrameApp(ctx, { initialStoryId: 'notification-system' }); + const [model] = app.init(); + const pageModel = (model as any).pageModels.storybook; + const surface = normalizeViewOutput(app.view(model), { width: 120, height: 40 }).surface; + const text = stripAnsi(surfaceToString(surface, ctx.style)); + + expect((model as any).activePageId).toBe('storybook'); + expect(selectedStorybookStory(pageModel).id).toBe('notification-system'); + expect(text).toContain('Bijou Storybook'); + expect(text).toContain('Storybook'); + expect(text).toContain('notification-system'); + }); }); From 54743ede37abea953fc92d30448daa12912a3ad5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:35:27 -0700 Subject: [PATCH 05/10] feat(dogfood): add title screen block --- examples/docs/dogfood-blocks.ts | 114 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 24 +++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index f706b2ce..21c9d8cb 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -163,6 +163,88 @@ export interface StorybookWorkbenchBlockConfig { readonly profileLabel?: string; } +export interface TitleScreenBlockConfig { + readonly title?: string; + readonly subtitle?: string; +} + +export const titleRouteRequirement = defineDataRequirement({ + id: 'title.route', + resource: 'dogfood.route', + label: 'Current route', + description: 'Current DOGFOOD route used by the title screen call-to-action posture.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'TitleScreenBlock' }], +}); + +export const titleScreenData = defineViewData({ + id: 'title-screen.data', + label: 'TitleScreenBlock data', + description: 'DOGFOOD title route context.', + requirements: [ + { name: 'route', requirement: titleRouteRequirement }, + ], +}); + +export const titleOpenDocsIntent = commandIntent('title.openDocs', { + label: 'Open docs', + description: 'Request navigation from the title screen into the documentation app.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'TitleScreenBlock' }], +}); + +export const titleOpenStorybookIntent = commandIntent('title.openStorybook', { + label: 'Open Storybook', + description: 'Request navigation from the title screen into the Storybook workbench.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'TitleScreenBlock' }], +}); + +export const titleOpenSettingsIntent = commandIntent('title.openSettings', { + label: 'Open settings', + description: 'Request the frame-owned DOGFOOD settings surface.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'TitleScreenBlock' }], +}); + +export const titleScreenBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'TitleScreenBlock', + family: 'dogfood-entry', + scale: 'app', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Introduces Bijou DOGFOOD and routes users toward docs, Storybook, or settings.', + useWhen: ['DOGFOOD needs a first screen that exposes app-level entry intents.'], + avoidWhen: ['A nested documentation article needs local section content.'], + relatedDocs: ['docs/DOGFOOD.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'hero', required: true, description: 'Primary title and value proposition.' }, + { id: 'actions', required: false, description: 'Available entry actions.' }, + ], + variants: [ + { + id: 'default', + label: 'Default', + requiredSlots: ['hero'], + optionalSlots: ['actions'], + facts: [{ kind: 'state', key: 'dogfood.title.layout', value: 'default' }], + }, + ], + composedComponents: ['landing page', 'AppFrame settings'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'TitleScreenBlock' }], + storyIds: ['title-screen.default'], + examples: [{ id: 'dogfood.title', label: 'DOGFOOD title screen' }], + tags: ['dogfood', 'title', 'navigation'], + }, + data: titleScreenData, + commands: [ + titleOpenDocsIntent, + titleOpenStorybookIntent, + titleOpenSettingsIntent, + ], + render: renderTitleScreenBlock, +}); + export const storybookStoriesRequirement = defineDataRequirement({ id: 'storybook.stories', resource: 'dogfood.storybook.stories', @@ -279,7 +361,16 @@ export const storybookWorkbenchBlockRegistryEntry = dogfoodBlockRegistryEntry({ tags: ['storybook', 'workbench'], }); +export const titleScreenBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: titleScreenBlock, + role: 'title', + surfaceId: 'landing.title', + description: 'DOGFOOD title and entry action surface.', + tags: ['title', 'entry'], +}); + export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ + titleScreenBlockRegistryEntry, storybookWorkbenchBlockRegistryEntry, ]); @@ -369,6 +460,29 @@ function renderStorybookWorkbenchBlock( }; } +function renderTitleScreenBlock( + input: BlockRenderInput, +): BlockRenderResult { + const title = input.config?.title ?? 'Bijou Docs'; + const subtitle = input.config?.subtitle ?? 'Blocks, components, localization, and terminal UI proof.'; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `${title}: ${subtitle}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'TitleScreenBlock' }], + }; + } + + return { + output: [ + title, + subtitle, + 'Actions: open docs; open Storybook; open settings', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'TitleScreenBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index cae9d929..9607b1f6 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -12,6 +12,8 @@ import { isDogfoodBlockRegistryEntry, storybookWorkbenchBlock, storybookWorkbenchBlockRegistryEntry, + titleScreenBlock, + titleScreenBlockRegistryEntry, type DogfoodBlockRegistryEntry, } from '../../../examples/docs/dogfood-blocks.js'; @@ -125,7 +127,7 @@ describe('DF-069 DOGFOOD block registry primitives', () => { expect(defaultDogfoodBlockRegistry.forSurface('storybook.workbench')).toBe( storybookWorkbenchBlockRegistryEntry, ); - expect(defaultDogfoodBlockRegistry.blockNames()).toEqual(['StorybookWorkbenchBlock']); + expect(defaultDogfoodBlockRegistry.blockNames()).toContain('StorybookWorkbenchBlock'); expect(storybookWorkbenchBlock.data?.names()).toEqual(['stories', 'selection']); expect(storybookWorkbenchBlock.commands?.map((intent) => intent.id)).toEqual([ 'storybook.selectStory', @@ -146,6 +148,26 @@ describe('DF-069 DOGFOOD block registry primitives', () => { 'StorybookWorkbench stories: 12; selected: Button / Primary; profile: desktop', ); }); + + it('publishes the DOGFOOD title screen as an app-level block', () => { + expect(titleScreenBlockRegistryEntry.block).toBe(titleScreenBlock); + expect(titleScreenBlockRegistryEntry.role).toBe('title'); + expect(defaultDogfoodBlockRegistry.forSurface('landing.title')).toBe(titleScreenBlockRegistryEntry); + expect(titleScreenBlock.data?.names()).toEqual(['route']); + expect(titleScreenBlock.commands?.map((intent) => intent.id)).toEqual([ + 'title.openDocs', + 'title.openStorybook', + 'title.openSettings', + ]); + + expect(titleScreenBlock.render({ + config: { + title: 'Bijou', + subtitle: 'Terminal UI proof', + }, + mode: 'accessible', + }).output).toBe('Bijou: Terminal UI proof'); + }); }); function testBlock( From 0f08009d1edf0a6f434bb971936f54e08088ef79 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:41:40 -0700 Subject: [PATCH 06/10] feat(dogfood): add navigation list block --- examples/docs/dogfood-blocks.ts | 133 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 24 ++++ 2 files changed, 157 insertions(+) diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index 21c9d8cb..6db74563 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -168,6 +168,106 @@ export interface TitleScreenBlockConfig { readonly subtitle?: string; } +export interface NavigationListBlockConfig { + readonly itemCount?: number; + readonly activeLabel?: string; +} + +export const navigationItemsRequirement = defineDataRequirement({ + id: 'navigation.items', + resource: 'dogfood.navigation.items', + label: 'Navigation items', + description: 'Visible DOGFOOD navigation groups and rows.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'NavigationListBlock' }], +}); + +export const navigationSelectionRequirement = defineDataRequirement({ + id: 'navigation.selection', + resource: 'dogfood.navigation.selection', + label: 'Navigation selection', + description: 'Focused or active DOGFOOD navigation row.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'NavigationListBlock' }], +}); + +export const navigationListData = defineViewData({ + id: 'navigation-list.data', + label: 'NavigationListBlock data', + description: 'DOGFOOD navigation rows and selected item.', + requirements: [ + { name: 'items', requirement: navigationItemsRequirement }, + { name: 'selection', requirement: navigationSelectionRequirement }, + ], +}); + +export const navigationSelectItemIntent = commandIntent<{ readonly itemId: string }>( + 'navigation.selectItem', + { + label: 'Select item', + description: 'Request activation of a DOGFOOD navigation row.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'NavigationListBlock' }], + }, +); + +export const navigationExpandGroupIntent = commandIntent<{ readonly groupId: string }>( + 'navigation.expandGroup', + { + label: 'Expand group', + description: 'Request expansion of a DOGFOOD navigation group.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'NavigationListBlock' }], + }, +); + +export const navigationCollapseGroupIntent = commandIntent<{ readonly groupId: string }>( + 'navigation.collapseGroup', + { + label: 'Collapse group', + description: 'Request collapse of a DOGFOOD navigation group.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'NavigationListBlock' }], + }, +); + +export const navigationListBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'NavigationListBlock', + family: 'dogfood-navigation', + scale: 'panel', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Owns DOGFOOD section and guide navigation as selectable semantic rows.', + useWhen: ['DOGFOOD needs selectable navigation with explicit command intents.'], + avoidWhen: ['A component only needs a local menu without app navigation semantics.'], + relatedDocs: ['docs/DOGFOOD.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'items', required: true, description: 'Navigation rows and groups.' }, + { id: 'selection', required: false, description: 'Current focused or active row.' }, + ], + variants: [ + { + id: 'docs-sidebar', + label: 'Docs sidebar', + requiredSlots: ['items'], + optionalSlots: ['selection'], + facts: [{ kind: 'state', key: 'dogfood.navigation.scope', value: 'docs-sidebar' }], + }, + ], + composedComponents: ['browsableListSurface()', 'viewportSurface()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'NavigationListBlock' }], + storyIds: ['navigation-list.docs-sidebar'], + examples: [{ id: 'dogfood.navigation', label: 'DOGFOOD docs navigation' }], + tags: ['dogfood', 'navigation', 'selection'], + }, + data: navigationListData, + commands: [ + navigationSelectItemIntent, + navigationExpandGroupIntent, + navigationCollapseGroupIntent, + ], + render: renderNavigationListBlock, +}); + export const titleRouteRequirement = defineDataRequirement({ id: 'title.route', resource: 'dogfood.route', @@ -369,8 +469,17 @@ export const titleScreenBlockRegistryEntry = dogfoodBlockRegistryEntry({ tags: ['title', 'entry'], }); +export const navigationListBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: navigationListBlock, + role: 'navigation', + surfaceId: 'docs.navigation', + description: 'DOGFOOD docs and guide navigation surface.', + tags: ['navigation', 'docs'], +}); + export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ titleScreenBlockRegistryEntry, + navigationListBlockRegistryEntry, storybookWorkbenchBlockRegistryEntry, ]); @@ -483,6 +592,30 @@ function renderTitleScreenBlock( }; } +function renderNavigationListBlock( + input: BlockRenderInput, +): BlockRenderResult { + const itemCount = input.config?.itemCount ?? 0; + const activeLabel = input.config?.activeLabel ?? 'none'; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `Navigation items: ${itemCount}; active: ${activeLabel}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'NavigationListBlock' }], + }; + } + + return { + output: [ + 'NavigationListBlock', + `items: ${itemCount}`, + `active: ${activeLabel}`, + 'Intents: select item; expand group; collapse group', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'NavigationListBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index 9607b1f6..c69f2d4a 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -10,6 +10,8 @@ import { dogfoodBlockRegistryEntry, isDogfoodBlockRegistry, isDogfoodBlockRegistryEntry, + navigationListBlock, + navigationListBlockRegistryEntry, storybookWorkbenchBlock, storybookWorkbenchBlockRegistryEntry, titleScreenBlock, @@ -168,6 +170,28 @@ describe('DF-069 DOGFOOD block registry primitives', () => { mode: 'accessible', }).output).toBe('Bijou: Terminal UI proof'); }); + + it('publishes DOGFOOD navigation as a selectable block surface', () => { + expect(navigationListBlockRegistryEntry.block).toBe(navigationListBlock); + expect(navigationListBlockRegistryEntry.role).toBe('navigation'); + expect(defaultDogfoodBlockRegistry.forSurface('docs.navigation')).toBe( + navigationListBlockRegistryEntry, + ); + expect(navigationListBlock.data?.names()).toEqual(['items', 'selection']); + expect(navigationListBlock.commands?.map((intent) => intent.id)).toEqual([ + 'navigation.selectItem', + 'navigation.expandGroup', + 'navigation.collapseGroup', + ]); + + expect(navigationListBlock.render({ + config: { + itemCount: 7, + activeLabel: 'Blocks', + }, + mode: 'pipe', + }).output).toBe('Navigation items: 7; active: Blocks'); + }); }); function testBlock( From dcb2cea7937b997f040988ec4dfb4e45831aa25f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:46:28 -0700 Subject: [PATCH 07/10] feat(dogfood): add documentation article block --- examples/docs/dogfood-blocks.ts | 125 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 23 ++++ 2 files changed, 148 insertions(+) diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index 6db74563..4bb5546d 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -173,6 +173,98 @@ export interface NavigationListBlockConfig { readonly activeLabel?: string; } +export interface DocumentationArticleBlockConfig { + readonly title?: string; + readonly headingCount?: number; +} + +export const documentationArticleRequirement = defineDataRequirement({ + id: 'documentation.article', + resource: 'dogfood.documentation.article', + label: 'Documentation article', + description: 'Current DOGFOOD documentation article body.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'DocumentationArticleBlock' }], +}); + +export const documentationHeadingsRequirement = defineDataRequirement({ + id: 'documentation.headings', + resource: 'dogfood.documentation.headings', + label: 'Article headings', + description: 'Headings discovered from the active DOGFOOD documentation article.', + optional: true, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'DocumentationArticleBlock' }], +}); + +export const documentationArticleData = defineViewData({ + id: 'documentation-article.data', + label: 'DocumentationArticleBlock data', + description: 'DOGFOOD article content and heading outline.', + requirements: [ + { name: 'article', requirement: documentationArticleRequirement }, + { name: 'headings', requirement: documentationHeadingsRequirement }, + ], +}); + +export const documentationSelectHeadingIntent = commandIntent<{ readonly headingId: string }>( + 'documentation.selectHeading', + { + label: 'Select heading', + description: 'Request navigation to a heading in the active article.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'DocumentationArticleBlock' }], + }, +); + +export const documentationOpenReferenceIntent = commandIntent<{ readonly referenceId: string }>( + 'documentation.openReference', + { + label: 'Open reference', + description: 'Request opening a referenced doc, package, or source path.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'DocumentationArticleBlock' }], + }, +); + +export const documentationArticleBlock: BlockDefinition = + defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'DocumentationArticleBlock', + family: 'dogfood-documentation', + scale: 'section', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Owns a DOGFOOD documentation article and its local heading/reference intents.', + useWhen: ['DOGFOOD needs to render a documentation article as a semantic content block.'], + avoidWhen: ['A surface is only selecting which article should be active.'], + relatedDocs: ['docs/README.md', 'docs/DOGFOOD.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'article', required: true, description: 'Markdown or rendered documentation body.' }, + { id: 'outline', required: false, description: 'Article heading outline.' }, + ], + variants: [ + { + id: 'article', + label: 'Article', + requiredSlots: ['article'], + optionalSlots: ['outline'], + facts: [{ kind: 'state', key: 'dogfood.documentation.layout', value: 'article' }], + }, + ], + composedComponents: ['markdown()', 'viewportSurface()', 'link()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'DocumentationArticleBlock' }], + storyIds: ['documentation-article.article'], + examples: [{ id: 'dogfood.documentation.article', label: 'DOGFOOD article content' }], + tags: ['dogfood', 'docs', 'article'], + }, + data: documentationArticleData, + commands: [ + documentationSelectHeadingIntent, + documentationOpenReferenceIntent, + ], + render: renderDocumentationArticleBlock, + }); + export const navigationItemsRequirement = defineDataRequirement({ id: 'navigation.items', resource: 'dogfood.navigation.items', @@ -477,9 +569,18 @@ export const navigationListBlockRegistryEntry = dogfoodBlockRegistryEntry({ tags: ['navigation', 'docs'], }); +export const documentationArticleBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: documentationArticleBlock, + role: 'article', + surfaceId: 'docs.article', + description: 'DOGFOOD documentation article content surface.', + tags: ['docs', 'article'], +}); + export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ titleScreenBlockRegistryEntry, navigationListBlockRegistryEntry, + documentationArticleBlockRegistryEntry, storybookWorkbenchBlockRegistryEntry, ]); @@ -616,6 +717,30 @@ function renderNavigationListBlock( }; } +function renderDocumentationArticleBlock( + input: BlockRenderInput, +): BlockRenderResult { + const title = input.config?.title ?? 'Untitled article'; + const headingCount = input.config?.headingCount ?? 0; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `Article: ${title}; headings: ${headingCount}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'DocumentationArticleBlock' }], + }; + } + + return { + output: [ + 'DocumentationArticleBlock', + `title: ${title}`, + `headings: ${headingCount}`, + 'Intents: select heading; open reference', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'DocumentationArticleBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index c69f2d4a..597bfe7d 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -6,6 +6,8 @@ import { import { DOGFOOD_BLOCK_PACKAGE, defaultDogfoodBlockRegistry, + documentationArticleBlock, + documentationArticleBlockRegistryEntry, dogfoodBlockRegistry, dogfoodBlockRegistryEntry, isDogfoodBlockRegistry, @@ -192,6 +194,27 @@ describe('DF-069 DOGFOOD block registry primitives', () => { mode: 'pipe', }).output).toBe('Navigation items: 7; active: Blocks'); }); + + it('publishes DOGFOOD documentation articles as semantic content blocks', () => { + expect(documentationArticleBlockRegistryEntry.block).toBe(documentationArticleBlock); + expect(documentationArticleBlockRegistryEntry.role).toBe('article'); + expect(defaultDogfoodBlockRegistry.forSurface('docs.article')).toBe( + documentationArticleBlockRegistryEntry, + ); + expect(documentationArticleBlock.data?.names()).toEqual(['article', 'headings']); + expect(documentationArticleBlock.commands?.map((intent) => intent.id)).toEqual([ + 'documentation.selectHeading', + 'documentation.openReference', + ]); + + expect(documentationArticleBlock.render({ + config: { + title: 'Blocks', + headingCount: 5, + }, + mode: 'accessible', + }).output).toBe('Article: Blocks; headings: 5'); + }); }); function testBlock( From 2fd9941e0d2b429f82b16d05a3067c3d4e610813 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:51:22 -0700 Subject: [PATCH 08/10] feat(dogfood): add settings menu block --- examples/docs/dogfood-blocks.ts | 134 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 22 +++ 2 files changed, 156 insertions(+) diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index 4bb5546d..ef5b8540 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -178,6 +178,107 @@ export interface DocumentationArticleBlockConfig { readonly headingCount?: number; } +export interface SettingsMenuBlockConfig { + readonly sectionCount?: number; + readonly activeSettingLabel?: string; +} + +export const settingsSectionsRequirement = defineDataRequirement({ + id: 'settings.sections', + resource: 'dogfood.settings.sections', + label: 'Settings sections', + description: 'Frame-owned DOGFOOD settings sections visible to the settings menu.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'SettingsMenuBlock' }], +}); + +export const settingsSelectionRequirement = defineDataRequirement({ + id: 'settings.selection', + resource: 'dogfood.settings.selection', + label: 'Settings selection', + description: 'Current settings row focus inside the frame-owned settings menu.', + optional: true, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'SettingsMenuBlock' }], +}); + +export const settingsMenuData = defineViewData({ + id: 'settings-menu.data', + label: 'SettingsMenuBlock data', + description: 'DOGFOOD frame settings sections and active row.', + requirements: [ + { name: 'sections', requirement: settingsSectionsRequirement }, + { name: 'selection', requirement: settingsSelectionRequirement }, + ], +}); + +export const settingsActivateRowIntent = commandIntent<{ readonly rowId: string }>( + 'settings.activateRow', + { + label: 'Activate row', + description: 'Request activation of the focused settings row.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'SettingsMenuBlock' }], + }, +); + +export const settingsSetLocaleIntent = commandIntent<{ readonly localeId: string }>( + 'settings.setLocale', + { + label: 'Set locale', + description: 'Request the DOGFOOD locale selection to change.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'SettingsMenuBlock' }], + }, +); + +export const settingsSetShellThemeIntent = commandIntent<{ readonly themeId: string }>( + 'settings.setShellTheme', + { + label: 'Set shell theme', + description: 'Request a frame shell theme change.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'SettingsMenuBlock' }], + }, +); + +export const settingsMenuBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'SettingsMenuBlock', + family: 'dogfood-settings', + scale: 'panel', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Owns the semantic settings menu contract for locale, theme, and app preferences.', + useWhen: ['DOGFOOD needs an inspectable settings surface with command intents.'], + avoidWhen: ['A frame shell only needs the lower-level settings drawer renderer.'], + relatedDocs: ['docs/DOGFOOD.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'sections', required: true, description: 'Settings groups and rows.' }, + { id: 'selection', required: false, description: 'Focused or active setting row.' }, + ], + variants: [ + { + id: 'drawer', + label: 'Drawer', + requiredSlots: ['sections'], + optionalSlots: ['selection'], + facts: [{ kind: 'state', key: 'dogfood.settings.surface', value: 'drawer' }], + }, + ], + composedComponents: ['createFramedApp() settings', 'preferenceListSurface()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'SettingsMenuBlock' }], + storyIds: ['settings-menu.drawer'], + examples: [{ id: 'dogfood.settings', label: 'DOGFOOD settings menu' }], + tags: ['dogfood', 'settings', 'locale', 'theme'], + }, + data: settingsMenuData, + commands: [ + settingsActivateRowIntent, + settingsSetLocaleIntent, + settingsSetShellThemeIntent, + ], + render: renderSettingsMenuBlock, +}); + export const documentationArticleRequirement = defineDataRequirement({ id: 'documentation.article', resource: 'dogfood.documentation.article', @@ -577,10 +678,19 @@ export const documentationArticleBlockRegistryEntry = dogfoodBlockRegistryEntry( tags: ['docs', 'article'], }); +export const settingsMenuBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: settingsMenuBlock, + role: 'settings', + surfaceId: 'frame.settings', + description: 'DOGFOOD frame settings menu surface.', + tags: ['settings', 'frame'], +}); + export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ titleScreenBlockRegistryEntry, navigationListBlockRegistryEntry, documentationArticleBlockRegistryEntry, + settingsMenuBlockRegistryEntry, storybookWorkbenchBlockRegistryEntry, ]); @@ -741,6 +851,30 @@ function renderDocumentationArticleBlock( }; } +function renderSettingsMenuBlock( + input: BlockRenderInput, +): BlockRenderResult { + const sectionCount = input.config?.sectionCount ?? 0; + const activeSettingLabel = input.config?.activeSettingLabel ?? 'none'; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `Settings sections: ${sectionCount}; active: ${activeSettingLabel}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'SettingsMenuBlock' }], + }; + } + + return { + output: [ + 'SettingsMenuBlock', + `sections: ${sectionCount}`, + `active: ${activeSettingLabel}`, + 'Intents: activate row; set locale; set shell theme', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'SettingsMenuBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index 597bfe7d..53c2f66c 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -14,6 +14,8 @@ import { isDogfoodBlockRegistryEntry, navigationListBlock, navigationListBlockRegistryEntry, + settingsMenuBlock, + settingsMenuBlockRegistryEntry, storybookWorkbenchBlock, storybookWorkbenchBlockRegistryEntry, titleScreenBlock, @@ -215,6 +217,26 @@ describe('DF-069 DOGFOOD block registry primitives', () => { mode: 'accessible', }).output).toBe('Article: Blocks; headings: 5'); }); + + it('publishes DOGFOOD settings as a frame-owned block surface', () => { + expect(settingsMenuBlockRegistryEntry.block).toBe(settingsMenuBlock); + expect(settingsMenuBlockRegistryEntry.role).toBe('settings'); + expect(defaultDogfoodBlockRegistry.forSurface('frame.settings')).toBe(settingsMenuBlockRegistryEntry); + expect(settingsMenuBlock.data?.names()).toEqual(['sections', 'selection']); + expect(settingsMenuBlock.commands?.map((intent) => intent.id)).toEqual([ + 'settings.activateRow', + 'settings.setLocale', + 'settings.setShellTheme', + ]); + + expect(settingsMenuBlock.render({ + config: { + sectionCount: 3, + activeSettingLabel: 'Locale', + }, + mode: 'pipe', + }).output).toBe('Settings sections: 3; active: Locale'); + }); }); function testBlock( From 13a8197ab1b570dfde2bdc436b8290fcd14e35bf Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 16:14:11 -0700 Subject: [PATCH 09/10] feat(dogfood): prove block surface coverage --- docs/CHANGELOG.md | 8 + docs/design/DF-069-block-authored-dogfood.md | 16 +- examples/docs/dogfood-blocks.ts | 278 ++++++++++++++++++ .../DF-069/dogfood-block-registry.test.ts | 69 +++++ 4 files changed, 370 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6a551d2..a7f90160 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,14 @@ All packages (`@flyingrobots/bijou`, `@flyingrobots/bijou-node`, `@flyingrobots/ ### ✨ Features +- **Block-authored DOGFOOD surface contracts** — DOGFOOD now has a local, + branded block registry for semantic product surfaces and publishes block + contracts for the title screen, navigation list, documentation article, block + preview, guide inspector, settings menu, and Storybook workbench. The + interactive Storybook entrypoint now runs through the AppFrame shell instead + of a parallel bespoke TUI, while registry discovery remains render-free and + does not introduce provider handles, subscriptions, command dispatch, or a + hidden global block registry. - **Localization port contract for `bijou-i18n`** — `@flyingrobots/bijou-i18n` now exports `LocalizationPort`, `LocalizationRequest`, `LocalizedObject`, localization status/issue/fact diff --git a/docs/design/DF-069-block-authored-dogfood.md b/docs/design/DF-069-block-authored-dogfood.md index fcfebc1f..7898605f 100644 --- a/docs/design/DF-069-block-authored-dogfood.md +++ b/docs/design/DF-069-block-authored-dogfood.md @@ -124,6 +124,20 @@ they render into. - behavior tests proving localization and command intent boundaries stay declarative +## Playback + +DF-069A lands the block-authored DOGFOOD contract layer: + +- DOGFOOD has a local, branded `DogfoodBlockRegistry`. +- Registry discovery rejects loose block-shaped objects. +- Registry discovery records surface ownership without calling `render()`. +- Storybook now has a `StorybookWorkbenchBlock` contract and the interactive + Storybook entrypoint uses the AppFrame shell. +- DOGFOOD surface blocks now cover title, navigation, documentation article, + block preview, guide inspector, settings, and Storybook workbench surfaces. + ## Retrospective -Started in the block-authored DOGFOOD stack. +This slice deliberately stops at semantic surface contracts and the Storybook +frame migration. It does not rewrite every DOGFOOD renderer yet, does not move +provider lifecycle policy, and does not convert leaf components into Blocks. diff --git a/examples/docs/dogfood-blocks.ts b/examples/docs/dogfood-blocks.ts index ef5b8540..7d319277 100644 --- a/examples/docs/dogfood-blocks.ts +++ b/examples/docs/dogfood-blocks.ts @@ -183,6 +183,187 @@ export interface SettingsMenuBlockConfig { readonly activeSettingLabel?: string; } +export interface BlockPreviewBlockConfig { + readonly blockName?: string; + readonly modeCount?: number; +} + +export interface GuideInspectorBlockConfig { + readonly selectionLabel?: string; + readonly factCount?: number; +} + +export const blockPreviewDefinitionRequirement = defineDataRequirement({ + id: 'block-preview.definition', + resource: 'dogfood.blocks.preview.definition', + label: 'Preview block definition', + description: 'Block definition selected for the DOGFOOD Blocks preview.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'BlockPreviewBlock' }], +}); + +export const blockPreviewModesRequirement = defineDataRequirement({ + id: 'block-preview.modes', + resource: 'dogfood.blocks.preview.modes', + label: 'Preview modes', + description: 'Lowering modes rendered for the selected block preview.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'BlockPreviewBlock' }], +}); + +export const blockPreviewData = defineViewData({ + id: 'block-preview.data', + label: 'BlockPreviewBlock data', + description: 'Selected block definition plus lowering modes.', + requirements: [ + { name: 'definition', requirement: blockPreviewDefinitionRequirement }, + { name: 'modes', requirement: blockPreviewModesRequirement }, + ], +}); + +export const blockPreviewSelectBlockIntent = commandIntent<{ readonly blockName: string }>( + 'blockPreview.selectBlock', + { + label: 'Select block', + description: 'Request preview focus for a DOGFOOD or standard block.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'BlockPreviewBlock' }], + }, +); + +export const blockPreviewCycleModeIntent = commandIntent<{ readonly direction: -1 | 1 }>( + 'blockPreview.cycleMode', + { + label: 'Cycle mode', + description: 'Request the next or previous lowering mode preview.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'BlockPreviewBlock' }], + }, +); + +export const blockPreviewBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'BlockPreviewBlock', + family: 'dogfood-blocks', + scale: 'section', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Owns the DOGFOOD live block preview and lowering summary surface.', + useWhen: ['DOGFOOD needs to show a selected Block across supported output modes.'], + avoidWhen: ['A page only needs static catalog metadata without live preview output.'], + relatedDocs: ['docs/design-system/blocks.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'definition', required: true, description: 'Selected block definition and stories.' }, + { id: 'lowering', required: false, description: 'Mode-lowering preview output.' }, + ], + variants: [ + { + id: 'live', + label: 'Live', + requiredSlots: ['definition'], + optionalSlots: ['lowering'], + facts: [{ kind: 'state', key: 'dogfood.blockPreview.mode', value: 'live' }], + }, + ], + composedComponents: ['renderBlockTree()', 'boxSurface()', 'viewportSurface()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'BlockPreviewBlock' }], + storyIds: ['block-preview.live'], + examples: [{ id: 'dogfood.blocks.preview', label: 'DOGFOOD block preview' }], + tags: ['dogfood', 'blocks', 'preview'], + }, + data: blockPreviewData, + commands: [ + blockPreviewSelectBlockIntent, + blockPreviewCycleModeIntent, + ], + render: renderBlockPreviewBlock, +}); + +export const guideInspectorSelectionRequirement = defineDataRequirement({ + id: 'guide-inspector.selection', + resource: 'dogfood.guide.inspector.selection', + label: 'Guide selection', + description: 'Current section or block selection shown in the DOGFOOD guide inspector.', + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'GuideInspectorBlock' }], +}); + +export const guideInspectorFactsRequirement = defineDataRequirement({ + id: 'guide-inspector.facts', + resource: 'dogfood.guide.inspector.facts', + label: 'Guide facts', + description: 'Facts, posture, and source links for the selected DOGFOOD guide row.', + optional: true, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'GuideInspectorBlock' }], +}); + +export const guideInspectorData = defineViewData({ + id: 'guide-inspector.data', + label: 'GuideInspectorBlock data', + description: 'DOGFOOD guide selection details and facts.', + requirements: [ + { name: 'selection', requirement: guideInspectorSelectionRequirement }, + { name: 'facts', requirement: guideInspectorFactsRequirement }, + ], +}); + +export const guideInspectorOpenSourceIntent = commandIntent<{ readonly sourcePath: string }>( + 'guideInspector.openSource', + { + label: 'Open source', + description: 'Request opening the source path for the selected guide row.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'GuideInspectorBlock' }], + }, +); + +export const guideInspectorFocusSectionIntent = commandIntent<{ readonly sectionId: string }>( + 'guideInspector.focusSection', + { + label: 'Focus section', + description: 'Request focus for another section related to the current inspector row.', + facts: [{ kind: 'entity', key: 'dogfood.command', value: 'GuideInspectorBlock' }], + }, +); + +export const guideInspectorBlock: BlockDefinition = defineBlock({ + metadata: { + packageName: DOGFOOD_BLOCK_PACKAGE, + blockName: 'GuideInspectorBlock', + family: 'dogfood-inspector', + scale: 'panel', + modes: DOGFOOD_BLOCK_MODES, + docs: { + summary: 'Owns the DOGFOOD side inspector for selected guide rows and block facts.', + useWhen: ['DOGFOOD needs a semantic side panel explaining the current docs selection.'], + avoidWhen: ['A surface needs to render primary documentation content.'], + relatedDocs: ['docs/DOGFOOD.md'], + }, + sourcePath: 'examples/docs/app.ts', + slots: [ + { id: 'selection', required: true, description: 'Current selected guide row.' }, + { id: 'facts', required: false, description: 'Selection facts, posture, and source hints.' }, + ], + variants: [ + { + id: 'guide-info', + label: 'Guide info', + requiredSlots: ['selection'], + optionalSlots: ['facts'], + facts: [{ kind: 'state', key: 'dogfood.inspector.surface', value: 'guide-info' }], + }, + ], + composedComponents: ['inspector()', 'boxSurface()'], + semanticFacts: [{ kind: 'entity', key: 'dogfood.block', value: 'GuideInspectorBlock' }], + storyIds: ['guide-inspector.guide-info'], + examples: [{ id: 'dogfood.guide.inspector', label: 'DOGFOOD guide inspector' }], + tags: ['dogfood', 'inspector', 'facts'], + }, + data: guideInspectorData, + commands: [ + guideInspectorOpenSourceIntent, + guideInspectorFocusSectionIntent, + ], + render: renderGuideInspectorBlock, +}); + export const settingsSectionsRequirement = defineDataRequirement({ id: 'settings.sections', resource: 'dogfood.settings.sections', @@ -678,6 +859,22 @@ export const documentationArticleBlockRegistryEntry = dogfoodBlockRegistryEntry( tags: ['docs', 'article'], }); +export const blockPreviewBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: blockPreviewBlock, + role: 'preview', + surfaceId: 'blocks.preview', + description: 'DOGFOOD Blocks live preview and lowering surface.', + tags: ['blocks', 'preview'], +}); + +export const guideInspectorBlockRegistryEntry = dogfoodBlockRegistryEntry({ + block: guideInspectorBlock, + role: 'inspector', + surfaceId: 'guide.inspector', + description: 'DOGFOOD side inspector surface.', + tags: ['inspector', 'guide'], +}); + export const settingsMenuBlockRegistryEntry = dogfoodBlockRegistryEntry({ block: settingsMenuBlock, role: 'settings', @@ -686,14 +883,47 @@ export const settingsMenuBlockRegistryEntry = dogfoodBlockRegistryEntry({ tags: ['settings', 'frame'], }); +export const requiredDogfoodBlockSurfaceIds: readonly string[] = Object.freeze([ + 'landing.title', + 'docs.navigation', + 'docs.article', + 'blocks.preview', + 'guide.inspector', + 'frame.settings', + 'storybook.workbench', +]); + export const defaultDogfoodBlockRegistry = dogfoodBlockRegistry([ titleScreenBlockRegistryEntry, navigationListBlockRegistryEntry, documentationArticleBlockRegistryEntry, + blockPreviewBlockRegistryEntry, + guideInspectorBlockRegistryEntry, settingsMenuBlockRegistryEntry, storybookWorkbenchBlockRegistryEntry, ]); +export interface DogfoodBlockCoverageReport { + readonly requiredSurfaceIds: readonly string[]; + readonly registeredSurfaceIds: readonly string[]; + readonly missingSurfaceIds: readonly string[]; +} + +export function dogfoodBlockCoverageReport( + registry: DogfoodBlockRegistry = defaultDogfoodBlockRegistry, +): DogfoodBlockCoverageReport { + const registeredSurfaceIds = registry.surfaceIds(); + const registered = new Set(registeredSurfaceIds); + + return Object.freeze({ + requiredSurfaceIds: requiredDogfoodBlockSurfaceIds, + registeredSurfaceIds, + missingSurfaceIds: Object.freeze( + requiredDogfoodBlockSurfaceIds.filter((surfaceId) => !registered.has(surfaceId)), + ), + }); +} + export function dogfoodBlockRegistryEntry( input: DogfoodBlockRegistryEntryInput, ): DogfoodBlockRegistryEntry { @@ -875,6 +1105,54 @@ function renderSettingsMenuBlock( }; } +function renderBlockPreviewBlock( + input: BlockRenderInput, +): BlockRenderResult { + const blockName = input.config?.blockName ?? 'none'; + const modeCount = input.config?.modeCount ?? 0; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `Block preview: ${blockName}; modes: ${modeCount}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'BlockPreviewBlock' }], + }; + } + + return { + output: [ + 'BlockPreviewBlock', + `block: ${blockName}`, + `modes: ${modeCount}`, + 'Intents: select block; cycle mode', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'BlockPreviewBlock' }], + }; +} + +function renderGuideInspectorBlock( + input: BlockRenderInput, +): BlockRenderResult { + const selectionLabel = input.config?.selectionLabel ?? 'none'; + const factCount = input.config?.factCount ?? 0; + + if (input.mode === 'pipe' || input.mode === 'accessible') { + return { + output: `Guide inspector: ${selectionLabel}; facts: ${factCount}`, + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'GuideInspectorBlock' }], + }; + } + + return { + output: [ + 'GuideInspectorBlock', + `selection: ${selectionLabel}`, + `facts: ${factCount}`, + 'Intents: open source; focus section', + ].join('\n'), + facts: [{ kind: 'entity', key: 'dogfood.block', value: 'GuideInspectorBlock' }], + }; +} + function dogfoodBlockKey(packageName: string, blockName: string): string { return `${packageName}/${blockName}`; } diff --git a/tests/cycles/DF-069/dogfood-block-registry.test.ts b/tests/cycles/DF-069/dogfood-block-registry.test.ts index 53c2f66c..a9225123 100644 --- a/tests/cycles/DF-069/dogfood-block-registry.test.ts +++ b/tests/cycles/DF-069/dogfood-block-registry.test.ts @@ -4,12 +4,17 @@ import { type BlockDefinition, } from '@flyingrobots/bijou'; import { + blockPreviewBlock, + blockPreviewBlockRegistryEntry, DOGFOOD_BLOCK_PACKAGE, defaultDogfoodBlockRegistry, documentationArticleBlock, documentationArticleBlockRegistryEntry, + dogfoodBlockCoverageReport, dogfoodBlockRegistry, dogfoodBlockRegistryEntry, + guideInspectorBlock, + guideInspectorBlockRegistryEntry, isDogfoodBlockRegistry, isDogfoodBlockRegistryEntry, navigationListBlock, @@ -237,6 +242,70 @@ describe('DF-069 DOGFOOD block registry primitives', () => { mode: 'pipe', }).output).toBe('Settings sections: 3; active: Locale'); }); + + it('publishes the DOGFOOD Blocks preview as a block-authored surface', () => { + expect(blockPreviewBlockRegistryEntry.block).toBe(blockPreviewBlock); + expect(blockPreviewBlockRegistryEntry.role).toBe('preview'); + expect(defaultDogfoodBlockRegistry.forSurface('blocks.preview')).toBe(blockPreviewBlockRegistryEntry); + expect(blockPreviewBlock.data?.names()).toEqual(['definition', 'modes']); + expect(blockPreviewBlock.commands?.map((intent) => intent.id)).toEqual([ + 'blockPreview.selectBlock', + 'blockPreview.cycleMode', + ]); + + expect(blockPreviewBlock.render({ + config: { + blockName: 'ReaderSurface', + modeCount: 4, + }, + mode: 'pipe', + }).output).toBe('Block preview: ReaderSurface; modes: 4'); + }); + + it('publishes the DOGFOOD guide inspector as a block-authored surface', () => { + expect(guideInspectorBlockRegistryEntry.block).toBe(guideInspectorBlock); + expect(guideInspectorBlockRegistryEntry.role).toBe('inspector'); + expect(defaultDogfoodBlockRegistry.forSurface('guide.inspector')).toBe( + guideInspectorBlockRegistryEntry, + ); + expect(guideInspectorBlock.data?.names()).toEqual(['selection', 'facts']); + expect(guideInspectorBlock.commands?.map((intent) => intent.id)).toEqual([ + 'guideInspector.openSource', + 'guideInspector.focusSection', + ]); + + expect(guideInspectorBlock.render({ + config: { + selectionLabel: 'Block Preview', + factCount: 6, + }, + mode: 'accessible', + }).output).toBe('Guide inspector: Block Preview; facts: 6'); + }); + + it('covers the intended semantic DOGFOOD surfaces without discovery-time rendering', () => { + const report = dogfoodBlockCoverageReport(); + + expect(report.missingSurfaceIds).toEqual([]); + expect(report.registeredSurfaceIds).toEqual([ + 'landing.title', + 'docs.navigation', + 'docs.article', + 'blocks.preview', + 'guide.inspector', + 'frame.settings', + 'storybook.workbench', + ]); + expect(defaultDogfoodBlockRegistry.blockNames()).toEqual([ + 'TitleScreenBlock', + 'NavigationListBlock', + 'DocumentationArticleBlock', + 'BlockPreviewBlock', + 'GuideInspectorBlock', + 'SettingsMenuBlock', + 'StorybookWorkbenchBlock', + ]); + }); }); function testBlock( From 3fac13b3d1243e1c17102449afe8568965dcdc98 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 16:52:53 -0700 Subject: [PATCH 10/10] test(dogfood): ignore protocol tokens in i18n debt --- docs/CHANGELOG.md | 7 ++- docs/design/DF-069-block-authored-dogfood.md | 6 +++ examples/docs/i18n-debt.ts | 49 ++++++++++++++++++++ scripts/dogfood-i18n-debt.test.ts | 11 ++++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a7f90160..12591080 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,8 +60,11 @@ All packages (`@flyingrobots/bijou`, `@flyingrobots/bijou-node`, `@flyingrobots/ across `docs-app`, `dogfood-locale`, `component-stories`, and Storybook-style DOGFOOD entrypoints. The scanner filters ids, file paths, import paths, and catalog-backed fallback calls so review sees the remaining localization debt - without scraping terminal render output. `release:readiness` now runs the - i18n debt ratchet alongside the existing DOGFOOD coverage gate. + without scraping terminal render output. It also filters machine-only key + names, event discriminants, mode literals, pane policy tokens, tone tokens, + and internal thrown-error strings so framed Storybook protocol data does not + inflate visible-copy debt. `release:readiness` now runs the i18n debt ratchet + alongside the existing DOGFOOD coverage gate. - **DOGFOOD locale preference and i18n ratchet** — DOGFOOD now resolves its initial language through an explicit locale port with a Node adapter that reads operating-system locale signals, exposes a Settings drawer language diff --git a/docs/design/DF-069-block-authored-dogfood.md b/docs/design/DF-069-block-authored-dogfood.md index 7898605f..719c0a93 100644 --- a/docs/design/DF-069-block-authored-dogfood.md +++ b/docs/design/DF-069-block-authored-dogfood.md @@ -141,3 +141,9 @@ DF-069A lands the block-authored DOGFOOD contract layer: This slice deliberately stops at semantic surface contracts and the Storybook frame migration. It does not rewrite every DOGFOOD renderer yet, does not move provider lifecycle policy, and does not convert leaf components into Blocks. + +The final review gate also tightened the DOGFOOD i18n debt scanner so framed +Storybook machine tokens such as key names, event discriminants, mode literals, +pane overflow policy, tones, and internal thrown errors do not count as visible +English copy. Storybook labels and rendered text still count as localization +debt until they move behind a localization port. diff --git a/examples/docs/i18n-debt.ts b/examples/docs/i18n-debt.ts index 876771f2..c4fa3714 100644 --- a/examples/docs/i18n-debt.ts +++ b/examples/docs/i18n-debt.ts @@ -74,14 +74,18 @@ const NONLOCALIZABLE_PROPERTY_NAMES = new Set([ 'id', 'ids', 'importPath', + 'key', 'kind', 'mode', 'namespace', + 'overflowX', 'packageName', 'path', 'sourceLocale', 'supportsModes', 'tags', + 'tone', + 'type', 'version', ]); @@ -210,6 +214,10 @@ function isNonlocalizableContext(node: ts.Node, sourceFile: ts.SourceFile): bool return true; } if (hasAncestor(node, (ancestor) => isNodeEnvComparison(ancestor, sourceFile))) return true; + if (isCaseClauseExpression(node)) return true; + if (isDiscriminantComparison(node)) return true; + if (isErrorConstructorArgument(node)) return true; + if (hasAncestor(node, (ancestor) => isOutputModeDeclaration(ancestor, sourceFile))) return true; const propertyName = nearestPropertyName(node); if (propertyName != null && NONLOCALIZABLE_PROPERTY_NAMES.has(propertyName)) return true; @@ -223,6 +231,7 @@ function isNonlocalizableContext(node: ts.Node, sourceFile: ts.SourceFile): bool if (callName != null && LOCALIZED_FALLBACK_FUNCTIONS.has(callName) && (argumentIndex === 1 || argumentIndex === 2)) { return true; } + if (callName === 'bind' && argumentIndex === 0) return true; if (callName != null && PATH_FUNCTIONS.has(callName)) return true; if (call.expression.kind === ts.SyntaxKind.ImportKeyword) return true; } @@ -267,6 +276,46 @@ function isNodeEnvComparison(node: ts.Node, sourceFile: ts.SourceFile): boolean || node.right.getText(sourceFile) === 'process.env.NODE_ENV'; } +function isCaseClauseExpression(node: ts.Node): boolean { + return ts.isCaseClause(node.parent) && node.parent.expression === node; +} + +function isDiscriminantComparison(node: ts.Node): boolean { + if (!ts.isBinaryExpression(node.parent)) return false; + const binary = node.parent; + if ( + binary.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken + && binary.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + return false; + } + + const otherSide = binary.left === node ? binary.right : binary.right === node ? binary.left : undefined; + return otherSide != null && ts.isPropertyAccessExpression(otherSide) && isDiscriminantProperty(otherSide.name.text); +} + +function isDiscriminantProperty(name: string): boolean { + return name === 'action' + || name === 'kind' + || name === 'mode' + || name === 'status' + || name === 'type'; +} + +function isErrorConstructorArgument(node: ts.Node): boolean { + for (let current: ts.Node | undefined = node.parent; current != null; current = current.parent) { + if (!ts.isNewExpression(current)) continue; + if (current.expression.getText() !== 'Error') continue; + return current.arguments?.some((argument) => argument === node || containsNode(argument, node)) ?? false; + } + return false; +} + +function isOutputModeDeclaration(node: ts.Node, sourceFile: ts.SourceFile): boolean { + if (!ts.isVariableDeclaration(node) || node.type == null) return false; + return node.type.getText(sourceFile).includes('OutputMode'); +} + function containsNode(parent: ts.Node, target: ts.Node): boolean { let found = false; parent.forEachChild((child) => { diff --git a/scripts/dogfood-i18n-debt.test.ts b/scripts/dogfood-i18n-debt.test.ts index d871db7f..ac9b62b0 100644 --- a/scripts/dogfood-i18n-debt.test.ts +++ b/scripts/dogfood-i18n-debt.test.ts @@ -18,6 +18,13 @@ describe('DOGFOOD i18n debt inventory', () => { "const token = 'docs.page.guides';", "const loadingLabel = 'loading';", "const continueLabel = 'continue';", + "const requiredModes: readonly OutputMode[] = ['interactive', 'static'];", + "const msg = { type: 'fixture-event', key: 'j' };", + "const pane = { overflowX: 'scroll', tone: 'muted' };", + "if (msg.type === 'pulse') return msg;", + "switch (msg.key) { case 'j': return msg; }", + "throw new Error('Internal fixture failure');", + "createKeyMap().group('Visible key group', (group) => group.bind('j', 'Visible key label', msg));", "const page = { id: 'docs.page.guides', title: 'Raw English Title' };", "const markdown = readMarkdownDoc('./content/guide.md');", "const summary = `Visible template text ${cataloged}`;", @@ -28,10 +35,12 @@ describe('DOGFOOD i18n debt inventory', () => { expect(inventory.entries.map((entry) => entry.value)).toEqual([ 'loading', 'continue', + 'Visible key group', + 'Visible key label', 'Raw English Title', 'Visible template text', ]); - expect(inventory.bySurface).toEqual([{ surface: 'fixture', count: 4 }]); + expect(inventory.bySurface).toEqual([{ surface: 'fixture', count: 6 }]); }); it('ignores machine environment mode literals without dropping visible copy', () => {