/** * lib/detail-tab-strip.ts — SSOT of "how many triggers does the rendered * detail tab bar carry" and of the tab-budget thresholds. * * The rendered strip of a detail page is NOT the authored JSON: a unified * fiche (detail + form views, read-first — lib/edit-surface.ts) renders its * `tabs[]` as section cards ABOVE the bar, band-placed related tabs * (placement 'band', the derived default for `summary` — * lib/page-spec-related-tabs.relatedTabPlacementOf) render as cartouches * above the strip, and a legacy fiche whose only strip content is related * tabs gains ONE synthetic « info » trigger for its own fields. This module * mirrors scaffold-component's resolution (render/detail.ts) so the budget * rules count exactly what the reader sees — never re-encode any of it. * * Consumers: * - create-screen/cli/derive-related-tabs check.ts — RTV-108 (the tab * budget, PRD-133's engine) * - create-prd/cli/derive-detail-summary — PRD-134's engine (a * crowded strip demands the summary band) * - development/audit-dev-frontend — DEV-UI-047 (unified * catch-up) reads entityViewsFromPagespecFilenames */ import { parseRelatedTabs, relatedTabPlacementOf } from './page-spec-related-tabs.js' import { pickUiDesignOverlay, resolveEditMode } from './ui-design-overlay.js' import { resolveEditSurface, type EditSurface } from './edit-surface.js' /** Above this rendered-trigger count the bar overflows the reader — warn. */ export const DETAIL_TAB_BUDGET_WARN = 7 /** Above this the fiche is a binder, not a fiche — err. */ export const DETAIL_TAB_BUDGET_ERR = 9 /** From this strip size on, a fiche without a summary band loses the record's * identity/state as soon as the reader leaves the first tab (PRD-134). */ export const DETAIL_SUMMARY_DEMAND_THRESHOLD = 4 /** `..md` — the pagespec filename convention (same family as * audit-dev-frontend's DEV_UI_031_PAGESPEC_RE). */ const PAGESPEC_FILENAME_RE = /^([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/ /** * Group pagespec FILENAMES into the per-entity view set `resolveEditSurface` * consumes. Basenames only; non-matching names are skipped. */ export function entityViewsFromPagespecFilenames( filenames: Iterable, ): Map> { const out = new Map>() for (const name of filenames) { const m = PAGESPEC_FILENAME_RE.exec(name) if (!m) continue let views = out.get(m[1]) if (views === undefined) { views = new Set() out.set(m[1], views) } views.add(m[2]) } return out } export interface DetailStripResolution { /** The entity's edit surface — 'unified' zeroes the field-tab term. */ surface: EditSurface /** Field tabs that RENDER as strip triggers (0 on a unified fiche). */ fieldTabCount: number /** True when the legacy fiche synthesises the « info » trigger (no field * tabs, ≥1 strip-placed related tab) — it IS a rendered trigger. */ syntheticInfoTab: boolean /** Related tabs whose resolved placement is 'band' (no trigger). */ bandCount: number /** Related tabs whose resolved placement is 'tab' (one trigger each). */ stripRelatedCount: number /** Zod-rejected relatedTabs entries — RTV-104's findings, never budget mass. */ rejectedCount: number /** Total triggers on the rendered bar: fieldTabCount + synthetic + stripRelated. */ stripTotal: number } /** * Resolve the RENDERED tab bar of one `view: detail` pagespec block, mirroring * scaffold-component render/detail.ts exactly: * - surface = resolveEditSurface(entityViews, resolveEditMode(block, overlay)) * - unified → tabs[] renders as section cards, field-tab term = 0 * - related tabs partition through relatedTabPlacementOf (band ⇒ no trigger) * - legacy + no field tabs + ≥1 strip related ⇒ the synthetic « info » trigger * `entityViews` may be undefined (no filename inventory) — the surface then * resolves 'legacy', the renderer's own behaviour for an unknown view set. */ export function resolveDetailStrip( block: Record, entityViews: Iterable | undefined, ): DetailStripResolution { const surface = resolveEditSurface(entityViews, resolveEditMode(block, pickUiDesignOverlay(block))) const rawTabs = Array.isArray(block.tabs) ? block.tabs : [] const authoredFieldTabs = rawTabs.length const { tabs, rejected } = parseRelatedTabs(block.relatedTabs) let bandCount = 0 let stripRelatedCount = 0 for (const tab of tabs) { if (relatedTabPlacementOf(tab) === 'band') bandCount++ else stripRelatedCount++ } const fieldTabCount = surface === 'unified' ? 0 : authoredFieldTabs const syntheticInfoTab = surface !== 'unified' && authoredFieldTabs === 0 && stripRelatedCount > 0 return { surface, fieldTabCount, syntheticInfoTab, bandCount, stripRelatedCount, rejectedCount: rejected.length, stripTotal: fieldTabCount + (syntheticInfoTab ? 1 : 0) + stripRelatedCount, } }