// Column-layout hints for questionnaire items. // // FHIR has NO standard for general form layout. The questionnaire-itemControl codes // table/htable/gtable/atable are MATRIX-question semantics ("questions are rows, answers are // columns"), and SDC's sdc-questionnaire-width is scoped by its own definition to // "when used with an item control that causes the question to appear as a column in a table". // So this is a deliberate, documented Akashic extension rather than a standard one bent // off-label -- bending SDC's width would tell an SDC-aware renderer there is a table when there // is not. See docs/_CLAUDE/2026-07-16-questionnaire-grid-columns-design.md. // // Portability comes from graceful degradation, not from the URL: an absent span means span 12 // (full width), which is exactly the pre-feature rendering, so any renderer that ignores this // extension still produces a correct -- just plainer -- stacked form. // // // - THREE OF THESE EXPORTS NOW HAVE A TWIN IN ANOTHER REPOSITORY. // // `COLUMN_GRID`, `normalizeColumnSpan` and `packIntoRows` are duplicated in // **akashic-infrastructure**: `aws/web/public-form/src/columnLayout.ts`. The public form moved to its // own document on our origin (an iframe, so the practice's scripts cannot read the fields a patient // types), and it has to lay out the same form the same way. // // THE ONE THAT MATTERS IS `packIntoRows`. What this file draws is `QuestionnairePreview` - the // practice's promise about how their form will look. What the twin draws is what the patient // actually gets. **If the two diverge, the product lies to the practice**, which is worse than // either being wrong alone. // // NO TEST CAN CATCH THAT DRIFT, and stating it plainly beats implying a guard that does not // exist: the two live in different repositories and neither CI can read the other's files. The only // thing between them is this comment and the one facing it. **Change one, change both.** // // The obvious removal was tried and is DISPROVEN. Packing rows once at authoring time - so the // frame receives finished layout and needs no rules - cannot work: the renderer packs rows AFTER // filtering items on `enableWhen`, which reads the patient's answers as they type. Layout is a // function of runtime state, so there is nothing to precompute. // // The author-only exports below (`COLUMN_SPANS`, `COLUMN_SPAN_LABELS`, `nearestColumnSpan`, // `COLUMN_SPAN_EXTENSION_URL`) are NOT duplicated - the frame never sees a FHIR extension, because // `buildPublicFormSnapshot` decodes the span into a plain number before a form is ever registered. // export const COLUMN_SPAN_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/questionnaire-item-column-span'; /** The layout grid. 12 is the smallest base divisible by 2, 3, 4 and 6 -- i.e. by every span below. */ export const COLUMN_GRID = 12; /** Author-selectable spans, widest first. Integers, so thirds are exact (4+4+4=12; 33.33*3=99.99). */ export const COLUMN_SPANS = [12, 9, 8, 6, 4, 3] as const; export type ColumnSpan = (typeof COLUMN_SPANS)[number]; export const COLUMN_SPAN_LABELS: Record = { 12: 'Full width', 9: '3/4', 8: '2/3', 6: '1/2', 4: '1/3', 3: '1/4' }; const isColumnSpan = (value: number): value is ColumnSpan => (COLUMN_SPANS as readonly number[]).includes(value); /** * Absent / malformed / unrecognised values collapse to a full-width row, which is the pre-feature * rendering. Never throws: a bad span must degrade, never break the form. */ export const normalizeColumnSpan = (value: unknown): ColumnSpan => { const span = typeof value === 'number' ? value : Number(value); return Number.isFinite(span) && isColumnSpan(span) ? span : COLUMN_GRID; }; /** * Snap a fraction of the row (0..1) to the nearest author-selectable span. Out-of-range fractions * clamp naturally, because the nearest member of a fixed set is always a valid span. */ export const nearestColumnSpan = (fraction: number): ColumnSpan => { const raw = fraction * COLUMN_GRID; return COLUMN_SPANS.reduce( (best, span) => (Math.abs(span - raw) < Math.abs(best - raw) ? span : best), COLUMN_GRID ); }; /** * Group a sibling list into rows: accumulate spans in document order, starting a new row when the * next item would push past COLUMN_GRID. An item of span 12 therefore always occupies its own row. * * Pure: no React, no FHIR, no services barrel -- which is also why this file can be scope-tsc'd. */ export const packIntoRows = (items: T[], getSpan: (item: T) => unknown): T[][] => { const rows: T[][] = []; let currentRow: T[] = []; let used = 0; for (const item of items) { const span = normalizeColumnSpan(getSpan(item)); if (currentRow.length !== 0 && used + span > COLUMN_GRID) { rows.push(currentRow); currentRow = []; used = 0; } currentRow.push(item); used += span; } if (currentRow.length !== 0) { rows.push(currentRow); } return rows; };