@casomoltd/tooling 0.21.1 → 0.22.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/README.md CHANGED
@@ -26,6 +26,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
26
26
  | `commit-msg` | Husky commit-msg hook — runs commitlint |
27
27
  | `readability` | Measure reading difficulty of page content |
28
28
  | `screenshot` | Capture a dev server page via Puppeteer |
29
+ | `build-report` | Compile a Typst client report to PDF with the house template |
29
30
 
30
31
  ## Install
31
32
 
@@ -131,6 +132,56 @@ Add these scripts to your `package.json`:
131
132
  Screenshots are saved to `.claude/screenshots/`. Set
132
133
  `SCREENSHOT_URL` to override the default `http://localhost:3000`.
133
134
 
135
+ ## Client reports (Typst)
136
+
137
+ `report/` ships the Casomo house style for client-facing PDF reports —
138
+ a [Typst](https://typst.app) template (`casomo-template.typ`, with the
139
+ brand mark beside it) plus the `build-report` bin that compiles a
140
+ report to PDF:
141
+
142
+ ```bash
143
+ npx build-report path/to/report.typ # → scratch (see below)
144
+ npx build-report report.typ -o public/report.pdf # explicit output path
145
+ npx build-report report.typ --watch # recompile on save
146
+ npx build-report report.typ --open # open the PDF when done
147
+ ```
148
+
149
+ The compiled PDF is a build artefact, so it defaults to the same scratch
150
+ resolution as the agent reports (see *Report output & `SCRATCH_DIR`*
151
+ under Agents): `$SCRATCH_DIR/reports/<name>.pdf`, or
152
+ `<repo-root>/scratch/reports/` when `SCRATCH_DIR` is unset — add
153
+ `scratch/` to the consumer repo's `.gitignore` (this repo does). Pass
154
+ `--out` only when the PDF is a deliverable the repo actually keeps
155
+ (e.g. a site's `public/`).
156
+
157
+ A report imports the template and applies it as a show rule; everything
158
+ after is the body. From a consumer repo the import goes through
159
+ `node_modules` (the bin sets the Typst root to the nearest
160
+ `package.json`/`.git` ancestor so the path resolves):
161
+
162
+ ```typst
163
+ #import "/node_modules/@casomoltd/tooling/report/casomo-template.typ": casomo-report, band
164
+
165
+ #show: casomo-report.with(
166
+ kicker: "Delivery Report & Findings",
167
+ title: "The Report Title",
168
+ subtitle: "One-line summary.", // optional
169
+ )
170
+
171
+ = Executive summary
172
+ …
173
+ ```
174
+
175
+ `report/example.typ` is a lorem-ipsum reference report exercising every
176
+ feature the template styles — compile it to see the house style.
177
+
178
+ Two prerequisites are external to npm (like puppeteer for
179
+ `screenshot`, they are deliberately not dependencies): the **`typst`
180
+ CLI** on PATH (`snap install typst` / `cargo install typst-cli`) and
181
+ the **IBM Plex Sans / IBM Plex Mono** fonts
182
+ ([github.com/IBM/plex](https://github.com/IBM/plex)) — `build-report`
183
+ fails loud on the former and warns on the latter.
184
+
134
185
  ## Quality gates
135
186
 
136
187
  There is no CI — all quality gates run locally via git hooks.
@@ -208,6 +259,7 @@ decision table) that keeps these units composing without overlap.
208
259
  | `/casomoltd:design-pass` | Map → review → refactor a package (drives `design-xray` + `code-review`) |
209
260
  | `/casomoltd:draft-design-spec` | Author a browser-reviewable HTML design spec from a brief and iterate on it before writing code (drives `design-xray`) |
210
261
  | `/casomoltd:page-design` | Structure a content/explainer page for trust — above-the-fold answer, disclosure, palette-only colour (rubric a page-design reviewer preloads) |
262
+ | `/casomoltd:generate-report` | Scaffold a Typst client report from the house template, compile via `build-report`, verify the PDF |
211
263
 
212
264
  Enable the plugin by adding this repo as a marketplace and installing it:
213
265
 
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Compile a Typst client report to PDF with the Casomo house template.
5
+ *
6
+ * Usage:
7
+ * build-report <report.typ> [--out <file.pdf>] [--watch] [--open]
8
+ *
9
+ * Arguments:
10
+ * report.typ The report source. It imports the house template
11
+ * (report/casomo-template.typ in this package).
12
+ *
13
+ * Options:
14
+ * --out, -o <file> Output PDF path. Default follows the README's report
15
+ * output rule: $SCRATCH_DIR/reports/<name>.pdf, or
16
+ * <repo-root>/scratch/reports/ when unset (gitignore
17
+ * scratch/ in the consumer repo).
18
+ * --root <dir> Typst compile root (default: auto-detected, see below)
19
+ * --watch Recompile on save (typst watch)
20
+ * --open Open the PDF after a successful compile (ignored with
21
+ * --watch)
22
+ *
23
+ * Requirements (not npm-managed):
24
+ * - the `typst` CLI on PATH (https://typst.app)
25
+ * - the IBM Plex Sans / IBM Plex Mono fonts installed
26
+ */
27
+
28
+ import {parseArgs} from "node:util";
29
+ import {spawnSync} from "node:child_process";
30
+ import path from "node:path";
31
+ import fs from "node:fs";
32
+ import {openPath} from "./utils.mjs";
33
+
34
+ const {values, positionals} = parseArgs({
35
+ options: {
36
+ out: {type: "string", short: "o"},
37
+ root: {type: "string"},
38
+ watch: {type: "boolean", default: false},
39
+ open: {type: "boolean", default: false},
40
+ },
41
+ allowPositionals: true,
42
+ });
43
+
44
+ function fail(message) {
45
+ console.error(`build-report: ${message}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ if (!positionals[0]) {
50
+ fail("usage: build-report <report.typ> [--out <file.pdf>] [--watch] [--open]");
51
+ }
52
+ const input = path.resolve(positionals[0]);
53
+ if (!fs.existsSync(input)) fail(`no such file: ${input}`);
54
+ if (path.extname(input) !== ".typ") fail(`expected a .typ source, got: ${input}`);
55
+
56
+ // Typst refuses to read files outside its --root, and a report imports the
57
+ // template as "/node_modules/@casomoltd/tooling/report/casomo-template.typ".
58
+ // Prefer the ancestor whose node_modules actually holds this package — in a
59
+ // hoisted/workspace layout that is higher than the report's own package.json —
60
+ // falling back to the nearest package.json/.git ancestor (this repo itself,
61
+ // where the example imports the template relative). No marker at all is an
62
+ // error: compiling on with a wrong root would only surface later as a
63
+ // confusing Typst import failure.
64
+ function findRoot(from) {
65
+ const packageDir = path.join("node_modules", "@casomoltd", "tooling", "report");
66
+ let marker = null;
67
+ let dir = path.dirname(from);
68
+ for (;;) {
69
+ if (fs.existsSync(path.join(dir, packageDir))) return dir;
70
+ if (
71
+ marker === null &&
72
+ (fs.existsSync(path.join(dir, "package.json")) ||
73
+ fs.existsSync(path.join(dir, ".git")))
74
+ ) {
75
+ marker = dir;
76
+ }
77
+ const parent = path.dirname(dir);
78
+ if (parent === dir) break;
79
+ dir = parent;
80
+ }
81
+ if (marker) return marker;
82
+ fail("no repo root found above the input (package.json/.git) — pass --root <dir>");
83
+ }
84
+ const root = values.root ? path.resolve(values.root) : findRoot(input);
85
+
86
+ // typst is an external native binary (like puppeteer for screenshot, it is
87
+ // deliberately not an npm dependency). One probe does double duty: ENOENT
88
+ // means typst is missing; otherwise the output feeds the font check. Missing
89
+ // fonts don't fail a Typst compile — it silently falls back and the PDF comes
90
+ // out off-brand — so warn loud up front.
91
+ const fonts = spawnSync("typst", ["fonts"], {encoding: "utf8"});
92
+ if (fonts.error) {
93
+ fail(
94
+ "the `typst` CLI is not on PATH — install it first " +
95
+ "(https://typst.app, e.g. `snap install typst` or `cargo install typst-cli`).",
96
+ );
97
+ }
98
+ if (fonts.status === 0) {
99
+ const families = new Set(fonts.stdout.split(/\r?\n/));
100
+ // Keep in sync with the faces report/tokens.typ names.
101
+ for (const family of ["IBM Plex Sans", "IBM Plex Mono"]) {
102
+ if (!families.has(family)) {
103
+ console.warn(
104
+ `build-report: font "${family}" not found — the PDF will use a ` +
105
+ "fallback font. Install IBM Plex: https://github.com/IBM/plex",
106
+ );
107
+ }
108
+ }
109
+ } else {
110
+ console.warn("build-report: could not verify fonts (`typst fonts` failed)");
111
+ }
112
+
113
+ // A compiled PDF is a throwaway build artefact, not a source — default it to
114
+ // the shared scratch pool, same resolution order as the agent reports (README
115
+ // "Report output & SCRATCH_DIR"): $SCRATCH_DIR/reports/, else a repo-local
116
+ // scratch/reports/. --out opts out for deliverables a repo serves.
117
+ const output = values.out
118
+ ? path.resolve(values.out)
119
+ : path.join(
120
+ path.resolve(process.env.SCRATCH_DIR || path.join(root, "scratch")),
121
+ "reports",
122
+ path.basename(input, ".typ") + ".pdf",
123
+ );
124
+ fs.mkdirSync(path.dirname(output), {recursive: true});
125
+
126
+ const mode = values.watch ? "watch" : "compile";
127
+ const result = spawnSync("typst", [mode, "--root", root, input, output], {
128
+ stdio: "inherit",
129
+ });
130
+ if (result.status !== 0) process.exit(result.status ?? 1);
131
+
132
+ // typst watch narrates its own recompiles and only returns on interrupt, so
133
+ // the post-compile tail is for single compiles only.
134
+ if (!values.watch) {
135
+ console.log(`Report compiled: ${output}`);
136
+ if (values.open) openPath(output);
137
+ }
@@ -22,9 +22,9 @@
22
22
  * 0 HTML written (and opened unless --no-open)
23
23
  * 1 Bad arguments or the input file is missing
24
24
  */
25
- import {spawn} from "node:child_process";
26
25
  import {existsSync, readFileSync, writeFileSync} from "node:fs";
27
26
  import {resolve} from "node:path";
27
+ import {openPath} from "./utils.mjs";
28
28
 
29
29
  const args = process.argv.slice(2);
30
30
  const noOpen = args.includes("--no-open");
@@ -110,9 +110,5 @@ const url = `file://${resolve(htmlPath)}`;
110
110
  console.log(url);
111
111
 
112
112
  if (!noOpen) {
113
- // text/html must be associated with a browser, not a mail client; if the
114
- // opener mis-routes on Linux, run e.g.
115
- // xdg-mime default firefox_firefox.desktop text/html
116
- const opener = process.platform === "darwin" ? "open" : "xdg-open";
117
- spawn(opener, [url], {detached: true, stdio: "ignore"}).unref();
113
+ openPath(url);
118
114
  }
package/bin/utils.mjs CHANGED
@@ -2,10 +2,23 @@
2
2
  * Shared helpers for CLI bin scripts.
3
3
  */
4
4
 
5
+ import {spawn} from "node:child_process";
5
6
  import path from "node:path";
6
7
  import fs from "node:fs";
7
8
  import {pathToFileURL} from "node:url";
8
9
 
10
+ /**
11
+ * Open a file or URL with the platform opener, detached so the CLI can exit.
12
+ *
13
+ * text/html must be associated with a browser, not a mail client; if the
14
+ * opener mis-routes on Linux, run e.g.
15
+ * xdg-mime default firefox_firefox.desktop text/html
16
+ */
17
+ export function openPath(target) {
18
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
19
+ spawn(opener, [target], {detached: true, stdio: "ignore"}).unref();
20
+ }
21
+
9
22
  /**
10
23
  * Load an optional per-repo config file from cwd.
11
24
  *
@@ -17,6 +17,7 @@ const ALLOWED_PREFIXES = [
17
17
  "knip/",
18
18
  "jscpd/",
19
19
  "readability/",
20
+ "report/",
20
21
  ];
21
22
  const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
22
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,7 @@
15
15
  "knip/",
16
16
  "jscpd/",
17
17
  "readability/",
18
+ "report/",
18
19
  "README.md"
19
20
  ],
20
21
  "scripts": {
@@ -33,6 +34,7 @@
33
34
  "./readability": "./readability/index.mjs"
34
35
  },
35
36
  "bin": {
37
+ "build-report": "./bin/build-report.mjs",
36
38
  "check-tags": "./bin/check-tags.sh",
37
39
  "check-version": "./bin/check-version.sh",
38
40
  "commit-msg": "./bin/commit-msg.sh",
@@ -0,0 +1,14 @@
1
+ <svg viewBox="0 0 135.467 135.467" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ fill="none"
4
+ stroke="#fff"
5
+ stroke-width="10.583"
6
+ stroke-linecap="round"
7
+ stroke-linejoin="round"
8
+ d="M 27.752,77.13
9
+ 118.744,77.565
10
+ 66.064,36.496
11
+ 67.733,58.482
12
+ 27.68,58.337 Z"
13
+ />
14
+ </svg>
@@ -0,0 +1,66 @@
1
+ // casomo-template.typ — shared house style for Casomo client reports.
2
+ //
3
+ // The single import point. Internals live in the sibling component files —
4
+ // tokens.typ (fonts, rule weights, legal identity) · styles.typ (document
5
+ // type rules) · table.typ (flat tables) · components.typ (band, callout,
6
+ // keyfig, num) · mark.typ (brand mark) · footer.typ · cover.typ — and this
7
+ // file re-exports the author-facing API, so a report needs only:
8
+ //
9
+ // #import "casomo-template.typ": casomo-report, band, callout, keyfig, num
10
+ // #show: casomo-report.with(
11
+ // kicker: "Delivery Report & Findings",
12
+ // title: "The Report Title",
13
+ // subtitle: "One-line summary of what was delivered.", // optional
14
+ // )
15
+ //
16
+ // = Executive summary
17
+ // …
18
+ //
19
+ // Components follow the house minimal direction — flat, hairline dividers,
20
+ // one separation device per element — translated to the report's own
21
+ // identity: monochrome, IBM Plex. Conventions transfer; the site's palette
22
+ // and web faces do not.
23
+ //
24
+ // Asset paths resolve relative to the file using them, so the report/ files
25
+ // stay together as one directory. Importers only need this directory inside
26
+ // the compile root (`typst compile --root …` — the `build-report` bin sets
27
+ // this). Compile with `build-report <report.typ>`; needs the `typst` CLI
28
+ // plus the IBM Plex Sans / IBM Plex Mono fonts installed.
29
+
30
+ #import "components.typ": band, callout, keyfig, num
31
+ #import "styles.typ": document-styles
32
+ #import "table.typ": table-styles
33
+ #import "footer.typ": report-footer
34
+ #import "cover.typ": cover-page
35
+
36
+ #let casomo-report(
37
+ kicker: none,
38
+ title: none,
39
+ subtitle: none,
40
+ author: "David Mohamad",
41
+ email: "info@casomoltd.com",
42
+ body,
43
+ ) = {
44
+ // Copyright year tracks the compile date — a report is (re)issued when it is
45
+ // (re)compiled, so the year is never stale.
46
+ let year = datetime.today().display("[year]")
47
+
48
+ show: document-styles
49
+ show: table-styles
50
+
51
+ set page(
52
+ paper: "a4",
53
+ margin: (top: 2.5cm, bottom: 2.5cm, left: 2.5cm, right: 2.5cm),
54
+ header: none,
55
+ footer: report-footer(year),
56
+ )
57
+
58
+ cover-page(kicker, title, subtitle, author, email, year)
59
+
60
+ // --- Table of contents ---
61
+ page[
62
+ #outline(title: "Contents", indent: auto, depth: 2)
63
+ ]
64
+
65
+ body
66
+ }
@@ -0,0 +1,56 @@
1
+ // components.typ — the author-facing body components: band dividers, mono
2
+ // figures, callouts, and key figures. (How a band heading *renders* lives
3
+ // with the other heading rules in styles.typ; this file owns the semantics.)
4
+ #import "tokens.typ": mono-font, intent-bar
5
+
6
+ // Band dividers — unnumbered group headers that organise the contents and the
7
+ // body into sections (e.g. Delivery / Advisories / Decisions / Hand-over)
8
+ // without numbers and without disturbing the 1..N section numbering. Optional:
9
+ // reports that don't want grouped sections simply never call band().
10
+ #let band(title, note: none) = {
11
+ heading(level: 1, numbering: none, outlined: true)[#title]
12
+ if note != none {
13
+ text(size: 9.5pt, style: "italic", fill: luma(110))[#note]
14
+ v(0.1em)
15
+ }
16
+ }
17
+
18
+ // Figures set in IBM Plex Mono so numerals read as data and align
19
+ // digit-for-digit down a column; labels and prose stay in Plex Sans. Sized
20
+ // down slightly to match the sans x-height. Use in numeric table cells and
21
+ // for inline figures: num[£29,970].
22
+ #let num(value) = text(font: mono-font, size: 0.92em, value)
23
+
24
+ // A note the reader must not miss — a thick left bar carries the emphasis,
25
+ // no box, no fill. `label` bolds the intent: callout(label: "Warning:")[…].
26
+ #let callout(body, label: none) = block(
27
+ above: 1.3em,
28
+ below: 1.3em,
29
+ width: 100%,
30
+ stroke: (left: intent-bar),
31
+ inset: (left: 12pt, top: 3pt, bottom: 3pt),
32
+ {
33
+ set text(size: 10pt)
34
+ set par(justify: false)
35
+ if label != none {
36
+ strong(label)
37
+ h(0.4em)
38
+ }
39
+ body
40
+ },
41
+ )
42
+
43
+ // A lifted headline metric — big mono figure over a muted caption, carried
44
+ // by the same left bar: keyfig("£30,639", [Band 5, top of scale]).
45
+ #let keyfig(value, caption) = block(
46
+ above: 1.4em,
47
+ below: 1.4em,
48
+ stroke: (left: intent-bar),
49
+ inset: (left: 12pt, top: 2pt, bottom: 2pt),
50
+ {
51
+ text(font: mono-font, weight: 700, size: 20pt, value)
52
+ linebreak()
53
+ v(1pt)
54
+ text(size: 9.5pt, fill: luma(110), caption)
55
+ },
56
+ )
@@ -0,0 +1,38 @@
1
+ // cover.typ — the cover page: kicker over the title, the author block, and
2
+ // the brand badge with the legal line. Suppresses the running footer.
3
+ #import "tokens.typ": sans-font, company-name, company-registration
4
+ #import "mark.typ": casomo-mark
5
+
6
+ #let cover-page(kicker, title, subtitle, author, email, year) = page(footer: [])[
7
+ #set text(font: sans-font)
8
+
9
+ #v(1fr)
10
+
11
+ #text(size: 12pt, fill: luma(100))[#kicker]
12
+ #v(8pt)
13
+ #text(size: 28pt, weight: "bold")[#title]
14
+ #if subtitle != none {
15
+ v(10pt)
16
+ text(size: 12pt, fill: luma(100))[#subtitle]
17
+ }
18
+
19
+ #v(24pt)
20
+ #text(size: 11pt, fill: luma(80))[
21
+ Author: #author \
22
+ Email: #link("mailto:" + email)[#email] \
23
+ Prepared on #datetime.today().display("[day] [month repr:long] [year]")
24
+ ]
25
+
26
+ #v(1fr)
27
+
28
+ #grid(
29
+ columns: (28pt, 1fr),
30
+ column-gutter: 12pt,
31
+ align: (horizon, horizon),
32
+ casomo-mark(size: 28pt, arrow: 20pt, radius: 5pt),
33
+ text(size: 10pt)[
34
+ #sym.copyright #year #company-name \
35
+ #company-registration
36
+ ],
37
+ )
38
+ ]
@@ -0,0 +1,88 @@
1
+ // example.typ — lorem-ipsum reference report for the Casomo house template.
2
+ //
3
+ // Exercises every structural feature the template styles (cover, contents,
4
+ // band dividers, numbered headings to depth 3, lists, flat tables with mono
5
+ // numerals, callouts, key figures, code, figures, links, cross-references)
6
+ // so a single compile verifies the whole style.
7
+ //
8
+ // build-report report/example.typ
9
+
10
+ #import "casomo-template.typ": casomo-report, band, callout, keyfig, num
11
+
12
+ #show: casomo-report.with(
13
+ kicker: "Example Report — Template Reference",
14
+ title: "Lorem Ipsum Dolor Sit Amet",
15
+ subtitle: "A placeholder report exercising every feature of the Casomo house style.",
16
+ )
17
+
18
+ = Executive summary
19
+
20
+ #lorem(60)
21
+
22
+ - *First finding* — #lorem(18)
23
+ - *Second finding* — #lorem(22)
24
+ - *Third finding* — #lorem(15)
25
+
26
+ #keyfig("£30,639", [Lorem ipsum dolor sit amet — the headline metric, lifted.])
27
+
28
+ #callout(label: "Note:")[#lorem(28)]
29
+
30
+ #band("Part One", note: "Band dividers group sections without disturbing numbering.")
31
+
32
+ = Background
33
+
34
+ #lorem(70)
35
+
36
+ == Prior work
37
+
38
+ #lorem(45) See @approach for how this shaped the approach, or the
39
+ #link("https://www.casomoltd.com")[Casomo website] for context.
40
+
41
+ == Constraints
42
+
43
+ + #lorem(14)
44
+ + #lorem(16)
45
+ + #lorem(12)
46
+
47
+ = Approach <approach>
48
+
49
+ #lorem(50)
50
+
51
+ == Method detail
52
+
53
+ #lorem(30)
54
+
55
+ ```ts
56
+ export function loremIpsum(dolor: string): string {
57
+ return `sit amet, ${dolor} adipiscing elit`;
58
+ }
59
+ ```
60
+
61
+ #band("Part Two")
62
+
63
+ = Results
64
+
65
+ #lorem(40)
66
+
67
+ #figure(
68
+ table(
69
+ columns: (auto, 1fr, auto, auto),
70
+ align: (left, left, right, right),
71
+ table.header([Item], [Description], [Annual], [Monthly]),
72
+ [Alpha], [#lorem(8)], num[£29,970], num[£2,497.50],
73
+ [Beta], [#lorem(9)], num[£31,876], num[£2,656.33],
74
+ [Gamma], [#lorem(7)], num[£36,483], num[£3,040.25],
75
+ ),
76
+ caption: [#lorem(16)],
77
+ ) <results-table>
78
+
79
+ #lorem(35) The headline figures sit in @results-table.
80
+
81
+ #callout(label: "Warning:")[#lorem(20)]
82
+
83
+ = Recommendations
84
+
85
+ #lorem(30)
86
+
87
+ - #lorem(20)
88
+ - #lorem(17)
@@ -0,0 +1,15 @@
1
+ // footer.typ — the running page footer: brand mark, legal line, page X / N.
2
+ #import "tokens.typ": sans-font, company-name, company-registration
3
+ #import "mark.typ": casomo-mark
4
+
5
+ #let report-footer(year) = context {
6
+ set text(size: 9pt, font: sans-font)
7
+ grid(
8
+ columns: (20pt, 1fr, auto),
9
+ column-gutter: 8pt,
10
+ align: (horizon, horizon, horizon),
11
+ casomo-mark(),
12
+ [#sym.copyright #year #company-name, #company-registration],
13
+ [#counter(page).display() / #counter(page).final().first()],
14
+ )
15
+ }
@@ -0,0 +1,13 @@
1
+ // mark.typ — the brand mark: the white arrow clipped in a black rounded
2
+ // square. One definition renders both placements (footer small, cover large).
3
+ // The SVG path resolves relative to this file, so both stay in report/.
4
+ #let casomo-mark(size: 20pt, arrow: 14pt, radius: 4pt) = box(
5
+ width: size,
6
+ height: size,
7
+ radius: radius,
8
+ fill: black,
9
+ clip: true,
10
+ align(center + horizon)[
11
+ #image("casomo-arrow-white.svg", width: arrow)
12
+ ],
13
+ )
@@ -0,0 +1,56 @@
1
+ // styles.typ — the document-wide type rules: base faces, paragraph rhythm,
2
+ // the heading scale (including how a band() divider renders), list spacing,
3
+ // link/ref colour, and figure captions. Table rules live in table.typ.
4
+ #import "tokens.typ": sans-font, mono-font
5
+
6
+ #let document-styles(body) = {
7
+ // Faces — tokens.typ owns the names; build-report.mjs preflights them.
8
+ set text(font: sans-font, size: 11.5pt)
9
+ set par(justify: true, leading: 0.95em)
10
+ set heading(numbering: "1.1")
11
+ show raw: set text(font: mono-font)
12
+
13
+ // Lists: a broader marker and a clear gap between items, so multi-line
14
+ // bullet items read as distinct points rather than blending into one block.
15
+ // The inter-item spacing is deliberately wider than the in-item leading.
16
+ set list(marker: text(size: 1.15em, fill: luma(55))[•], spacing: 1.15em)
17
+ set enum(spacing: 1.15em)
18
+
19
+ // Numbered headings get a size by level; an unnumbered level-1 heading is a
20
+ // band() group divider (components.typ), rendered as a smallcaps label with
21
+ // a rule across the page. Reports that never call band() only ever hit the
22
+ // numbered branch.
23
+ show heading: it => {
24
+ if it.numbering == none {
25
+ block(above: 1.6em, below: 0.7em, width: 100%, breakable: false)[
26
+ #set text(size: 14pt, weight: "bold", fill: luma(70))
27
+ #grid(
28
+ columns: (auto, 1fr),
29
+ align: (left + horizon, horizon),
30
+ column-gutter: 10pt,
31
+ smallcaps(it.body),
32
+ line(length: 100%, stroke: 0.6pt + luma(180)),
33
+ )
34
+ ]
35
+ } else {
36
+ let size = if it.level == 1 { 16pt } else if it.level == 2 { 13pt } else { 11pt }
37
+ set text(size: size)
38
+ it
39
+ }
40
+ }
41
+ show outline.entry.where(level: 1): it => {
42
+ if it.element.numbering == none {
43
+ v(11pt, weak: true)
44
+ strong(smallcaps(it.element.body))
45
+ } else {
46
+ it
47
+ }
48
+ }
49
+ show link: set text(fill: rgb("#0000cc"))
50
+ show ref: set text(fill: rgb("#0000cc"))
51
+ show figure.caption: set text(size: 9.5pt, style: "italic", fill: luma(110))
52
+ show figure.caption: set par(justify: true)
53
+ show figure.caption: set align(left)
54
+
55
+ body
56
+ }
@@ -0,0 +1,17 @@
1
+ // table.typ — flat table rules: a strong rule under the header row, hairlines
2
+ // between body rows, no vertical strokes, no outer box, no zebra, no fills.
3
+ // The first row is styled as the header, so open every table with
4
+ // table.header(). Right-align numeric columns via the table's `align` and set
5
+ // the cells with num().
6
+ #import "tokens.typ": strong-rule, hairline
7
+
8
+ #let table-styles(body) = {
9
+ set table(
10
+ stroke: (x, y) => if y == 0 { (bottom: strong-rule) } else { (bottom: hairline) },
11
+ inset: (x: 10pt, y: 7pt),
12
+ )
13
+ show table.cell.where(y: 0): set text(weight: 600, size: 10pt)
14
+ // Body-par justification stretches badly in narrow cells — tables rag right.
15
+ show table.cell: set par(justify: false)
16
+ body
17
+ }
@@ -0,0 +1,17 @@
1
+ // tokens.typ — the shared constants every report component reads.
2
+
3
+ // Faces — checked by bin/build-report.mjs's font preflight; keep in sync.
4
+ #let sans-font = "IBM Plex Sans"
5
+ #let mono-font = "IBM Plex Mono"
6
+
7
+ // The whole style runs on three separation weights: a strong rule (table
8
+ // headers), a hairline (row separators), and an intent bar (callouts / key
9
+ // figures). Anything heavier — boxes, fills, zebra striping — is deliberately
10
+ // absent: the house minimal direction, one separation device per element.
11
+ #let strong-rule = 1pt + black
12
+ #let hairline = 0.5pt + luma(200)
13
+ #let intent-bar = 2.5pt + black
14
+
15
+ // The legal identity, printed by both the footer and the cover.
16
+ #let company-name = "Casomo Ltd"
17
+ #let company-registration = "Registered in the UK, 15030496"