quario 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **`STYLE_NAMES`**, every name in the style vocabulary as a read-only list,
15
+ in the order the specification's table lists them. A tool that offers the
16
+ vocabulary — the editor's style rail — reads it from here instead of
17
+ carrying a copy that has to learn each new name.
18
+ - **`span` lets a cell cover several table columns**, so a label reaches across
19
+ them instead of being pushed sideways by empty cells. A literal positive
20
+ integer; absent is 1. It is legal on the two cells a document writes: a
21
+ column's `header`, where the columns it covers omit their own `header`, and a
22
+ total row's cells, whose spans must sum to the column count exactly. A data
23
+ row's cells are the columns' own, so there is no cell there to carry one — a
24
+ line that runs across the table is a `split` item below it. A span covers and
25
+ does not vote: a cell over more than one column has no say in their widths,
26
+ and a column nothing votes on takes the cell-padding floor.
27
+ - **`valign`** joins the style vocabulary: `"top"`, `"middle"` or `"bottom"`,
28
+ a literal or an `=` expression like `align`. It is legal exactly where a box
29
+ is taller than its content asked for — table cells, headers, totals, `row`,
30
+ and split slots, text or image — and a definition error on a stacked item and
31
+ on a band image, whose boxes have no such slack. A row's or a split's layers
32
+ under its cells' or slots' own. Undeclared is not a declaration: each target
33
+ keeps its own default.
34
+
35
+ ### Changed
36
+
37
+ - **A table row's `style` now resolves onto that row's cells**, the box
38
+ included, instead of meaning something different on every output. Before,
39
+ padding and border on `detail.header`, `detail.row` or a total row's `style`
40
+ reached no cell at all: a fragment dropped them, a worksheet turned them into
41
+ an edge on each cell, and a page drew one box around the row. Now they layer
42
+ under each cell's own, the way `bold` and `valign` on a row already did, so
43
+ one declaration means one thing everywhere.
44
+ What this makes possible is declining the cell padding a row at a time:
45
+ `"paddingTop": 0, "paddingBottom": 0` on `detail.row` sets the pitch for
46
+ every data row, where before it had to be written on each cell.
47
+ Two things move for documents that already declared a box on a row.
48
+ A row's `borderLeft` is now an edge on each of the row's cells rather than
49
+ one at the row's outer left — write it on the first column's cells to get the
50
+ single edge back. And a row's border now occupies height, as a cell's border
51
+ always has, so a bordered row is taller by its border's width.
52
+ A border side is won whole by the cell: a cell naming any of that side's
53
+ three keys takes the side entirely, so a cell that failed soft on one name
54
+ does not inherit the row's other two.
55
+ On the event stream, a `row`, `total-row` or `table-start` `style` now
56
+ carries only what layers by ordinary means, and a row whose whole block was
57
+ box carries no `style` at all.
58
+
59
+ ## [0.4.0] - 2026-09-03
60
+
61
+ ### Changed
62
+
63
+ - **`format: "date"` now reads a date string, not only a `Date`.** A JSON
64
+ document has no date type, so the kind could not be reached from parsed
65
+ data without reviving every date field by hand first. It now revives two
66
+ forms itself: a calendar date `2026-08-14`, and a timestamp naming its
67
+ offset (`2026-08-14T12:30:00Z` or `+02:00`). **This changes existing
68
+ output**: a cell declaring the kind over `"2026-08-14"` rendered
69
+ `2026-08-14` and now renders `14/8/2026` under an `en-IE` instance. A
70
+ zoneless `2026-08-14T00:00:00` is not read — it means local time, so it
71
+ would present a different day per machine — and neither is a loose
72
+ `14/8/2026`, a partial `2026-08`, a lowercase `t`/`z`, or a day that does
73
+ not exist. Anything unread renders as authored, as before.
74
+
75
+ The revival is the `Date` a host would have injected, timezone included: a
76
+ calendar date is UTC midnight, so under a western instance timezone it
77
+ presents as the previous day, exactly as `new Date("2026-08-14")` does.
78
+ Only `date` revives — `number`, `currency` and `percent` never read a
79
+ string, because JSON already carries numbers — and epoch milliseconds stay
80
+ a number under every kind.
81
+
82
+ - **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
83
+ opts into the same revival, for consumers that write typed cells. The
84
+ one-argument call is unchanged.
85
+
86
+ ### Fixed
87
+
88
+ - **The TypeScript declarations accept the box.** The per-side `padding*` and
89
+ `border*` names, and the table's `detail.header`, were validated by the
90
+ engine from 0.3.0 but were missing from the shipped declarations, so a
91
+ report declaring the padding or the border sides that release introduced
92
+ was rejected by the compiler as an unknown property and needed a cast to
93
+ get past it. They now type exactly as the engine reads them, on a band
94
+ image's `style` as well. Four names come with them, for annotating your own
95
+ helpers: `LineStyle` (`"solid" | "dashed" | "dotted"`), beside `Align` and
96
+ `FormatKind`; `TableHeaderBox`, the type of `detail.header`; and
97
+ `BoxDeclarations` with `Side`, the per-side names as one type, which
98
+ `StyleDeclarations` was built from without being nameable. Nothing
99
+ about rendering changes: a report that compiled through a cast produces the
100
+ same output without one.
101
+
10
102
  ## [0.3.0] - 2026-09-02
11
103
 
12
104
  ### Added
@@ -16,20 +108,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
108
  `row.style`, not the report default. Locale, currency code, and timezone
17
109
  live on `quario({ locale, currency, timeZone })`. The public `format()`
18
110
  helper is how targets present a kind; a kind on the wrong type contributes
19
- nothing (`docs/adr/0041`).
111
+ nothing.
20
112
 
21
113
  - **The report header may pin a `height` from the page top.** Dual-shaped
22
114
  like `detail`: an item array, or `{ height, items }`. `page.margin` is a
23
115
  document field (one number, all four sides), required with `height` and
24
116
  legal without. Authored `spaceBefore` on the first occupying item of the
25
- next band is refused (`docs/adr/0038`).
117
+ next band is refused.
26
118
 
27
119
  - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
28
120
  before or after a band item, in points, including band images and splits as
29
121
  band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
30
122
  and split slots refuse the names. `spaceBefore` drops at a fresh body page
31
- or strip top; page-band items keep it. Leading and inset stay cut
32
- (`docs/adr/0037`).
123
+ or strip top; page-band items keep it. Leading and inset stay cut.
33
124
 
34
125
  - **Per-side padding and border on the closed style vocabulary.**
35
126
  `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
@@ -50,7 +141,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
141
  array or `{ style, cells }`. After, it is absent or a non-empty array of
51
142
  `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
52
143
  `total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
53
- The stream yields one `total-row` per emitted row (`docs/adr/0042`).
144
+ The stream yields one `total-row` per emitted row.
54
145
 
55
146
  - **A visible text item occupies a line at its own `size`.** Empty display
56
147
  used to take the report default's leading in PDF and collapse in HTML;
package/lib/format.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
9
9
  * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
10
10
  */
11
- import { finiteDate, finiteNum } from "./stream.js";
11
+ import { finiteDate, finiteNum, reviveDate } from "./stream.js";
12
12
  /** @type {(options: any) => string} */
13
13
  let localeOf = (options) => options?.locale || "en-US";
14
14
  /** @type {(options: any) => string} */
@@ -31,11 +31,18 @@ let asMoney = (value, locale, options) => {
31
31
  if (!finiteNum(value) || !currency) return;
32
32
  return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
33
33
  };
34
+ // A string in an accepted RFC 3339 form revives to the Date a host would have
35
+ // injected, then presents like any other Date — including in the instance's
36
+ // timezone, so a calendar date under a western zone presents as the day
37
+ // before, exactly as an injected `new Date("2026-08-14")` does today. The two
38
+ // spellings never disagree, which is the point (ADR 0041).
34
39
  /** @type {(value: any, locale: string, options: any) => string | undefined} */
35
- let asDate = (value, locale, options) =>
36
- finiteDate(value)
37
- ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(value)
40
+ let asDate = (value, locale, options) => {
41
+ let date = finiteDate(value) ? value : reviveDate(value);
42
+ return date
43
+ ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(date)
38
44
  : undefined;
45
+ };
39
46
 
40
47
  /** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
41
48
  let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
package/lib/index.d.ts CHANGED
@@ -28,6 +28,40 @@ export type JsonValue =
28
28
  | JsonValue[]
29
29
  | { [key: string]: JsonValue };
30
30
 
31
+ /**
32
+ * The four sides, as the key fragments the names are built from. Not a value an
33
+ * author ever writes: only the pieces `padding<Side>` and `border<Side><Part>`
34
+ * are spelled out of it, and it is exported so the box below can be named.
35
+ */
36
+ export type Side = "Top" | "Right" | "Bottom" | "Left";
37
+
38
+ /** The line a border side is drawn with. */
39
+ export type LineStyle = "solid" | "dashed" | "dotted";
40
+
41
+ /**
42
+ * The box: padding and border per side. Written as a mapping over the four
43
+ * sides, the way the runtime builds the names, so a side can never be spelled
44
+ * out in one place and forgotten in another.
45
+ *
46
+ * A border side is width, style, and colour together, or none — an incomplete
47
+ * literal side is a definition error the traversal reports, which no type can
48
+ * state. Padding is not flow spacing: `spaceBefore` / `spaceAfter` are blank
49
+ * space *between* items, and live on the vocabulary below.
50
+ */
51
+ export type BoxDeclarations = {
52
+ /** Inset on that side, in points (>= 0). */
53
+ [S in Side as `padding${S}`]?: ExpressionValue<number>;
54
+ } & {
55
+ /** Stroke width on that side, in points (>= 0). `0` is a complete side that draws nothing. */
56
+ [S in Side as `border${S}Width`]?: ExpressionValue<number>;
57
+ } & {
58
+ /** Line style of that side. */
59
+ [S in Side as `border${S}Style`]?: ExpressionValue<LineStyle>;
60
+ } & {
61
+ /** Stroke color of that side, `#rgb`/`#rrggbb`. */
62
+ [S in Side as `border${S}Color`]?: ExpressionValue<string>;
63
+ };
64
+
31
65
  /**
32
66
  * The closed, target-neutral style vocabulary. Literal values are validated
33
67
  * strictly (unknown names and mistyped literals are definition errors);
@@ -36,7 +70,7 @@ export type JsonValue =
36
70
  * its own formatting model (the HTML target to inline CSS, the PDF target to
37
71
  * faces, points, and rects).
38
72
  */
39
- export interface StyleDeclarations {
73
+ export interface StyleDeclarations extends BoxDeclarations {
40
74
  /** `'sans'` | `'serif'` | `'mono'`, or an embedded font family name. */
41
75
  family?: ExpressionValue<string>;
42
76
  /** Font size in points. */
@@ -53,6 +87,12 @@ export interface StyleDeclarations {
53
87
  background?: ExpressionValue<string>;
54
88
  /** Horizontal alignment within the cell. */
55
89
  align?: ExpressionValue<Align>;
90
+ /**
91
+ * Vertical alignment within a box taller than its content: a table cell or
92
+ * row, a split slot (text or image). A definition error on a stacked item
93
+ * and on a band image, whose boxes have no such slack.
94
+ */
95
+ valign?: ExpressionValue<VAlign>;
56
96
  /** How a number or date is presented. Locale stays on the instance. */
57
97
  format?: ExpressionValue<FormatKind>;
58
98
  /** Blank space before this item, in points. */
@@ -70,6 +110,7 @@ export interface SortKey {
70
110
  export type CellValue = string;
71
111
 
72
112
  export type Align = "left" | "center" | "right";
113
+ export type VAlign = "top" | "middle" | "bottom";
73
114
  export type FormatKind = "number" | "currency" | "percent" | "date";
74
115
 
75
116
  export interface Cell {
@@ -87,14 +128,15 @@ export interface TextItem extends Cell {
87
128
  export type ImageFit = "natural" | "width";
88
129
 
89
130
  /**
90
- * The declarations an image item accepts. The rest of the vocabulary describes
91
- * text, which an image does not have, so any other name on one is a definition
92
- * error. A narrowing of the one vocabulary rather than a second list of its
93
- * own, so the two cannot drift.
131
+ * The declarations an image item accepts: the box, plus the four names that
132
+ * are not about text. The rest of the vocabulary describes text, which an
133
+ * image does not have, so any other name on one is a definition error. A
134
+ * narrowing of the one vocabulary rather than a second list of its own, so the
135
+ * two cannot drift.
94
136
  */
95
137
  export type ImageStyleDeclarations = Pick<
96
138
  StyleDeclarations,
97
- "background" | "align" | "spaceBefore" | "spaceAfter"
139
+ "background" | "align" | "spaceBefore" | "spaceAfter" | keyof BoxDeclarations
98
140
  >;
99
141
 
100
142
  /**
@@ -150,10 +192,19 @@ export interface TableHeader {
150
192
  value: CellValue;
151
193
  /** Literal only — never an expression. */
152
194
  style?: StyleDeclarations;
195
+ /**
196
+ * How many columns this header covers — a literal integer >= 1, never an
197
+ * expression; absent is 1. The columns it covers omit their own `header`.
198
+ */
199
+ span?: number;
153
200
  }
154
201
 
155
202
  export interface TableColumn extends Cell {
156
- header: string | TableHeader;
203
+ /**
204
+ * Absent exactly when a neighbour's header `span` covers this column: every
205
+ * column's header slot is filled once, by its own or by a span.
206
+ */
207
+ header?: string | TableHeader;
157
208
  /** Column width as a percentage of the table width (0 < width <= 100). */
158
209
  width?: number;
159
210
  }
@@ -163,13 +214,35 @@ export interface TableRow {
163
214
  style?: StyleDeclarations;
164
215
  }
165
216
 
217
+ /**
218
+ * One cell of a total row: an ordinary cell, plus the columns it covers. The
219
+ * spans in a row must sum to the column count exactly.
220
+ */
221
+ export type TotalCell = Cell & {
222
+ /**
223
+ * How many columns this cell covers — a literal integer >= 1, never an
224
+ * expression; absent is 1.
225
+ */
226
+ span?: number;
227
+ };
228
+
166
229
  export interface TotalRow {
167
- cells: [Cell, ...Cell[]];
230
+ cells: [TotalCell, ...TotalCell[]];
168
231
  visible?: ExpressionValue<boolean>;
169
232
  style?: StyleDeclarations;
170
233
  }
171
234
 
235
+ /**
236
+ * The header row's own box — distinct from `TableHeader`, which is one
237
+ * column's header cell. This is the box the row wears, replayed with the
238
+ * header cells on every page continuation.
239
+ */
240
+ export interface TableHeaderBox {
241
+ style?: StyleDeclarations;
242
+ }
243
+
172
244
  export interface TableDetail {
245
+ header?: TableHeaderBox;
173
246
  row?: TableRow;
174
247
  columns: [TableColumn, ...TableColumn[]];
175
248
  total?: [TotalRow, ...TotalRow[]];
@@ -312,6 +385,11 @@ export type Token = LiteralToken | ValueToken;
312
385
  export interface EventCell {
313
386
  tokens: Token[];
314
387
  style?: Record<string, unknown>;
388
+ /**
389
+ * How many columns this cell covers, present only when more than one: a
390
+ * span of 1 is the absence of one and never reaches the stream.
391
+ */
392
+ span?: number;
315
393
  /**
316
394
  * The definition's schema path, on cells that belong to one: a `row` cell
317
395
  * carries its column's (`detail.columns[i]`), a `total-row` cell its
@@ -445,7 +523,8 @@ export interface TableStartEvent {
445
523
  type: "table-start";
446
524
  /** Always `detail` — the table definition's schema path. */
447
525
  path: string;
448
- columns: { header: EventCell; path: string; width?: number }[];
526
+ /** One entry per column. `header` is absent when a neighbour's span covers it. */
527
+ columns: { header?: EventCell; path: string; width?: number }[];
449
528
  /** The header-row box from `detail.header`, when declared. */
450
529
  style?: Record<string, unknown>;
451
530
  }
@@ -657,8 +736,16 @@ export function format(
657
736
  * The typed-cell seam: exactly one value token holding a finite number, a
658
737
  * boolean, or a valid Date keeps its pre-stringify value; anything else —
659
738
  * including a lone null — reports `undefined` and joins to display text.
739
+ *
740
+ * Passing the cell's resolved `format` kind opts into the seam's one
741
+ * coercion: under `"date"`, a string in either accepted RFC 3339 form —
742
+ * `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
743
+ * names. Omitting the argument leaves the seam as it was.
660
744
  */
661
- export function typed(tokens: readonly Token[]): number | boolean | Date | undefined;
745
+ export function typed(
746
+ tokens: readonly Token[],
747
+ kind?: unknown,
748
+ ): number | boolean | Date | undefined;
662
749
 
663
750
  /**
664
751
  * Whether a band item's `role` names one of the report's own bands — its
@@ -668,3 +755,10 @@ export function typed(tokens: readonly Token[]): number | boolean | Date | undef
668
755
  * one: the question is whose band a role names, not what a walk can hand over.
669
756
  */
670
757
  export function isReportBand(role: string | undefined): boolean;
758
+
759
+ /**
760
+ * Every name in the closed style vocabulary, in the order SCHEMA.md's table
761
+ * lists them. A tool that offers the vocabulary imports this rather than
762
+ * keeping a copy.
763
+ */
764
+ export const STYLE_NAMES: readonly string[];
package/lib/index.js CHANGED
@@ -25,6 +25,7 @@ import { opt } from "./stream.js";
25
25
  // imports them from the package, not from a file inside it.
26
26
  export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
27
27
  export { format } from "./format.js";
28
+ export { STYLE_NAMES } from "./style.js";
28
29
  export { isDiagnostic } from "./locate.js";
29
30
 
30
31
  /** @typedef {import("./scope.js").Scope} Scope */
package/lib/license.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // reassigns them. A key is valid for every version released inside its window
12
12
  // (LICENSE section 7), so validity compares ISO date strings and never reads
13
13
  // a clock — accepted output stays accepted.
14
- let RELEASE = "2026-09-02"; // x-release-please-date
14
+ let RELEASE = "2026-09-05"; // x-release-please-date
15
15
  // The verifying half of the signing pair: the 65-byte uncompressed P-256
16
16
  // point, base64url. The private half never enters the repo;
17
17
  // scripts/license/sign.mjs mints keys against it.
package/lib/plan.js CHANGED
@@ -54,8 +54,11 @@ import {
54
54
  checkReportStyle,
55
55
  checkRowStyle,
56
56
  checkSlotImageStyle,
57
+ checkItemStyle,
57
58
  checkSlotStyle,
58
59
  checkStyle,
60
+ fanBox,
61
+ isBoxName,
59
62
  } from "./style.js";
60
63
 
61
64
  // Tolerance on the column-width sum. Widths are literal numbers an author may
@@ -67,11 +70,29 @@ let WIDTH_EPS = 1e-9;
67
70
  // plan rather than riding the state a plan accumulates.
68
71
  /** @type {any} */
69
72
  let NIL = () => null;
73
+ // The two halves a table row's `style` compiles to, and the pair a row that
74
+ // declares none wears.
75
+ /** @typedef {{ box: (scope: Scope) => any, rest: (scope: Scope) => any }} RowStyles */
76
+ /** @type {RowStyles} */
77
+ let NO_ROW_STYLE = { box: NIL, rest: NIL };
70
78
  /** @type {any} */
71
79
  let EMPTY = () => [];
72
80
  // What `$.params` reads when a report declares none.
73
81
  let NONE = Object.freeze({});
74
82
 
83
+ // How many table columns one cell covers. A literal, for the same reason a
84
+ // column `width` is: the column geometry is allocated before any row is laid
85
+ // out, so it cannot depend on one. Absent is 1, and an unreadable span reads
86
+ // as 1 so the coverage walk above it reports where the author actually went
87
+ // wrong instead of cascading. `spanned` is the emit side: a span of 1 is the
88
+ // absence of one, and never reaches the stream.
89
+ /** @type {(span: any) => number} */
90
+ let spanValue = (span) => (Number.isInteger(span) && span >= 1 ? span : 1);
91
+ /** @type {(span: any) => boolean} */
92
+ let validSpan = (span) => span == null || spanValue(span) === span;
93
+ /** @type {(span: number) => number} */
94
+ let spanned = (span) => (span > 1 ? span : 0);
95
+
75
96
  /** @type {(value: any) => boolean} */
76
97
  let isExpr = (value) => typeof value === "string" && value[0] === "=";
77
98
  /** @type {(from: Iterable<string>, into: Set<string>) => void} */
@@ -564,21 +585,56 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
564
585
  for (let [name, value] of entries) style[name] = value(scope);
565
586
  return style;
566
587
  };
567
- /** @type {(block: any) => boolean} */
568
- let isComputed = (block) => Object.values(block).some(isExpr);
569
588
  /**
570
589
  * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
571
590
  * => (scope: Scope) => any}
572
591
  */
573
- let compileStyles = (block, path, check) => {
574
- let entries = readStyles(block, path, check);
575
- for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
592
+ // A compiled block, constant-folded when nothing in it defers to render, so
593
+ // a literal style is resolved once and every node that wears it is handed
594
+ // the same frozen object.
595
+ /** @type {(block: any, entries: [string, Eval][]) => (scope: Scope) => any} */
596
+ let folded = (block, entries) => {
576
597
  if (!entries.length) return NIL;
577
598
  let resolve = styleFn(entries);
578
- if (isComputed(block)) return resolve;
599
+ if (entries.some(([name]) => isExpr(block[name]))) return resolve;
579
600
  let constant = Object.freeze(resolve({}));
580
601
  return () => constant;
581
602
  };
603
+ /**
604
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
605
+ * => [string, Eval][]}
606
+ */
607
+ let styleEntries = (block, path, check) => {
608
+ let entries = readStyles(block, path, check);
609
+ for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
610
+ return entries;
611
+ };
612
+ /**
613
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
614
+ * => (scope: Scope) => any}
615
+ */
616
+ let compileStyles = (block, path, check) => folded(block, styleEntries(block, path, check));
617
+ // A table row's block is split where it is compiled rather than at every
618
+ // row: its box half resolves onto the row's cells and the rest stays on the
619
+ // row (SCHEMA.md, "Style declarations"). Both halves fold like any other
620
+ // block, so a literal row style is partitioned once per table.
621
+ /**
622
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
623
+ * => RowStyles}
624
+ */
625
+ let compileRowStyles = (block, path, check) => {
626
+ let entries = styleEntries(block, path, check);
627
+ return {
628
+ box: folded(
629
+ block,
630
+ entries.filter(([name]) => isBoxName(name)),
631
+ ),
632
+ rest: folded(
633
+ block,
634
+ entries.filter(([name]) => !isBoxName(name)),
635
+ ),
636
+ };
637
+ };
582
638
  /**
583
639
  * @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
584
640
  * => (scope: Scope) => any}
@@ -589,6 +645,14 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
589
645
  if (block == null || !obj(block, stylePath)) return NIL;
590
646
  return compileStyles(block, stylePath, check);
591
647
  };
648
+ /** The same, for the three blocks that sit on a table row. */
649
+ /** @type {(def: any, path: string) => RowStyles} */
650
+ let rowStylesOf = (def, path) => {
651
+ let block = def.style;
652
+ let stylePath = pathTo(path, "style");
653
+ if (block == null || !obj(block, stylePath)) return NO_ROW_STYLE;
654
+ return compileRowStyles(block, stylePath, checkRowStyle);
655
+ };
592
656
  // A cell value: one sjabloon template. Emphasis is the cell's own `style`;
593
657
  // markup in literal text is meaningful only to the HTML target.
594
658
  /** @type {(value: any, path: string) => (scope: Scope, opts?: any) => any} */
@@ -703,6 +767,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
703
767
  columnsOf,
704
768
  takeOf,
705
769
  stylesOf,
770
+ rowStylesOf,
706
771
  cellValue,
707
772
  foldsOf,
708
773
  sortOf,
@@ -738,17 +803,53 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
738
803
  return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
739
804
  };
740
805
 
806
+ /** @type {(def: any, path: string) => number} */
807
+ let spanOf = (def, path) => {
808
+ if (validSpan(def.span)) return spanValue(def.span);
809
+ bad(path + ".span", "expected a positive integer (span >= 1)");
810
+ return 1;
811
+ };
812
+
741
813
  // A cell without a row, so `@` stays unbound. A bare string is shorthand for
742
814
  // `{ value }` and keeps its authored path, so problems locate where written.
743
- /** @type {(def: any, path: string) => (scope: Scope) => any} */
815
+ // A span needs the object form -- a bare string has nowhere to carry one.
816
+ // Absent is a problem here: a column that legitimately declares no header is
817
+ // one a neighbour's span covers, and `headerSlot` answers that before this
818
+ // is reached.
819
+ /**
820
+ * @type {(def: any, path: string) => { cell: (scope: Scope) => any, span: number }}
821
+ */
744
822
  let headerOf = (def, path) => {
745
- if (typeof def === "string") return cellShape(cellValue(def, path), null, NIL);
823
+ if (typeof def === "string")
824
+ return { cell: cellShape(cellValue(def, path), null, NIL), span: 1 };
746
825
  if (!record(def)) {
747
826
  bad(path, "expected a template string or object");
748
- return cellShape(EMPTY, null, NIL);
827
+ return { cell: cellShape(EMPTY, null, NIL), span: 1 };
749
828
  }
750
- keys(def, ["value", "style"], path);
751
- return cellOf(def, path);
829
+ keys(def, ["value", "style", "span"], path);
830
+ return { cell: cellOf(def, path), span: spanOf(def, path) };
831
+ };
832
+
833
+ // A column a neighbour's span covers declares no header of its own. One that
834
+ // declares one anyway is the overlap half of the coverage rule.
835
+ /** @type {(def: any, path: string) => null} */
836
+ let covered = (def, path) => {
837
+ if (def != null) bad(path, "covered by an earlier header's span");
838
+ return null;
839
+ };
840
+ // The per-column half of the coverage rule. `headerCover` decides which slot
841
+ // a column is in; this reports it, in the column's own turn, so header
842
+ // problems still come out in the documented key order.
843
+ /**
844
+ * @type {(def: any, path: string, slot: string) =>
845
+ * { cell: (scope: Scope) => any, span: number } | null}
846
+ */
847
+ let headerSlot = (def, path, slot) => {
848
+ if (slot === "unreached") return null;
849
+ if (slot === "covered") return covered(def, path);
850
+ let header = headerOf(def, path);
851
+ if (slot === "over") bad(path + ".span", "reaches past the last column");
852
+ return header;
752
853
  };
753
854
 
754
855
  /** @type {(width: any) => boolean} */
@@ -759,11 +860,11 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
759
860
  bad(path + ".width", "expected a percentage number (0 < width <= 100)");
760
861
  return NaN;
761
862
  };
762
- /** @type {(def: any, path: string) => any} */
763
- let columnOf = (def, path) => {
863
+ /** @type {(def: any, path: string, slot: string) => any} */
864
+ let columnOf = (def, path, slot) => {
764
865
  if (!obj(def, path)) return null;
765
866
  keys(def, ["header", "value", "visible", "style", "width"], path);
766
- let header = headerOf(def.header, path + ".header");
867
+ let header = headerSlot(def.header, path + ".header", slot);
767
868
  let tpl = cellValue(def.value, path + ".value");
768
869
  let visible = visibleOf(def, path);
769
870
  let styles = stylesOf(def, path, checkCellStyle);
@@ -773,11 +874,14 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
773
874
  return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
774
875
  };
775
876
 
776
- /** @type {(def: any, path: string) => ((scope: Scope) => any) | null} */
877
+ /**
878
+ * @type {(def: any, path: string) =>
879
+ * { cell: (scope: Scope) => any, span: number } | null}
880
+ */
777
881
  let totalOf = (def, path) => {
778
882
  if (!obj(def, path)) return null;
779
- keys(def, ["value", "visible", "style"], path);
780
- return cellOf(def, path);
883
+ keys(def, ["value", "visible", "style", "span"], path);
884
+ return { cell: cellOf(def, path), span: spanOf(def, path) };
781
885
  };
782
886
 
783
887
  // A text item: a cell with no slot to keep, so a hidden one drops itself
@@ -793,7 +897,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
793
897
  let shape = cellShape(
794
898
  tpl,
795
899
  inSlot ? visible : null,
796
- stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
900
+ stylesOf(def, path, inSlot ? checkSlotStyle : checkItemStyle),
797
901
  );
798
902
  // The cell shape first, then the item's own fields, so a cell reads
799
903
  // the same wherever it appears in the stream.
@@ -998,7 +1102,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
998
1102
  */
999
1103
  let bands = (
1000
1104
  { bad, runNames },
1001
- { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, stylesOf, foldsOf, sortOf },
1105
+ { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, rowStylesOf, foldsOf, sortOf },
1002
1106
  { columnOf, itemsOf, totalOf },
1003
1107
  ) => {
1004
1108
  // Stacked items, one pass per row.
@@ -1014,30 +1118,71 @@ let bands = (
1014
1118
  }
1015
1119
  };
1016
1120
 
1017
- /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: (scope: Scope) => any }} */
1121
+ /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: RowStyles }} */
1018
1122
  let rowLook = (value) => {
1019
1123
  if (value.row == null || !obj(value.row, "detail.row"))
1020
- return { rowVisible: null, rowStyles: NIL };
1124
+ return { rowVisible: null, rowStyles: NO_ROW_STYLE };
1021
1125
  keys(value.row, ["visible", "style"], "detail.row");
1022
1126
  return {
1023
1127
  rowVisible: visibleOf(value.row, "detail.row"),
1024
- rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
1128
+ rowStyles: rowStylesOf(value.row, "detail.row"),
1025
1129
  };
1026
1130
  };
1027
1131
  // The header-row box: `style` only, a different path from each column's
1028
1132
  // `header` cell. Absent when the table declares none.
1029
- /** @type {(value: any) => (scope: Scope) => any} */
1133
+ /** @type {(value: any) => RowStyles} */
1030
1134
  let headerLook = (value) => {
1031
- if (value.header == null || !obj(value.header, "detail.header")) return NIL;
1135
+ if (value.header == null || !obj(value.header, "detail.header")) return NO_ROW_STYLE;
1032
1136
  keys(value.header, ["style"], "detail.header");
1033
- return stylesOf(value.header, "detail.header", checkRowStyle);
1137
+ return rowStylesOf(value.header, "detail.header");
1138
+ };
1139
+ // Which slot each column's header sits in -- the same invariant a total row's
1140
+ // cells carry, walked positionally rather than summed because the diagnostic
1141
+ // has to name the column it broke at. Read off the raw definitions, before
1142
+ // any column is parsed, so `columnOf` can report its verdict in the column's
1143
+ // own turn. Spans are read leniently: an unreadable one covers a single
1144
+ // column and gets its own diagnostic when the column is parsed.
1145
+ //
1146
+ // `own` fills its own slot, `covered` is filled by a neighbour's span, `over`
1147
+ // reaches past the last column, and everything after an `over` is
1148
+ // `unreached` -- one broken span should not also report every column behind
1149
+ // it as headerless.
1150
+ // How many columns a definition's header claims, read leniently: a malformed
1151
+ // column, a missing header and an unreadable span all claim one here, and
1152
+ // each gets its own diagnostic when the column is parsed.
1153
+ /** @type {(def: any) => number} */
1154
+ let headerSpan = (def) => {
1155
+ let header = record(def) ? def.header : null;
1156
+ return record(header) ? spanValue(header.span) : 1;
1157
+ };
1158
+ /** @type {(slots: string[], from: number, to: number, slot: string) => void} */
1159
+ let fill = (slots, from, to, slot) => {
1160
+ for (let at = from; at < to; at++) slots[at] = slot;
1161
+ };
1162
+ /** @type {(defs: any[]) => string[]} */
1163
+ let headerCover = (defs) => {
1164
+ let slots = defs.map(() => "own");
1165
+ let at = 0;
1166
+ while (at < defs.length) {
1167
+ let span = headerSpan(defs[at]);
1168
+ if (at + span > defs.length) {
1169
+ slots[at] = "over";
1170
+ fill(slots, at + 1, defs.length, "unreached");
1171
+ return slots;
1172
+ }
1173
+ fill(slots, at + 1, at + span, "covered");
1174
+ at += span;
1175
+ }
1176
+ return slots;
1034
1177
  };
1035
1178
  /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
1036
1179
  let tableColumns = (value) => {
1037
1180
  let defs = arr(value.columns, "detail.columns", true);
1038
- if (defs && !defs.length) bad("detail.columns", "expected at least one column");
1039
- let columns = (defs || [])
1040
- .map((def, i) => columnOf(def, "detail.columns[" + i + "]"))
1181
+ let list = defs || [];
1182
+ if (defs && !list.length) bad("detail.columns", "expected at least one column");
1183
+ let slots = headerCover(list);
1184
+ let columns = list
1185
+ .map((def, i) => columnOf(def, "detail.columns[" + i + "]", slots[i]))
1041
1186
  .filter((column) => column != null);
1042
1187
  checkShares(bad, columns, "detail.columns", "column");
1043
1188
  return { defs, columns };
@@ -1047,8 +1192,8 @@ let bands = (
1047
1192
  totals
1048
1193
  .map((def, i) => {
1049
1194
  let path = prefix + "[" + i + "]";
1050
- let cell = totalOf(def, path);
1051
- return cell && { cell, path };
1195
+ let total = totalOf(def, path);
1196
+ return total && { cell: total.cell, span: total.span, path };
1052
1197
  })
1053
1198
  .filter((entry) => entry != null);
1054
1199
  /** @type {(rows: any[] | null) => any[]} */
@@ -1058,7 +1203,7 @@ let bands = (
1058
1203
  };
1059
1204
  /**
1060
1205
  * @type {(value: any, defs: any[] | null) =>
1061
- * { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null}
1206
+ * { styles: RowStyles, visible: any, cells: any[] }[] | null}
1062
1207
  */
1063
1208
  let tableTotals = (value, defs) =>
1064
1209
  value.total == null
@@ -1066,76 +1211,91 @@ let bands = (
1066
1211
  : nonempty(arr(value.total, "detail.total", true)).map((def, r) =>
1067
1212
  totalRowOf(def, "detail.total[" + r + "]", defs),
1068
1213
  );
1214
+ // Read leniently: a cell whose span is unreadable, or which is not an object
1215
+ // at all, covers one column here, and its own diagnostic comes from parsing
1216
+ // it. Otherwise one mistake would report twice.
1069
1217
  /** @type {(cells: any[], defs: any[] | null) => boolean} */
1070
- let aligned = (cells, defs) => !defs || cells.length === defs.length;
1218
+ let covering = (cells, defs) =>
1219
+ !defs ||
1220
+ cells.reduce((all, cell) => all + spanValue(record(cell) ? cell.span : null), 0) ===
1221
+ defs.length;
1071
1222
  /** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
1072
1223
  let counted = (cells, defs, path) => {
1073
1224
  if (!cells) return [];
1074
- if (!aligned(cells, defs)) bad(path + ".cells", "expected one cell per column");
1225
+ if (!covering(cells, defs)) bad(path + ".cells", "expected cells covering every column");
1075
1226
  return cells;
1076
1227
  };
1077
1228
  /**
1078
1229
  * @type {(def: any, path: string, defs: any[] | null) =>
1079
- * { styles: (scope: Scope) => any, visible: any, cells: any[] }}
1230
+ * { styles: RowStyles, visible: any, cells: any[] }}
1080
1231
  */
1081
1232
  let totalRowOf = (def, path, defs) => {
1082
- if (!obj(def, path)) return { styles: NIL, visible: null, cells: [] };
1233
+ if (!obj(def, path)) return { styles: NO_ROW_STYLE, visible: null, cells: [] };
1083
1234
  keys(def, ["cells", "style", "visible"], path);
1084
1235
  let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
1085
1236
  return {
1086
- styles: stylesOf(def, path, checkRowStyle),
1237
+ styles: rowStylesOf(def, path),
1087
1238
  visible: visibleOf(def, path),
1088
1239
  cells: mapTotals(cells, path + ".cells"),
1089
1240
  };
1090
1241
  };
1091
1242
  /**
1092
1243
  * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
1093
- * rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Generator<any>}
1244
+ * rowVisible: Eval | null, rowStyles: RowStyles) => Generator<any>}
1094
1245
  */
1095
1246
  function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles) {
1096
1247
  for (let row of rows) {
1097
1248
  let scopeOfRow = runners.bind(scope, row);
1098
- if (!hidden(rowVisible, scopeOfRow))
1099
- yield opt(
1100
- {
1101
- type: "row",
1102
- cells: columns.map((column) =>
1103
- Object.assign(column.cell(scopeOfRow), { path: column.path }),
1104
- ),
1105
- },
1106
- { style: rowStyles(scopeOfRow), run: scopeOfRow.run },
1107
- );
1249
+ if (hidden(rowVisible, scopeOfRow)) continue;
1250
+ let cells = columns.map((column) =>
1251
+ Object.assign(column.cell(scopeOfRow), { path: column.path }),
1252
+ );
1253
+ fanBox(rowStyles.box(scopeOfRow), cells);
1254
+ yield opt({ type: "row", cells }, { style: rowStyles.rest(scopeOfRow), run: scopeOfRow.run });
1108
1255
  }
1109
1256
  }
1110
1257
  /**
1111
- * @type {(total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null,
1258
+ * @type {(total: { styles: RowStyles, visible: any, cells: any[] }[] | null,
1112
1259
  * scope: Scope) => Generator<any>}
1113
1260
  */
1114
1261
  function* emitTotals(total, scope) {
1115
1262
  if (!total) return;
1116
1263
  for (let row of total) {
1117
1264
  if (hidden(row.visible, scope)) continue;
1118
- yield opt(
1119
- {
1120
- type: "total-row",
1121
- cells: row.cells.map(({ cell, path }) => Object.assign(cell(scope), { path })),
1122
- },
1123
- { style: row.styles(scope) },
1265
+ let cells = row.cells.map(({ cell, span, path }) =>
1266
+ opt(Object.assign(cell(scope), { path }), { span: spanned(span) }),
1124
1267
  );
1268
+ fanBox(row.styles.box(scope), cells);
1269
+ yield opt({ type: "total-row", cells }, { style: row.styles.rest(scope) });
1125
1270
  }
1126
1271
  }
1127
- /** @type {(columns: any[], total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any, headerStyles: (scope: Scope) => any) => Band} */
1272
+ /** @type {(columns: any[], total: { styles: RowStyles, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: RowStyles, headerStyles: RowStyles) => Band} */
1128
1273
  let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) =>
1129
1274
  function* (rows, scope, runners) {
1275
+ // A covered column carries no `header` at all: the header row is the
1276
+ // cells the columns declare, which is fewer than the columns whenever
1277
+ // one spans. A consumer that reads no `span` still gets one cell per
1278
+ // uncovered column, which is the shape this had before spans existed.
1279
+ let cols = columns.map((column) =>
1280
+ opt(
1281
+ {},
1282
+ {
1283
+ header:
1284
+ column.header &&
1285
+ opt(column.header.cell(scope), { span: spanned(column.header.span) }),
1286
+ path: column.path,
1287
+ width: column.width,
1288
+ },
1289
+ ),
1290
+ );
1291
+ // The header-row box fans onto the cells that are there, which for the
1292
+ // same reason is fewer than the columns whenever one spans. The list is
1293
+ // built only when there is a box to put on it.
1294
+ let box = headerStyles.box(scope);
1295
+ if (box) fanBox(box, cols.map((column) => column.header).filter(Boolean));
1130
1296
  yield opt(
1131
- {
1132
- type: "table-start",
1133
- path: "detail",
1134
- columns: columns.map((column) =>
1135
- opt({ header: column.header(scope), path: column.path }, { width: column.width }),
1136
- ),
1137
- },
1138
- { style: headerStyles(scope) },
1297
+ { type: "table-start", path: "detail", columns: cols },
1298
+ { style: headerStyles.rest(scope) },
1139
1299
  );
1140
1300
  yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
1141
1301
  yield* emitTotals(total, scope);
@@ -1329,15 +1489,13 @@ let headerHeightOf = (height, bad) => {
1329
1489
  else if (!posPts(height)) bad("header.height", "expected a positive number of points");
1330
1490
  return posPts(height) ? height : null;
1331
1491
  };
1332
- /** @type {(itemsOf: any) => { items: any, height: number | null }} */
1333
- let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
1334
1492
  /** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
1335
1493
  let reportHeaderOf = (value, keys, itemsOf, bad) => {
1336
1494
  if (value == null || Array.isArray(value))
1337
1495
  return { items: itemsOf(value, "header", "report-header"), height: null };
1338
1496
  if (!record(value)) {
1339
1497
  bad("header", "expected an array of items or { height, items }");
1340
- return emptyHeader(itemsOf);
1498
+ return { items: itemsOf(null, "header", "report-header"), height: null };
1341
1499
  }
1342
1500
  keys(value, ["height", "items"], "header");
1343
1501
  return {
package/lib/stream.js CHANGED
@@ -24,6 +24,59 @@ let asTyped = (value) => {
24
24
  if (finiteDate(value)) return value;
25
25
  return undefined;
26
26
  };
27
+ // The seam's one coercion, kept behind the declared kind (ADR 0041).
28
+ /** @type {(value: any, kind: unknown) => Date | undefined} */
29
+ let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefined);
30
+
31
+ // The two RFC 3339 forms a JSON document conventionally carries a date in:
32
+ // a calendar date, and a timestamp that names its offset. A zoneless
33
+ // `2026-08-14T00:00:00` is deliberately absent — ECMAScript reads it as local
34
+ // time, so the same document would present a different day per machine, which
35
+ // ADR 0025 rules out. Uppercase `T`/`Z` only, for the same reason: the lower
36
+ // case RFC 3339 permits is outside the ECMAScript format and parses at the
37
+ // engine's discretion.
38
+ let RFC3339 =
39
+ /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2})))?$/;
40
+
41
+ // An absent group is the timestamp half of an optional match, not a breach.
42
+ /** @type {(bound: [string | undefined, number, number]) => boolean} */
43
+ let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
44
+ // A non-string never matches: `exec` would coerce one, and a single-element
45
+ // array stringifies to its element, which would revive an array as a date.
46
+ /** @type {(value: any) => RegExpExecArray | null} */
47
+ let rfc3339 = (value) => (typeof value === "string" ? RFC3339.exec(value) : null);
48
+
49
+ /**
50
+ * Revive a date string the way a host would have revived it — the accepted
51
+ * forms are exactly those `new Date(string)` reads identically on every
52
+ * machine, so declaring `format: "date"` and injecting `Date` values into the
53
+ * render data are two spellings of one document (ADR 0041).
54
+ *
55
+ * The components are range-checked here rather than left to the parser:
56
+ * ECMAScript rolls an out-of-range day over (`2026-02-30` becomes 2 March)
57
+ * where a date that does not exist should revive nothing at all. A leap
58
+ * second (`:60`) is refused on the same ground — RFC 3339 admits it and
59
+ * ECMAScript does not, so it would be a second rollover.
60
+ *
61
+ * @type {(value: any) => Date | undefined}
62
+ */
63
+ export let reviveDate = (value) => {
64
+ let parts = rfc3339(value);
65
+ if (!parts) return undefined;
66
+ let [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts;
67
+ /** @type {[string | undefined, number, number][]} */
68
+ let bounds = [
69
+ [month, 1, 12],
70
+ [day, 1, new Date(Date.UTC(+year, +month, 0)).getUTCDate()],
71
+ [hour, 0, 23],
72
+ [minute, 0, 59],
73
+ [second, 0, 59],
74
+ [offsetHour, 0, 23],
75
+ [offsetMinute, 0, 59],
76
+ ];
77
+ let date = bounds.every(within) ? new Date(value) : new Date(NaN);
78
+ return finiteDate(date) ? date : undefined;
79
+ };
27
80
 
28
81
  /**
29
82
  * The typed-cell seam: exactly one value token holding a finite number, a
@@ -31,13 +84,23 @@ let asTyped = (value) => {
31
84
  * including a lone null, matching the display-text treatment everywhere
32
85
  * else — reports `undefined` and joins to text.
33
86
  *
87
+ * Passing the cell's declared `format` kind opts into the one coercion the
88
+ * seam performs: under `"date"`, a string in either accepted RFC 3339 form
89
+ * revives to the `Date` it names. The gate is the declaration, and the kind
90
+ * is `date` alone, because a date is the one type a JSON document cannot
91
+ * carry — a number arrives as a number, so no other kind has anything to
92
+ * revive (ADR 0041). Callers that read no style omit the argument and see
93
+ * the seam exactly as it was.
94
+ *
34
95
  * @param {any[]} tokens A cell's tokens.
96
+ * @param {unknown} [kind] The cell's resolved `format` declaration.
35
97
  * @returns {number | boolean | Date | undefined} The native value, if any.
36
98
  */
37
- export function typed(tokens) {
99
+ export function typed(tokens, kind) {
38
100
  let [token] = tokens;
39
101
  if (tokens.length !== 1 || !("value" in token)) return undefined;
40
- return asTyped(token.value);
102
+ // `??` and not `||`: a boolean cell's own `false` is a typed value.
103
+ return asTyped(token.value) ?? asDeclared(token.value, kind);
41
104
  }
42
105
 
43
106
  // The report's own bands, by the role their items wear (CONTEXT.md, "Band").
package/lib/style.js CHANGED
@@ -1,12 +1,21 @@
1
1
  /**
2
- * The closed style vocabulary, as the traversal checks it: target-neutral names
3
- * with typed literal values. Unknown names and mistyped literals are definition
4
- * errors; `=` results stay lenient at render. Each target maps these names to
5
- * its own formatting model, and nothing here knows about any of them.
2
+ * The closed style vocabulary target-neutral names with typed literal values —
3
+ * and the resolutions the event stream is defined in terms of. Two things, and
4
+ * the split between them is the file's organising claim.
6
5
  *
7
- * `finite`/`HEX` are restated per target on purpose: those coercions are each
8
- * target's own edge, never shared engine code unlike the stream rules
9
- * (`typed`), which every target reads from `./stream.js`.
6
+ * The **checks** are what the traversal runs: unknown names and mistyped
7
+ * literals are definition errors; `=` results stay lenient at render.
8
+ *
9
+ * The **resolutions** are what a declaration means before any target sees it.
10
+ * A row's box is the one there is (SCHEMA.md, "Style declarations"): it says
11
+ * what the document means rather than what a surface paints, so every target
12
+ * is handed the answer instead of reaching its own. That is the same line
13
+ * `./stream.js` sits on, and the opposite side of it from `finite`/`HEX`,
14
+ * which are restated per target on purpose because a coercion is each target's
15
+ * own edge.
16
+ *
17
+ * Past those, each target maps these names to its own formatting model, and
18
+ * nothing here knows about any of them.
10
19
  */
11
20
  /** @type {(value: any) => boolean} */
12
21
  let finite = (value) => typeof value === "number" && Number.isFinite(value);
@@ -19,6 +28,8 @@ let spec = (msg, ok) => ({ ok, msg });
19
28
  let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
20
29
  let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
21
30
  let ALIGNMENTS = ["left", "center", "right"];
31
+ // "middle", not "center": that word is already the horizontal one.
32
+ let VALIGNMENTS = ["top", "middle", "bottom"];
22
33
  let LINES = ["solid", "dashed", "dotted"];
23
34
  let SIDES = ["Top", "Right", "Bottom", "Left"];
24
35
  let BORDER_PARTS = ["Width", "Style", "Color"];
@@ -49,6 +60,7 @@ let STYLES = {
49
60
  color: COLOR,
50
61
  background: COLOR,
51
62
  align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
63
+ valign: spec("expected top, middle, or bottom", (value) => VALIGNMENTS.includes(value)),
52
64
  format: spec("expected number, currency, percent, or date", (value) => FORMATS.includes(value)),
53
65
  };
54
66
  let FORMATS = ["number", "currency", "percent", "date"];
@@ -73,6 +85,27 @@ for (let name of FLOW) {
73
85
  STYLES[name] = POINTS;
74
86
  IMAGE_STYLES.push(name);
75
87
  }
88
+ // The vocabulary as a list, in the order SCHEMA.md's table reads it: the text
89
+ // names, the flow, then the box one part at a time across the four sides. A
90
+ // tool that offers the vocabulary (the editor's style rail) imports this
91
+ // rather than keeping a copy that has to be told about every new name.
92
+ /** @type {readonly string[]} */
93
+ // The box half of the vocabulary, generated once from the same two tables the
94
+ // checks read. `isBoxName` is the only place the question "does this name
95
+ // belong to the box?" is answered, so a name that does not start with
96
+ // `padding` or `border` could join without every asker being told separately.
97
+ let BOX_NAMES = [
98
+ ...SIDES.map((side) => "padding" + side),
99
+ ...BORDER_PARTS.flatMap((part) => SIDES.map((side) => "border" + side + part)),
100
+ ];
101
+ let BOX = new Set(BOX_NAMES);
102
+ /** @type {(name: string) => boolean} */
103
+ export let isBoxName = (name) => BOX.has(name);
104
+ export let STYLE_NAMES = Object.freeze([
105
+ ...Object.keys(STYLES).filter((name) => !FLOW.includes(name) && !isBoxName(name)),
106
+ ...FLOW,
107
+ ...BOX_NAMES,
108
+ ]);
76
109
  // One check for both the validating traversal and the compile path: an error
77
110
  // message for a declaration, or null when it is acceptable (expressions defer
78
111
  // to render).
@@ -150,9 +183,80 @@ let withoutFlow = (check, subject) => withoutNames(FLOW_NAMES, check, subject);
150
183
 
151
184
  export let checkCellStyle = withoutFlow(checkStyle, "a table cell");
152
185
  export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
153
- export let checkSlotImageStyle = withoutFlow(checkImageStyle, "a split slot");
186
+ // `valign` is legal exactly where a box has height it did not ask for
187
+ // (CONTEXT.md "Box": slack). A slot's box is the split's height, so a slot --
188
+ // text or image -- reads it; a band image's box is its own, so it does not.
189
+ // That is the one name an image accepts in a slot and nowhere else, the same
190
+ // shape of reason `width` reaches an item only there (docs/adr/0029).
191
+ let SLOT_IMAGE_STYLES = [...IMAGE_STYLES, "valign"];
192
+ export let checkSlotImageStyle = withoutFlow(
193
+ narrowed(SLOT_IMAGE_STYLES, "an image"),
194
+ "a split slot",
195
+ );
196
+ // A stacked text item's box is exactly its content and padding: no slack, so
197
+ // `valign` is refused rather than left unread.
198
+ /** @type {Record<string, true>} */
199
+ let STACKED_NAMES = { valign: true };
200
+ export let checkItemStyle = withoutNames(STACKED_NAMES, checkStyle, "a stacked item");
154
201
  // A row box has no cell value, so `format` is refused there the way flow
155
202
  // spacing is — it is a presentation of a value, not of a band of cells.
156
203
  /** @type {Record<string, true>} */
157
204
  let ROW_NAMES = { ...FLOW_NAMES, format: true };
158
205
  export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
206
+
207
+ // --- resolution --------------------------------------------------------------
208
+
209
+ // A row's `style` resolves onto its cells (SCHEMA.md, "Style declarations").
210
+ // The box is the half that cannot arrive any other way: a table row is not a
211
+ // box on any surface -- not a `<tr>`, not a worksheet row, not a band of the
212
+ // PDF's own -- so it is resolved here, once, and no target is left to have an
213
+ // opinion about it. Every other declaration on a row reaches its cells by the
214
+ // layering each target already does, which is why only the box moves.
215
+ //
216
+ // Which names are the box is decided where the block is compiled, not here: a
217
+ // literal row style is partitioned once per table rather than once per row.
218
+ // What is left for render is the per-cell layering below.
219
+
220
+ // The three names of each border side, built once. `layer` reaches for them
221
+ // per cell, and rebuilding twelve strings per cell was most of what this cost.
222
+ /** @type {Record<string, string[]>} */
223
+ let BORDER_NAMES = Object.fromEntries(
224
+ SIDES.map((side) => [side, BORDER_PARTS.map((part) => "border" + side + part)]),
225
+ );
226
+
227
+ /** @type {(block: any, side: string) => boolean} */
228
+ let ownsSide = (block, side) => BORDER_NAMES[side].some((name) => Object.hasOwn(block, name));
229
+
230
+ // The spread has already written every name the cell declared, so taking a
231
+ // side is dropping what is left of the row's: the names of that side the cell
232
+ // did not name itself.
233
+ /** @type {(out: any, over: any, side: string) => void} */
234
+ let takeSide = (out, over, side) => {
235
+ for (let name of BORDER_NAMES[side]) if (!Object.hasOwn(over, name)) delete out[name];
236
+ };
237
+
238
+ // A border side is won whole by the cell: a cell that named any of the three
239
+ // keys takes that side entirely, so a cell that failed-soft on one name does
240
+ // not pick up the row's other two and become a stroke nobody declared. Only
241
+ // the sides the row's box actually declares can be taken from it, so the
242
+ // caller settles which those are once and hands them down.
243
+ /** @type {(under: any, over: any, sides: string[]) => any} */
244
+ let layer = (under, over, sides) => {
245
+ if (!over) return under;
246
+ let out = { ...under, ...over };
247
+ for (let side of sides) if (ownsSide(over, side)) takeSide(out, over, side);
248
+ return out;
249
+ };
250
+
251
+ /**
252
+ * Resolve a row's box onto the row's cells. The cells are written in place:
253
+ * both they and the box were built for this one event and nothing else holds
254
+ * them yet. A row that declares no box is the common case and costs one test.
255
+ *
256
+ * @type {(box: any, cells: any[]) => void}
257
+ */
258
+ export let fanBox = (box, cells) => {
259
+ if (!box) return;
260
+ let sides = SIDES.filter((side) => ownsSide(box, side));
261
+ for (let cell of cells) cell.style = layer(box, cell.style, sides);
262
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -51,7 +51,7 @@
51
51
  "sjabloon",
52
52
  "padvinder"
53
53
  ],
54
- "limit": "10 kB"
54
+ "limit": "11 kB"
55
55
  }
56
56
  ],
57
57
  "engines": {