/** * lib/ui-design-overlay.ts — Canonical shape of the pagespec `uiDesign` overlay. * * SINGLE SOURCE OF TRUTH for the contract between the overlay WRITER * (`ui-design/cli/apply-form-directives` — persists the /ui-design judgment * into the pagespec machine block) and its READER * (`development/frontend/component/cli/scaffold-component` — applies the * judgment when rendering). The two CLIs used to carry hand-duplicated copies * of this interface with a "keep in sync" comment; both now import THIS module * (`lib/__tests__/ui-design-overlay-drift.test.ts` fails if a local * re-declaration reappears). * * The overlay is an OVERLAY: namespaced under the machine block's `uiDesign` * key, keyed by field `key`, additive and idempotent. It never collides with * the BA-authored spec and degrades gracefully when ignored. * * @see ui-design/SKILL.md (the judgment rubric) * @see ui-design/cli/apply-form-directives (writer) * @see development/frontend/component/cli/scaffold-component (reader) * @see lib/page-spec-sections.ts (first-order `sections[]` resolver) */ import { z } from 'zod' /** * Edit experience of a generated FormPage in EDIT mode: * - `read-first` (default) — every section renders as a read-only label/value * card; a per-section "Modifier"/"Terminer" toggle opens it for editing; * Save stays disabled until the form is dirty. * - `direct` — the legacy behaviour: all fields editable immediately. * CREATE mode is always direct (there is nothing to read yet). */ export const UI_DESIGN_EDIT_MODES = ['read-first', 'direct'] as const export type UiDesignEditMode = (typeof UI_DESIGN_EDIT_MODES)[number] /** Per-field judgment directives — mirrors the vocabulary scaffold-component's * `ComponentFieldSchema` honours (`currentUserFk`, `section`, `control`, * `dateBounds`, `fullWidth`). */ export const UiDesignFieldDirectiveSchema = z.object({ currentUserFk: z.boolean().optional(), section: z.string().optional(), control: z.string().optional(), dateBounds: z.enum(['past', 'future', 'any']).optional(), fullWidth: z.boolean().optional(), }).passthrough() /** * Ordered section METADATA (labels, read-grid columns, description). Section * MEMBERSHIP stays per-field (`fields..section`) — one canonical source, * never two. `apply-form-directives` accepts a `sections[].fields` sugar on * input and normalises it into both halves. */ export const UiDesignSectionMetaSchema = z.object({ /** Section grouping key — matches the `section` value carried by the fields. */ key: z.string().min(1), /** Explicit i18n key override; defaults downstream to `form.section.`. */ labelKey: z.string().optional(), /** Authored fallback label (used as the t() defaultValue). */ label: z.string().optional(), /** READ-grid column count of the section (1..3, default 3). The EDIT grid * keeps following the page-global `formLayout` — per-section edit columns * are deliberately NOT a v1 lever (they would fork the odd-run balancing). */ columns: z.number().int().min(1).max(3).optional(), /** One-line description rendered under the section title. */ description: z.string().optional(), }).passthrough() /** * LIST-page judgment directives (plan UI 2.5) — the list mirror of the form * levers. Everything here REFINES what the pagespec already declares (it never * adds capabilities): `viewMode` picks the default among the declared * `viewModes`; `density`/`emptyState` override the pagespec fields; * `statsOrder` reorders the declared `stats[]` keys; `cardFields` overrides * the derived card anatomy by COLUMN key (camelCase). */ export const UiDesignListDirectiveSchema = z.object({ /** Default representation override — must be one of the pagespec's declared `viewModes`. */ viewMode: z.enum(['table', 'cards']).optional(), density: z.enum(['comfortable', 'compact']).optional(), /** Display order of the KPI stat keys (unknown keys ignored, missing appended). */ statsOrder: z.array(z.string()).optional(), /** Business empty-state override (same shape as the pagespec field). */ emptyState: z.object({ titleKey: z.string().optional(), descriptionKey: z.string().optional(), icon: z.string().optional(), withCreate: z.boolean().optional(), }).passthrough().optional(), /** Card anatomy override, by column key: title / subtitle / badge / meta. */ cardFields: z.object({ titleKey: z.string().optional(), subtitleKey: z.string().optional(), badgeKey: z.string().optional(), metaKeys: z.array(z.string()).optional(), }).passthrough().optional(), }).passthrough() /** * DETAIL-page judgment directives (plan UI 3.2): the summary band — title * value, status badge, meta pairs — refining or authoring the pagespec's * `summary` block (field keys, camelCase). */ export const UiDesignDetailDirectiveSchema = z.object({ summary: z.object({ titleField: z.string().optional(), statusField: z.string().optional(), fields: z.array(z.string()).optional(), }).passthrough().optional(), }).passthrough() export const UiDesignOverlaySchema = z.object({ formLayout: z.enum(['two-column', 'single-column']).optional(), editMode: z.enum(UI_DESIGN_EDIT_MODES).optional(), /** Per-field directives, keyed by pagespec field `key` (camelCase). */ fields: z.record(z.string(), UiDesignFieldDirectiveSchema).optional(), /** Explicit display order of field keys (most important first). */ order: z.array(z.string()).optional(), /** Ordered section metadata — order/labels only, membership is per-field. */ sections: z.array(UiDesignSectionMetaSchema).optional(), /** LIST-page judgment (viewMode / density / statsOrder / emptyState / cardFields). */ list: UiDesignListDirectiveSchema.optional(), /** DETAIL-page judgment (summary band). */ detail: UiDesignDetailDirectiveSchema.optional(), }).passthrough() export type UiDesignFieldDirective = z.infer export type UiDesignSectionMeta = z.infer export type UiDesignListDirective = z.infer export type UiDesignDetailDirective = z.infer export type UiDesignOverlay = z.infer /** * Extract the `uiDesign` overlay from a pagespec machine block. PERMISSIVE by * design (the reader must degrade gracefully on a hand-edited overlay): shape * is trusted when it is a plain object — validation happens at write time in * `apply-form-directives`, not here. */ export function pickUiDesignOverlay(pageSpec: unknown): UiDesignOverlay | undefined { if (pageSpec === null || typeof pageSpec !== 'object' || Array.isArray(pageSpec)) return undefined const raw = (pageSpec as Record).uiDesign if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined return raw as UiDesignOverlay } /** * Resolve the edit experience of a form view. Precedence: overlay `editMode` * (the /ui-design judgment) > pagespec `editExperience` (BA-authored opt-out) * > `read-first` (the default since the read-first uplift). Any unknown value * falls back to the default — never throw on a hand-edited pagespec. */ export function resolveEditMode(pageSpec: unknown, overlay: UiDesignOverlay | undefined): UiDesignEditMode { const fromOverlay = overlay?.editMode if (fromOverlay === 'direct' || fromOverlay === 'read-first') return fromOverlay const fromSpec = pageSpec !== null && typeof pageSpec === 'object' && !Array.isArray(pageSpec) ? (pageSpec as Record).editExperience : undefined if (fromSpec === 'direct' || fromSpec === 'read-first') return fromSpec return 'read-first' }