@liggi/figkit 0.2.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/GUIDE.md CHANGED
@@ -5,7 +5,7 @@ work out coordinates or text widths: boxes size to their text, arrows route them
5
5
  measure the real result.
6
6
 
7
7
  ```
8
- npx figkit figure.html [--out figure.png]
8
+ npx @liggi/figkit figure.html [--out figure.png]
9
9
  ```
10
10
 
11
11
  The file is just the `<main>` below; no doctype or `<head>` needed. Without `--out` the PNG goes to a temporary folder; the command prints where.
@@ -28,6 +28,8 @@ the images and look, then embed the PNG: `![what it shows](/abs/path.png)`.
28
28
  A question about structure ("what are the entities?", "where does X live?", "how do these relate?", "what loads
29
29
  what?", "walk me through it") is answered with a picture first, not with numbered steps.
30
30
 
31
+ - "What happens when…?", "walk me through a run" and "who calls whom?" are a `sequence` spec (below): the steps in
32
+ order between the parts, not a numbered list or a table of steps.
31
33
  - "What are the entities and how do they relate?" is an `entities` spec (below), not boxes and arrows in HTML. It
32
34
  draws references: which record holds a pointer to which. The order work happens in (approve, then label, then
33
35
  train) is not a reference; it goes in prose, a list or a separate flow. A record that creates others still points
@@ -46,12 +48,12 @@ what?", "walk me through it") is answered with a picture first, not with numbere
46
48
 
47
49
  ## Charts and entities from a spec
48
50
 
49
- When the whole figure is one chart or one entities picture of a type below, write a JSON spec instead of HTML. You give the data; figkit
51
+ When the whole figure is one chart, one entities picture or one sequence of a type below, write a JSON spec instead of HTML. You give the data; figkit
50
52
  does the layout, colours and labels. Use HTML (the rest of this guide) for anything else, including a chart that sits
51
53
  beside boxes or arrows.
52
54
 
53
55
  ```
54
- npx figkit chart.json [--out chart.png]
56
+ npx @liggi/figkit chart.json [--out chart.png]
55
57
  ```
56
58
 
57
59
  A spec that breaks a rule is refused with one line per problem; fix them and run it again. Then look at the images
@@ -105,6 +107,128 @@ mark's `x` must be one of them. The y axis starts at zero unless `"zero": false`
105
107
  "mark": { "x": "2026-10-14", "label": "212, launch day" } }
106
108
  ```
107
109
 
110
+ `groups`: a few things measured for each of 2 to 6 groups, 2 to 4 series, one bar per series in each group, all
111
+ from zero. Use it when the point is a difference that shows in some groups and not others (before and after, one
112
+ segment against another); for one number per thing, use `bars`. Each group gets a heading and each bar its series
113
+ name and number, so there is no legend and no axis. `accent` is required: `{ "series" }` colours that series in every
114
+ group, `{ "series", "group" }` colours one bar; `"tone": "bad"` or `"good"` when the bar is a result.
115
+
116
+ ```json
117
+ { "type": "groups", "title": "Only the new carrier got slower in December",
118
+ "note": "Median days from dispatch to doorstep", "unit": " days",
119
+ "series": ["Old carrier", "New carrier"],
120
+ "groups": [ { "label": "November", "values": [2.2, 2.1] }, { "label": "December", "values": [2.4, 3.6] } ],
121
+ "accent": { "series": "New carrier", "group": "December", "tone": "bad" } }
122
+ ```
123
+
124
+ `lines`: 2 or 3 lines on one axis, each named at its right end in its own colour. `accent` (required) names the
125
+ line the title is about; the others are grey. `mark` labels one point on the accent line. `xTicks` sets the axis
126
+ labels, e.g. hours as 12am, 6am, 12pm, 6pm, rather than 0 to 23. More than 3 lines is `panels`.
127
+
128
+ ```json
129
+ { "type": "lines", "title": "Weekday orders peak at noon", "note": "Orders per hour",
130
+ "series": [ { "label": "Weekdays", "points": [ { "x": 0, "y": 3 }, { "x": 12, "y": 30 }, { "x": 23, "y": 4 } ] },
131
+ { "label": "Weekends", "points": [ { "x": 0, "y": 4 }, { "x": 12, "y": 22 }, { "x": 23, "y": 6 } ] } ],
132
+ "accent": "Weekdays", "mark": { "x": 12, "label": "30 at noon" },
133
+ "xTicks": [ { "x": 0, "label": "12am" }, { "x": 12, "label": "12pm" } ] }
134
+ ```
135
+
136
+ `panels`: small multiples, the same `groups` or `lines` chart drawn for 2 to 4 parts side by side, on one shared
137
+ scale so the panels compare; stacked on a phone. `chart` says which. For `groups`, `series` and `accent` are given once
138
+ at the top and each panel has its `groups`; for `lines`, `accent` is the line's label and every panel has the same
139
+ lines in the same order. Use it when one chart would need more than 3 lines or 4 series, or the parts measure
140
+ different things on the same scale.
141
+
142
+ ```json
143
+ { "type": "panels", "chart": "groups", "title": "The cheaper plan answers almost as fast as the premium one",
144
+ "note": "Share of tickets answered within an hour", "unit": "%", "max": 100,
145
+ "series": ["Basic", "Standard", "Premium"], "accent": { "series": "Standard" },
146
+ "panels": [ { "label": "Email", "groups": [ { "label": "Weekdays", "values": [61, 84, 89] } ] },
147
+ { "label": "Chat", "groups": [ { "label": "Weekdays", "values": [72, 91, 94] } ] } ] }
148
+ ```
149
+
150
+ `heatmap`: a grid of values over two dimensions (day by hour, team by week), coloured from pale to dark blue: darker
151
+ is always more. Up to 12 `rows` and 31 `columns`; `values` is one array per row. `note` (required) says what the
152
+ colour counts, and the scale is drawn above the grid. Here the colour is the measure, so the one-accent rule moves:
153
+ `mark` outlines the one cell to notice in the accent colour and names it beside the scale. Cells show their numbers
154
+ when there is room. Columns label themselves at even steps, so give hours as "12am", "1am", … and they read 12am,
155
+ 6am, 12pm, 6pm.
156
+
157
+ ```json
158
+ { "type": "heatmap", "title": "The help desk is busiest midweek mornings", "note": "Tickets opened per hour",
159
+ "rows": ["Mon", "Tue", "Wed"], "columns": ["9am", "10am", "11am", "12pm"],
160
+ "values": [ [22, 25, 27, 28], [23, 26, 29, 30], [24, 27, 30, 32] ],
161
+ "mark": { "row": "Wed", "column": "12pm", "label": "Busiest: Wed noon" } }
162
+ ```
163
+
164
+ ### How sure a number is
165
+
166
+ Most numbers are counted from a sample: 71% of 75 days is not 71% of 7,500. When the reader will compare such
167
+ numbers, draw how sure each one is.
168
+
169
+ Ranges on `bars`, `series` and `lines`: give each bar or point a `low` and a `high` around its value, and say once
170
+ in `range` what they are, in the reader's words ("likely range (95%)", "lowest to highest week"); it is printed under
171
+ the reading line beside the mark it describes. A bar steps back to a pale fill, its range is drawn across its end in
172
+ the bar's colour and the range's ends are printed after the value; a series or line gets a band. Accent the bar whose
173
+ range the title is about. Give every item a
174
+ range or none, since one without reads as certain. figkit refuses a range that does not contain its value, a range
175
+ with no `range` text, and a bar range below zero. `n` on a bar prints its sample size under its label, as "14 runs" when `each` names
176
+ the things counted (`"each": "runs"`), else "n = 14"; give every bar one or none. `ref` on a `series`,
177
+ `{ "y": 67, "label": "Jul to mid-Oct: 67%" }`, draws a dashed level to compare the line with, named in the key line. A percentage of fewer than 10 things is refused: show it as a count (`"show": "3 of 6"`) or draw `units`.
178
+
179
+ ```json
180
+ { "type": "bars", "title": "The new reply check catches more, but the ranges still overlap",
181
+ "note": "Share of unsafe replies caught, on the same test set", "unit": "%", "max": 100, "range": "likely range (95%)", "each": "replies",
182
+ "bars": [ { "label": "Current check", "value": 64, "low": 53, "high": 74, "n": 72 },
183
+ { "label": "New check", "value": 78, "low": 67, "high": 86, "n": 72, "tone": "accent" } ] }
184
+ ```
185
+
186
+ The title claims only what the ranges allow: when they overlap, "catches more" needs a "but".
187
+
188
+ `dots`: every value as a dot, for a sample small enough to look at one by one (up to 300 a group, 600 in all), in 1
189
+ to 6 groups on one axis. Use it instead of a bar of the average when the spread or a few odd values are the point.
190
+ Dots that would overlap stack upward and downward, so none hides another, and each group's count is printed. `note`
191
+ (required) says what one dot is. `each` names what a dot is, so counts read "50 runs". `summary` draws the `"median"` or `"mean"` as a line across each group, labelled;
192
+ `mark` rings one value and labels it; `zero` starts the axis at zero; `tone` on a group colours its dots.
193
+
194
+ ```json
195
+ { "type": "dots", "title": "Most builds finish in four minutes; a few take over ten",
196
+ "note": "One dot per build: minutes from push to result", "unit": " min", "summary": "median", "zero": true, "each": "builds",
197
+ "groups": [ { "label": "Main branch", "values": [3.8, 4.1, 3.6, 11.8, 4.0, 12.6, 4.2] },
198
+ { "label": "Pull requests", "values": [4.9, 5.2, 4.6, 5.8, 5.1], "tone": "accent" } ],
199
+ "mark": { "group": "Main branch", "value": 12.6, "label": "12.6 min, cache miss" } }
200
+ ```
201
+
202
+ `scatter`: two measures against each other, one dot per thing measured, up to 1000. It is its own type, not a mode
203
+ of `dots`, because it takes pairs and answers a different question: whether one measure moves with the other. `x`
204
+ and `y` each need a `label` saying what they measure, and take `unit`, `"log": true` for measures that span several
205
+ powers of ten (every value above zero; the axis runs to whole powers of ten), `zero`, and `time` (`"seconds"`,
206
+ `"minutes"`, `"hours"` or `"days"`, the unit the values are in) so ticks read 1 min, 1 h, 1 day, 1 wk.
207
+ `"medians": true` draws the median y of each band of x (each power of ten on a log axis, else fifths) in the accent
208
+ colour, with the middle half of the band's dots shaded behind it, for bands with 5 or more dots: the relation, or its
209
+ absence, drawn without fitting a model. `label` names a point beside it, at most 5; `tone` colours it.
210
+ figkit draws no fitted trend line: it claims more than dots do. Say the relation in the title only when the dots or
211
+ the medians show it, and "barely" when they mostly do not.
212
+
213
+ ```json
214
+ { "type": "scatter", "title": "Bigger pull requests wait longer for review",
215
+ "note": "One dot per merged pull request",
216
+ "x": { "label": "Lines changed", "log": true }, "y": { "label": "Time to first review", "time": "hours", "zero": true },
217
+ "points": [ { "x": 12, "y": 1.5 }, { "x": 120, "y": 5.5 }, { "x": 540, "y": 22, "label": "Search rewrite", "tone": "accent" } ] }
218
+ ```
219
+
220
+ `units`: a small count made concrete, "11 of 29" as 29 squares with 11 filled. `total` is at most 100: beyond that
221
+ the squares cannot be counted by eye, and a `split` bar of the shares reads better. Squares sit ten to a row and fill
222
+ column by column, so each part is one block from the left. The blocks show counts, not order: if when things happened
223
+ matters, draw it over time instead. Up to 3 `parts` fill squares in order (the first in the accent colour
224
+ unless it has a `tone`); `rest` (required) names the empty squares, so at least one is left. Counts are whole things.
225
+
226
+ ```json
227
+ { "type": "units", "title": "11 of the 29 flagged days were real problems",
228
+ "note": "One square per day the monitor flagged in October", "total": 29,
229
+ "parts": [ { "label": "real problems", "count": 11 } ], "rest": "false alarms" }
230
+ ```
231
+
108
232
  `entities`: what the things are and which points at which. 2 to 5 `records`, each with an `id` you use in `refs`,
109
233
  a `name` in the reader's words, and up to 4 `fields` that say what it holds (put a table name here only if the reader
110
234
  needs it). Each of up to 8 `refs` is a pointer the `from` record holds to the `to` record: `count` is how many `from`
@@ -129,9 +253,40 @@ in plain words, not in-house terms or button labels.
129
253
 
130
254
  More than 5 records is two figures: the core records, then one corner of it with the core record it hangs off.
131
255
 
132
- Flows (boxes and arrows) have no spec: write them in HTML, below.
256
+ `sequence`: who calls whom, in order. Use it when the question is "what happens when…", "walk me through a run",
257
+ "who starts what": the order is the point and more than one part takes part. A table of steps (step, who starts it,
258
+ who is involved) is this picture without the shape; draw it instead. A flow of boxes and arrows shows what connects
259
+ to what, not when; use it for structure. 2 to 5 `participants`, left to right in the order the reader meets them, each
260
+ an `id`, a `name` and an optional `kind` (where it runs: "GitHub Actions", "Cloud Run job"). 2 to 12 `messages` in
261
+ the order they happen, each from one participant to another with a `label` saying what is asked for or sent. figkit
262
+ numbers the steps, so your prose can say "step 4". `"reply": true` is the answer to an earlier call, drawn dashed;
263
+ leave replies out when nothing comes back that the reader needs. A message from a participant to itself is work it
264
+ does on its own. `note` adds one short line under a step: a condition ("only if the tests pass"), a wait ("up to an
265
+ hour"). `repeats` frames steps `from` to `to` (numbered from 1) that run more than once, with a `label` such as "For
266
+ each model". Accent one participant, or the steps the title is about, not both. The title claims only what the
267
+ steps show.
268
+
269
+ ```json
270
+ { "type": "sequence", "title": "A failed card charge is retried before the shopper hears anything",
271
+ "participants": [ { "id": "app", "name": "Shop app", "kind": "browser" }, { "id": "api", "name": "Checkout API" },
272
+ { "id": "pay", "name": "Card processor" } ],
273
+ "messages": [ { "from": "app", "to": "api", "label": "Place order" },
274
+ { "from": "api", "to": "pay", "label": "Charge the card", "tone": "accent" },
275
+ { "from": "pay", "to": "api", "label": "Approved or declined", "reply": true },
276
+ { "from": "api", "to": "app", "label": "Order number", "reply": true } ],
277
+ "repeats": [ { "from": 2, "to": 3, "label": "Up to 3 tries" } ] }
278
+ ```
279
+
280
+ More than 5 participants or 12 steps is two figures: the whole run with minor parts merged into one participant, then
281
+ the stage that needs detail. Leave out steps that only log or record what happened unless the question is about them.
282
+
283
+ Flows (boxes and arrows) have no spec: write them in HTML, below. A flow says what connects to what; when the order
284
+ things happen in is the point, use `sequence`.
133
285
 
134
286
  - `title` (required) is the point, 14 words at most. `note` is the reading line under it; `source` a footer.
287
+ - `"style": "report"` draws a light chart in one sans font, for a chart that sits in a page: a research report, a doc.
288
+ It also draws a phone layout, `chart.phone.png`. Leave it out for the default dark figure for a dark chat.
289
+ - Keep `note` to what is measured, and "lower is better" where it applies; dates and method go in `source`.
135
290
  - `unit` is printed straight after each number. `show` on an item replaces its printed value, e.g. `"11 of 29"`.
136
291
  - `tone` is `accent` for what to notice, `good` or `bad` when it is a result; at most two tones in a chart. Leave the rest grey.
137
292
  - `bars` takes `max` to fix the end of the axis (e.g. 100 for percentages) and a short `note` per bar.
@@ -141,7 +296,7 @@ Flows (boxes and arrows) have no spec: write them in HTML, below.
141
296
  ## The page
142
297
 
143
298
  ```html
144
- <main class="fig"> <!-- add "detail" for a map to open and read; "paper" for a light figure for docs -->
299
+ <main class="fig"> <!-- add "detail" for a map to open and read; "paper" for a light figure for docs; "report" for a light chart in a page, in one sans font -->
145
300
  <header>
146
301
  <h1>The point, stated. <em>The part to notice</em></h1>
147
302
  <p class="read">How to read it. <b class="t-accent">Orange</b> means code.</p>
@@ -262,7 +417,10 @@ A few numbers compared:
262
417
  </fig-bars>
263
418
  ```
264
419
 
265
- Anything else (lines, dots, intervals, distributions, small multiples) is Observable Plot, already loaded with d3:
420
+ Grouped bars, several lines, small multiples and heat maps have specs (`groups`, `lines`, `panels`, `heatmap`); use
421
+ them when the chart is the whole figure. Dots, ranges, scatters and small counts have specs too
422
+ (`dots`, `scatter`, ranges on `bars` and `series`, `units`). Anything else (a distribution of thousands, a chart beside boxes) is
423
+ Observable Plot, already loaded with d3:
266
424
 
267
425
  ```html
268
426
  <div id="chart"></div>
@@ -279,7 +437,7 @@ Anything else (lines, dots, intervals, distributions, small multiples) is Observ
279
437
  </script>
280
438
  ```
281
439
 
282
- `theme` has `ink`, `muted`, `soft`, `line`, `accent`, `second`, `good`, `bad`. Draw the series in `soft`, the thing to
440
+ `theme` has `ink`, `muted`, `soft`, `fill`, `fill2`, `line`, `accent`, `second`, `good`, `bad`. Draw the series in `fill`, the thing to
283
441
  notice in `accent`, and label it directly on the chart; no legends. Start bar axes at zero.
284
442
 
285
443
  ## Screenshots
package/README.md CHANGED
@@ -16,7 +16,7 @@ Node 20 or later, and a Chromium-based browser: figkit uses Chrome or Edge if on
16
16
 
17
17
  ```
18
18
  npm install @liggi/figkit
19
- npx figkit guide # prints GUIDE.md, the rules for writing figures
19
+ npx @liggi/figkit guide # prints GUIDE.md, the rules for writing figures
20
20
  ```
21
21
 
22
22
  ## A figure from HTML
@@ -39,15 +39,16 @@ Save this as `order.html`:
39
39
  ```
40
40
 
41
41
  ```
42
- npx figkit order.html --out order.png
42
+ npx @liggi/figkit order.html --out order.png
43
43
  ```
44
44
 
45
45
  ![An order is checked before it is charged: Basket, then Fraud check, then Charge the card](docs/readme-flow.png)
46
46
 
47
47
  ## A chart from a spec
48
48
 
49
- For one chart of a known type (`split`, `bars`, `before-after`, `dumbbell`, `series`), or an `entities` picture of
50
- records and the references between them, give the data and figkit does the rest, including the layout. Save this as `checkout.json`:
49
+ For one chart of a known type (`split`, `bars`, `before-after`, `dumbbell`, `series`, `groups`, `lines`, `panels`,
50
+ `heatmap`, `dots`, `scatter`, `units`), or an `entities` picture of records and the references between them, give the data and figkit does the
51
+ rest, including the layout. Add `"style": "report"` for a light chart that sits in a page. Save this as `checkout.json`:
51
52
 
52
53
  ```json
53
54
  { "type": "bars", "title": "The new checkout halves the time to pay",
@@ -58,7 +59,7 @@ records and the references between them, give the data and figkit does the rest,
58
59
  ```
59
60
 
60
61
  ```
61
- npx figkit checkout.json --out checkout.png
62
+ npx @liggi/figkit checkout.json --out checkout.png
62
63
  ```
63
64
 
64
65
  ![Bar chart: the current checkout takes 86 seconds, the new one 41](docs/readme-bars.png)
package/bin/fig.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { createRequire } from 'node:module';
10
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
12
  import { chromium } from 'playwright-core';
12
13
  import { ensureBuilt } from '../viz/build.mjs';
@@ -14,6 +15,12 @@ import { parseSpec } from '../viz/spec.js';
14
15
 
15
16
  const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const url = (...p) => pathToFileURL(path.join(HOME, ...p)).href;
18
+ /** A dependency's file, wherever npm put the package: figkit's own node_modules, or one hoisted above it. */
19
+ const dep = (name, ...p) => {
20
+ const dir = createRequire(import.meta.url).resolve.paths(name).map((d) => path.join(d, name)).find((d) => fs.existsSync(d));
21
+ if (!dir) { console.error(`figkit: cannot find ${name}; run npm install in the figkit folder.`); process.exit(1); }
22
+ return pathToFileURL(path.join(dir, ...p)).href;
23
+ };
17
24
  const args = process.argv.slice(2);
18
25
 
19
26
  if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
@@ -39,18 +46,19 @@ if (/\.json$/i.test(src)) {
39
46
  console.log(`This chart spec has ${spec.problems.length} problem${spec.problems.length === 1 ? '' : 's'}:\n${spec.problems.map((p) => ` - ${p}`).join('\n')}`);
40
47
  process.exit(1);
41
48
  }
42
- body = `<main class="fig ${spec.spec.type === 'entities' ? 'entities-fig' : 'chart'}"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
49
+ const cls = [spec.spec.type === 'entities' ? 'entities-fig' : spec.spec.type === 'sequence' ? 'sequence-fig' : 'chart', spec.spec.type === 'panels' ? 'panels-fig' : '', spec.spec.style ?? ''].filter(Boolean).join(' ');
50
+ body = `<main class="fig ${cls}"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
43
51
  } else if (!/class="[^"]*\bfig\b/.test(body)) body = `<main class="fig">\n${body}\n</main>`;
44
52
  await ensureBuilt();
45
53
 
46
54
  const fonts = ['newsreader/400.css', 'newsreader/400-italic.css', 'geist-sans/400.css', 'geist-sans/500.css', 'geist-sans/600.css', 'geist-mono/400.css', 'geist-mono/500.css']
47
- .map((f) => `<link rel="stylesheet" href="${url('node_modules/@fontsource', f)}">`).join('\n');
55
+ .map((f) => `<link rel="stylesheet" href="${dep('@fontsource/' + f.split('/')[0], f.split('/')[1])}">`).join('\n');
48
56
  const page_html = `<!doctype html><html><head><meta charset="utf-8">
49
57
  <base href="${pathToFileURL(path.dirname(src) + path.sep).href}">
50
58
  ${fonts}
51
59
  <link rel="stylesheet" href="${url('runtime/fig.css')}">
52
- <script src="${url('node_modules/d3/dist/d3.min.js')}"></script>
53
- <script src="${url('node_modules/@observablehq/plot/dist/plot.umd.min.js')}"></script>
60
+ <script src="${dep('d3', 'dist/d3.min.js')}"></script>
61
+ <script src="${dep('@observablehq/plot', 'dist/plot.umd.min.js')}"></script>
54
62
  <script src="${url('dist/viz.iife.js')}"></script>
55
63
  <script src="${url('runtime/fig.js')}"></script>
56
64
  <style>body { display: inline-block; }</style>
@@ -93,7 +101,7 @@ try {
93
101
  if (phone) await page.addInitScript(() => { window.__figPhone = true; });
94
102
  await page.goto(pathToFileURL(wrapped).href);
95
103
  await page.waitForFunction('window.__figDone === true', null, { timeout: 30000 });
96
- const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!document.querySelector('.fig .sides, .fig .entities'), entities: !!document.querySelector('.fig .entities') }));
104
+ const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!document.querySelector('.fig .sides, .fig .entities, .fig .sequence, .fig .panels, .fig.report'), entities: !!document.querySelector('.fig .entities'), sequence: !!document.querySelector('.fig .sequence'), panels: !!document.querySelector('.fig .panels'), stack: !!document.querySelector('.fig .sides') }));
97
105
  await page.locator('.fig').first().screenshot({ path: shot });
98
106
  await page.close();
99
107
  return { ...r, problems: [...errors.map((e) => `Script error: ${e}`), ...r.problems] };
@@ -108,7 +116,7 @@ try {
108
116
 
109
117
  const { info } = desk;
110
118
  const lines = [`Figure: ${png} (${info.width * 2}×${info.height * 2})`];
111
- if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), ${desk.entities ? 'the records in two columns' : 'the sides stacked'}, for a viewer to show on a phone`);
119
+ if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), ${desk.entities ? 'the records in two columns' : desk.sequence ? 'the participants narrower' : desk.panels ? 'the panels stacked' : desk.stack ? 'the sides stacked' : 'at phone width'}, for a viewer to show on a phone`);
112
120
  lines.push(`Phone inline: ${inline} (preview only)`);
113
121
  const ph = (mob ?? desk).info.phone;
114
122
  const sizes = [['the title', ph?.title], ['headings', ph?.heading], ['body', ph?.body]].filter(([, v]) => v != null).map(([k, v]) => `${k} ${v}px`);