quario 0.3.0 → 0.4.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,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-03
11
+
12
+ ### Changed
13
+
14
+ - **`format: "date"` now reads a date string, not only a `Date`.** A JSON
15
+ document has no date type, so the kind could not be reached from parsed
16
+ data without reviving every date field by hand first. It now revives two
17
+ forms itself: a calendar date `2026-08-14`, and a timestamp naming its
18
+ offset (`2026-08-14T12:30:00Z` or `+02:00`). **This changes existing
19
+ output**: a cell declaring the kind over `"2026-08-14"` rendered
20
+ `2026-08-14` and now renders `14/8/2026` under an `en-IE` instance. A
21
+ zoneless `2026-08-14T00:00:00` is not read — it means local time, so it
22
+ would present a different day per machine — and neither is a loose
23
+ `14/8/2026`, a partial `2026-08`, a lowercase `t`/`z`, or a day that does
24
+ not exist. Anything unread renders as authored, as before.
25
+
26
+ The revival is the `Date` a host would have injected, timezone included: a
27
+ calendar date is UTC midnight, so under a western instance timezone it
28
+ presents as the previous day, exactly as `new Date("2026-08-14")` does.
29
+ Only `date` revives — `number`, `currency` and `percent` never read a
30
+ string, because JSON already carries numbers — and epoch milliseconds stay
31
+ a number under every kind.
32
+
33
+ - **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
34
+ opts into the same revival, for consumers that write typed cells. The
35
+ one-argument call is unchanged.
36
+
37
+ ### Fixed
38
+
39
+ - **The TypeScript declarations accept the box.** The per-side `padding*` and
40
+ `border*` names, and the table's `detail.header`, were validated by the
41
+ engine from 0.3.0 but were missing from the shipped declarations, so a
42
+ report declaring the padding or the border sides that release introduced
43
+ was rejected by the compiler as an unknown property and needed a cast to
44
+ get past it. They now type exactly as the engine reads them, on a band
45
+ image's `style` as well. Four names come with them, for annotating your own
46
+ helpers: `LineStyle` (`"solid" | "dashed" | "dotted"`), beside `Align` and
47
+ `FormatKind`; `TableHeaderBox`, the type of `detail.header`; and
48
+ `BoxDeclarations` with `Side`, the per-side names as one type, which
49
+ `StyleDeclarations` was built from without being nameable. Nothing
50
+ about rendering changes: a report that compiled through a cast produces the
51
+ same output without one.
52
+
10
53
  ## [0.3.0] - 2026-09-02
11
54
 
12
55
  ### Added
@@ -16,20 +59,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
59
  `row.style`, not the report default. Locale, currency code, and timezone
17
60
  live on `quario({ locale, currency, timeZone })`. The public `format()`
18
61
  helper is how targets present a kind; a kind on the wrong type contributes
19
- nothing (`docs/adr/0041`).
62
+ nothing.
20
63
 
21
64
  - **The report header may pin a `height` from the page top.** Dual-shaped
22
65
  like `detail`: an item array, or `{ height, items }`. `page.margin` is a
23
66
  document field (one number, all four sides), required with `height` and
24
67
  legal without. Authored `spaceBefore` on the first occupying item of the
25
- next band is refused (`docs/adr/0038`).
68
+ next band is refused.
26
69
 
27
70
  - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
28
71
  before or after a band item, in points, including band images and splits as
29
72
  band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
30
73
  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`).
74
+ or strip top; page-band items keep it. Leading and inset stay cut.
33
75
 
34
76
  - **Per-side padding and border on the closed style vocabulary.**
35
77
  `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
@@ -50,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
92
  array or `{ style, cells }`. After, it is absent or a non-empty array of
51
93
  `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
52
94
  `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`).
95
+ The stream yields one `total-row` per emitted row.
54
96
 
55
97
  - **A visible text item occupies a line at its own `size`.** Empty display
56
98
  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. */
@@ -87,14 +121,15 @@ export interface TextItem extends Cell {
87
121
  export type ImageFit = "natural" | "width";
88
122
 
89
123
  /**
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.
124
+ * The declarations an image item accepts: the box, plus the four names that
125
+ * are not about text. The rest of the vocabulary describes text, which an
126
+ * image does not have, so any other name on one is a definition error. A
127
+ * narrowing of the one vocabulary rather than a second list of its own, so the
128
+ * two cannot drift.
94
129
  */
95
130
  export type ImageStyleDeclarations = Pick<
96
131
  StyleDeclarations,
97
- "background" | "align" | "spaceBefore" | "spaceAfter"
132
+ "background" | "align" | "spaceBefore" | "spaceAfter" | keyof BoxDeclarations
98
133
  >;
99
134
 
100
135
  /**
@@ -169,7 +204,17 @@ export interface TotalRow {
169
204
  style?: StyleDeclarations;
170
205
  }
171
206
 
207
+ /**
208
+ * The header row's own box — distinct from `TableHeader`, which is one
209
+ * column's header cell. This is the box the row wears, replayed with the
210
+ * header cells on every page continuation.
211
+ */
212
+ export interface TableHeaderBox {
213
+ style?: StyleDeclarations;
214
+ }
215
+
172
216
  export interface TableDetail {
217
+ header?: TableHeaderBox;
173
218
  row?: TableRow;
174
219
  columns: [TableColumn, ...TableColumn[]];
175
220
  total?: [TotalRow, ...TotalRow[]];
@@ -657,8 +702,16 @@ export function format(
657
702
  * The typed-cell seam: exactly one value token holding a finite number, a
658
703
  * boolean, or a valid Date keeps its pre-stringify value; anything else —
659
704
  * including a lone null — reports `undefined` and joins to display text.
705
+ *
706
+ * Passing the cell's resolved `format` kind opts into the seam's one
707
+ * coercion: under `"date"`, a string in either accepted RFC 3339 form —
708
+ * `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
709
+ * names. Omitting the argument leaves the seam as it was.
660
710
  */
661
- export function typed(tokens: readonly Token[]): number | boolean | Date | undefined;
711
+ export function typed(
712
+ tokens: readonly Token[],
713
+ kind?: unknown,
714
+ ): number | boolean | Date | undefined;
662
715
 
663
716
  /**
664
717
  * Whether a band item's `role` names one of the report's own bands — its
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-03"; // 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/stream.js CHANGED
@@ -24,6 +24,61 @@ 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
+ /** @type {(year: string, month: string) => number} */
42
+ let monthLength = (year, month) => new Date(Date.UTC(+year, +month, 0)).getUTCDate();
43
+ // An absent group is the timestamp half of an optional match, not a breach.
44
+ /** @type {(bound: [string | undefined, number, number]) => boolean} */
45
+ let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
46
+ // A non-string never matches: `exec` would coerce one, and a single-element
47
+ // array stringifies to its element, which would revive an array as a date.
48
+ /** @type {(value: any) => RegExpExecArray | null} */
49
+ let rfc3339 = (value) => (typeof value === "string" ? RFC3339.exec(value) : null);
50
+
51
+ /**
52
+ * Revive a date string the way a host would have revived it — the accepted
53
+ * forms are exactly those `new Date(string)` reads identically on every
54
+ * machine, so declaring `format: "date"` and injecting `Date` values into the
55
+ * render data are two spellings of one document (ADR 0041).
56
+ *
57
+ * The components are range-checked here rather than left to the parser:
58
+ * ECMAScript rolls an out-of-range day over (`2026-02-30` becomes 2 March)
59
+ * where a date that does not exist should revive nothing at all. A leap
60
+ * second (`:60`) is refused on the same ground — RFC 3339 admits it and
61
+ * ECMAScript does not, so it would be a second rollover.
62
+ *
63
+ * @type {(value: any) => Date | undefined}
64
+ */
65
+ export let reviveDate = (value) => {
66
+ let parts = rfc3339(value);
67
+ if (!parts) return undefined;
68
+ let [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts;
69
+ /** @type {[string | undefined, number, number][]} */
70
+ let bounds = [
71
+ [month, 1, 12],
72
+ [day, 1, monthLength(year, month)],
73
+ [hour, 0, 23],
74
+ [minute, 0, 59],
75
+ [second, 0, 59],
76
+ [offsetHour, 0, 23],
77
+ [offsetMinute, 0, 59],
78
+ ];
79
+ let date = bounds.every(within) ? new Date(value) : new Date(NaN);
80
+ return finiteDate(date) ? date : undefined;
81
+ };
27
82
 
28
83
  /**
29
84
  * The typed-cell seam: exactly one value token holding a finite number, a
@@ -31,13 +86,23 @@ let asTyped = (value) => {
31
86
  * including a lone null, matching the display-text treatment everywhere
32
87
  * else — reports `undefined` and joins to text.
33
88
  *
89
+ * Passing the cell's declared `format` kind opts into the one coercion the
90
+ * seam performs: under `"date"`, a string in either accepted RFC 3339 form
91
+ * revives to the `Date` it names. The gate is the declaration, and the kind
92
+ * is `date` alone, because a date is the one type a JSON document cannot
93
+ * carry — a number arrives as a number, so no other kind has anything to
94
+ * revive (ADR 0041). Callers that read no style omit the argument and see
95
+ * the seam exactly as it was.
96
+ *
34
97
  * @param {any[]} tokens A cell's tokens.
98
+ * @param {unknown} [kind] The cell's resolved `format` declaration.
35
99
  * @returns {number | boolean | Date | undefined} The native value, if any.
36
100
  */
37
- export function typed(tokens) {
101
+ export function typed(tokens, kind) {
38
102
  let [token] = tokens;
39
103
  if (tokens.length !== 1 || !("value" in token)) return undefined;
40
- return asTyped(token.value);
104
+ // `??` and not `||`: a boolean cell's own `false` is a typed value.
105
+ return asTyped(token.value) ?? asDeclared(token.value, kind);
41
106
  }
42
107
 
43
108
  // The report's own bands, by the role their items wear (CONTEXT.md, "Band").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",