diff --git a/mockups/canvases/README.md b/mockups/canvases/README.md
index cb2d3696..ae9a6094 100644
--- a/mockups/canvases/README.md
+++ b/mockups/canvases/README.md
@@ -61,9 +61,9 @@ and `templates/` use `page(title, body, extra_css="")`. Copy whichever matches
your source. `apple-wallet` is the folder built from more than one source: two
modules, one per Figma file, imported by its `gen.py`.
-`00-welcome` is the one board here that is not phone-shaped. Its `gen.py`
-writes 2153 x 819 into `layout.json` as `w`/`h`, which is how any board
-declares a box other than the default 478 x 980.
+Two boards here are not phone-shaped. `00-welcome` writes 2153 x 819 into
+`layout.json` as `w`/`h` and `dun-web` writes 1954 x 982, which is how any
+board declares a box other than the default 478 x 980.
## Examples
@@ -308,6 +308,23 @@ declares a box other than the default 478 x 980.
of the captures' own clocks, badges or iOS 26 glyphs carried over. And it
records p2, where two stacked 20% scrims land exactly on 255 x .8 x .8. The nine `ref-*` boards are gitignored, so a
fresh clone has 14.
+- `dun-web/`: the run to read when the source is a web page rather than a
+ phone. One 1954 x 982 landscape board, the eight-card bento feature section
+ of Dun's marketing page, in 8 boards across three rows: a colour and radius
+ board, a type and metrics board, four evidence boards for 94 tokens, the
+ replica, and the capture under it. The
+ capture is 1x, so design px and capture px are the same number and no
+ measurement passes through a scale. Mean absolute delta is 4.86 levels (of
+ 255) over the whole frame and 3.19-9.21 per card, the spread tracking how
+ much of a card is small type. Its `README.md` records the two findings that
+ cost the most: a faded row's tile does **not** fade with its contents (the
+ tile holds .78 while the contents go to .04, so the tile is painted behind
+ an opacity wrapper), and the estimator that separates the two, which only
+ works when both windows sit at the same x because that is what cancels the
+ cards' right-edge fade. It also records the dark pills, whose plane fit
+ extrapolates below black, and the card radius that `refkit bbox` reports as
+ 28 where a circle fit over the corner says 36. The one `ref-*` board is
+ gitignored, so a fresh clone has 7.
- `templates/`: the starting point, not a finished board. The four boards
every run produces (design tokens, evidence, one phone screen, one parked
reference) with placeholder values, generated from one list of tokens so
diff --git a/mockups/canvases/dun-web/00-design-tokens.html b/mockups/canvases/dun-web/00-design-tokens.html
new file mode 100644
index 00000000..f7e33635
--- /dev/null
+++ b/mockups/canvases/dun-web/00-design-tokens.html
@@ -0,0 +1,155 @@
+
+
+
+
+Dun - Design Tokens
+
+
+
+
Dun — web feature section
Measured off one 1954 × 982 capture at 1×, so every number here is also a design px. The faces and the geometry are on the next board, the evidence for every value on the ones after it.
Colour
--d-bg#F2F2F2
--d-c-ws#D8E2E8
--d-c-proj#F9D2B9
--d-c-team#E1E6C4
--d-c-docs#F6C8CE
--d-c-comm#D3ECE8
--d-c-subt#F9E9B5
--d-c-task#DDDAED
--d-c-bulk#ECEFBF
--d-white#FFFFFF
--d-panel-top#F5F9FA
--d-panel-bot#EBF2F2
--d-chip#F6F6F6
--d-dark#000000
--d-dark-2#4E4E4E
--d-box#3A3B38
--d-kbd#2B2B2B
--d-tip#444746
--d-pill-grey#EEEEEE
--d-plus#E6EAEB
--d-tile-fadergba(255,255,255,.78)
--d-ghostrgba(255,255,255,.42)
--d-ringrgba(255,255,255,.75)
--d-hairline#ECECEC
--d-tree#E8D7A4
--d-i-ws#1F4256
--d-i-proj#724324
--d-i-team#203E14
--d-i-docs#5A2D4F
--d-i-comm#215C5C
--d-i-subt#51430D
--d-i-task#5C476A
--d-i-bulk#727358
--d-ink#050505
--d-ink-2#656765
--d-ink-3#B2B2B2
--d-ink-4#D1D1D1
--d-ink-dim#9BA0A0
--d-ink-inv#FFFFFF
--d-doc-ink#2A2A2A
--d-doc-head#343434
--d-doc-body#777876
--d-blue#3B82F6
--d-amber#FBBF24
--d-orange#F59E0B
--d-fuchsia#D946EF
--d-green#22C55E
--d-red#F87171
--d-folder#FFDE77
--d-cur-blue#3092FE
--d-cur-green#1BB944
Radius
36px
24px
14px
20px
18px
8px
8px
16px
999px
+
+
diff --git a/mockups/canvases/dun-web/00b-type.html b/mockups/canvases/dun-web/00b-type.html
new file mode 100644
index 00000000..40baa37f
--- /dev/null
+++ b/mockups/canvases/dun-web/00b-type.html
@@ -0,0 +1,155 @@
+
+
+
+
+Dun - Type and metrics
+
+
+
+
Type and metrics
One composite font: per role. The hand face is a substitution and sets narrower than the reference’s, so its size is the one fitted by rendering rather than read off the capture.
One row per token. A token with no evidence is a guess. The replayable half is probes.json.
--d-font
-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serif
refkit font on “Everything”: no call (SF Pro Rounded .665, SF Pro .629). Real face is outside the macOS set; repo platform stack, width ratio in README
refkit font on the “Workspace” label: all candidates weak (Futura .383, Brush Script .341). SignPainter closest on scratch/labelcmp2.png
--d-bg
#F2F2F2
flat-fill census of the page ground, 100% flat
--d-c-ws
#D8E2E8
flat-fill census inside the Workspace card
--d-c-proj
#F9D2B9
flat-fill census inside the Projects card
--d-c-team
#E1E6C4
flat-fill census inside the Teams card
--d-c-docs
#F6C8CE
flat-fill census inside the Docs card
--d-c-comm
#D3ECE8
flat-fill census inside the Comments card
--d-c-subt
#F9E9B5
flat-fill census inside the Subtasks card
--d-c-task
#DDDAED
flat-fill census inside the Tasks card
--d-c-bulk
#ECEFBF
flat-fill census inside the Bulk actions card
--d-white
#FFFFFF
flat-fill census inside a Teams pill, 2859 px
--d-panel-top
#F5F9FA
refkit scan col 560, Workspace panel at its top edge
--d-panel-bot
#EBF2F2
refkit scan col 560, same panel at the card’s bottom
--d-chip
#F6F6F6
refkit scan row 812, the Tasks row’s Research chip; the Docs segmented field solves #F7F7F7, one level away, so it is the same token and not two
--d-dark
#000000
the Save-doc and Mark-as-done pills are a gradient, not a fill: a plane fit over each core, evaluated at the gradient line’s two ends, gives Mark-as-done −5.1 → 78.5 on a 10.7° axis and Save doc −2.1 → 78.2 on 13.0°. Both dark ends extrapolate below zero, so the ramp starts at black: starting the gradient line 6% before the box instead closes the last 0.2 levels of the four gradient-only bands and is not worth a stop
--d-dark-2
#4E4E4E
the same two fits’ light end, 78.5 and 78.2 of 255. The first pass read #434343 by averaging the cores instead of fitting the plane, which flattened the ramp from both directions at once
--d-box
#3A3B38
mode of a Bulk checkbox core, 40 px
--d-kbd
#2B2B2B
mode of the Ctrl and Enter key chips on the Bulk toolbar
--d-tip
#444746
mode of the Dun tooltip core, 357 px
--d-pill-grey
#EEEEEE
mode of the ThisUX tooltip core, 347 px
--d-plus
#E6EAEB
mode of the add-workspace circle core, 1599 px
--d-tile-fade
rgba(255,255,255,.78)
the deepest faded row on Bulk and on Subtasks keeps its tile long after its ink has gone: Bulk row 3’s tile solves .77 and the last Subtasks row’s .78 in green and .77 in blue, against contents at .55 and .38. So the tile alpha rides on the background and the opacity goes on a wrapper inside it
One row per token. A token with no evidence is a guess. The replayable half is probes.json.
--d-ghost
rgba(255,255,255,.42)
the stacked card behind the Tasks row reads #EBEAF3 over --d-c-task (#DDDAED): alpha .41 on red and .43 on green. Blue has 18 levels of headroom against red’s 34 and solves .33, which is the noise floor, not a third colour
--d-ring
rgba(255,255,255,.75)
1-2px near-white rim just outside every card fill (#F9FEFF/#FFFFFF); the ground returns to #F2F2F2 at once, so there is no dark drop shadow to solve
--d-hairline
#ECECEC
refkit scan row 527, the toolbar button outline
--d-tree
#E8D7A4
row scan at y614: the subtask connector is 1px wide and a tint of --d-c-subt, not a grey; a #D6D6D6 mask finds the DUN-NN text instead of the tree
--d-i-ws
#1F4256
ink core, darkest 2%, Workspace label and body (they match)
--d-i-proj
#724324
ink core, Projects label and body
--d-i-team
#203E14
ink core, Teams label and body
--d-i-docs
#5A2D4F
ink core, Docs label and body
--d-i-comm
#215C5C
ink core, Comments label and body
--d-i-subt
#51430D
ink core, Subtasks label and body
--d-i-task
#5C476A
ink core, Tasks label and body
--d-i-bulk
#727358
ink core, Bulk label and lines 1-2; line 3 is the same ink at --d-o-text
--d-ink
#050505
ink core of a row title (Develop MVP, Competitor Analysis Research)
--d-ink-2
#656765
ink core of the commenter name and the Research chip label
--d-ink-3
#B2B2B2
ink core of a DUN-NN row id, two cards
--d-ink-4
#D1D1D1
ink core of the 2h timestamp
--d-ink-dim
#9BA0A0
ink core of the ThisUX (#9DA09F) and Fli.so (#A0A5A5) tooltip labels and of the add-workspace plus glyph (#919596, only 19 px of ink)
--d-ink-inv
#FFFFFF
ink core on the dark pills and the cursor labels
--d-doc-ink
#2A2A2A
ink core of the doc title, Product Requirement Doc
--d-doc-head
#343434
ink core of a doc section heading
--d-doc-body
#777876
ink core of a doc paragraph
--d-blue
#3B82F6
1.5px stroke; darkest px #488ED6, nearest Tailwind step to its hue
One row per token. A token with no evidence is a guess. The replayable half is probes.json.
--d-r-card
36px
circle fit to where the fill starts on each row of the Tasks card’s bottom-left corner: r 39.5 at rms 0.6, against 30.0 for my own 28px corner, so the fit runs 2 levels of threshold wide and the reference is 37.5. A sweep of the 32 card corners bottoms out at 36, and the same sweep confirms r-panel, r-tile and r-cur where they already stood. A bbox on one corner crop read 28, which is the corner’s chord, not its radius
--d-r-panel
24px
the Workspace and Docs panels’ one visible corner
--d-r-tip
14px
the Dun tooltip corner, and the Docs toolbar’s
--d-r-tile
20px
the Comments tile corner
--d-r-row
18px
solved on a Subtasks row, the Tasks pill and the Bulk toolbar; the Tasks pill is 55px tall, so 18 is a real radius and not h/2
--d-r-box
8px
a Bulk checkbox and a toolbar button corner
--d-r-chip
8px
the Research chip corner
--d-r-cur
16px
the James and You label pills, 46 and 47px tall,
+ so 16 is a real radius and not a full pill
--d-r-pill
999px
solved ≥ h/2 on the four Teams pills, so they are full pills
--d-t-label
italic 700 25px/25px var(--d-font-hand)
ink top is card + 31 on all eight cards. Size is calibrated, not solved: SignPainter is a substitution, so its 21px ink was measured against all eight reference labels (81/64/44/39/79/69/41/96 px wide) and each asks for 23.7-26.4
--d-t-body
400 18px/28px var(--d-font)
body cap 13px → 18.2; line 1 ink 402px wide on Workspace → 17-18px. Line 1 ink top is card + 65 and the pitch is 28 on all eight cards
--d-t-pill
400 19px/24px var(--d-font)
“Engineering” cap 14px → 19.6; ink 95px wide → 18px
--d-t-row
600 16px/20px var(--d-font)
“Competitor Analysis Research” cap 12px → 16.8; ink 212px wide → 14px. The widest disagreement on the board: SF Pro Semibold sets it +14%
--d-t-row-id
400 16px/20px var(--d-font)
“DUN-20” cap 12px → 16.8; ink 58px wide → 15px
--d-t-chip
400 13px/16px var(--d-font)
“Research” cap 10px → 14; ink 55px wide → 12-13px
--d-t-btn
600 16px/20px var(--d-font)
“3 Selected” cap 12px → 16.8, ink 75px → 14px; “Mark as done” ink 121px → 18px. White-on-dark ink measures dilated and dark-on-white eroded, so the two straddle 16
--d-t-btn-s
600 13px/17px var(--d-font)
“Save doc” cap 10px → 14, ink 57px → 12px. The Docs toolbar is a smaller instance of the Bulk one (252 × 51 vs 528 × 56)
--d-t-kbd
500 11px/14px var(--d-font)
“Ctrl” ink is 18 × 8 on the Docs plate and 17 × 9 on the Bulk one — the same size within a pixel, so one token, not two
--d-t-tip
600 21px/26px var(--d-font)
cap 16/16/15px and ink 38/72/48px wide on Dun / ThisUX / Fli.so; 21px satisfies both on all three, the cleanest agreement on the board
--d-t-cursor
600 18px/22px var(--d-font)
“James” cap 14px → 19.6, ink 58px → 18px; “You” cap 13px → 18.2
One row per token. A token with no evidence is a guess. The replayable half is probes.json.
--d-t-doc-t
500 18px/22px var(--d-font)
“Product Requirement Doc” ink is 201 × 18, and cap + descender is .9386em, so 18px. Weight from ink mass: the reference carries 1.125× this string at 15px/700, where the same coverage sum on type that already matches runs 1.00-1.23, which puts it near 500 rather than 700
--d-t-doc-h
500 11px/14px var(--d-font)
“Overview” ink 50px and “Description” 60px, both 11px; 700 sets Description 3px over and carries 1.1× the reference's ink mass
--d-t-doc-b
400 8px/11px var(--d-font)
the first doc paragraph is 222px of ink → 8px, and 8px ascender-to-descender → 8px; the measured pitch inside a paragraph is 11
--d-t-comm-n
600 13px/16px var(--d-font)
“Damia” cap 10px → 14, ink 34px → 10px; 13 splits them
--d-t-comm-b
400 12px/16px var(--d-font)
“The font size feels too small. Maybe we” cap 9px → 12.6, ink 220px → 11-12px; the measured line pitch is 16
--d-w
1954px
the capture’s own width; the board is the section, 1x
--d-h
982px
the capture’s own height
--d-pad
27px
refkit bands: body ink at card left + 27 on all eight cards
--d-label-top
31px
refkit bands: label ink top, card + 30 to + 33
--d-body-top
65px
refkit bands: body line 1 ink top, card + 65 to + 67
--d-gap
21px
column gap in band 1: 595→616, 1136→1157, 1497→1518
--d-row-pitch
85px
refkit bands pitch on the five Subtasks rows
--d-bulk-pitch
66px
refkit bands pitch on the four Bulk actions rows
--d-o-text
.82
Bulk’s third body line reads #878A6B where lines 1-2 read #727358: the same ink at .82 over #ECEFBF solves 136, the measured 135
--d-o-bulk-2
.94
both row lists fade toward the bottom of their card, and a row’s own opacity comes off the contrast between its title ink and the tile right above that ink, divided by row 1’s: a ratio that cancels the right-edge fade, which a fill sample does not. Bulk row 2 reads .935 on all three channels
--d-o-bulk-3
.55
Bulk row 3, .544/.540/.559. Its checkbox solves .57 instead, because it sits 3px above the toolbar inside the white ringing along that edge
--d-o-bulk-4
.04
Bulk row 4, .040/.037/.040, and its tile is 2 levels off the card fill in blue and level with it in red and green
--d-rot-pill
.56deg
the Tasks tile is the one rotated box on the board: its top edge runs 774.70 at x 160 to 779.55 at x 700 and its bottom 831.53 to 836.33, two independent fits on +0.0097 rad. Every other tile, panel and toolbar here is flat to a third of a pixel
--d-rot-ghost
-1.04deg
the stack behind it turns the other way: its one visible edge is 859.25 at x 200 and 850.88 at x 660, −0.0182 rad
--d-o-subt-4
.93
Subtasks row 4, .928 off its row id at x 1684. Its title reads .839 instead, and the difference is the fade: the titles on rows 3 and 4 are indented to x 1761 where the row ids and row 1 are not, and a ratio only cancels the fade where both windows sit at the same x. Row 3 reads .99, so it is not faded at all
--d-o-subt-5
.38
the last Subtasks row, .381/.389/.416 off its id and .33 off its title. The two cards do not share one ramp: this row is 56px off its card’s bottom and Bulk’s fourth is 46, and Bulk’s has all but gone
Everything starts with a clean workspace. Nothing cluttered, nothing distracting.
Dun
ThisUX
Fli.so
James
You
Projects
Create projects to keep things organized. Big or small, work how you like
Teams
Your team’s in too. Everyone has their own space, work together & stay in sync
Design
Engineering
Marketing
Sales
Docs
Docs, right next to your tasks. So no hopping between apps
Product Requirement Doc
Overview
A simple todo app to add, manage, and complete daily tasks.
Description
The app helps people stay organized by letting them quickly jot down tasks, group them by projects, mark them done, and set reminders.
It’s fast, minimal, and easy to use — works well solo or with a small team. Think of it like a digital sticky note with superpowers.
Why
Most task apps are too heavy or too confusing for regular folks. People just want to get stuff out of their heads and come back to it when they’re ready.
We’re solving this because staying organized shouldn’t feel like a job by
KPIs
them via Twitter, Product Hunt, and design communities with a simple promise: “Get things done without the fluff.”
User adds at least 1 task per day for 7 straight days
At least 40% of tasks marked “done”
30% users use more than one list/project
nk B2C. We’ll targ
Save doc
Ctrl
Comments
Need to discuss? Just comment on a task, it all stay in one place
Damia
2h
The font size feels too small. Maybe we could bump it up a bit for readability
Subtasks
Big task? Break it down and tackle bit by bit with subtasks
DUN-20
Develop MVP
DUN-21
Setup everything
DUN-22
Setup Database
DUN-23
Create GitHub
DUN-24
Implement Auth
Tasks
Adding tasks is simple, instant and feels as quick as writing on a sticky note
DUN-20
Competitor Analysis Research
Research
Bulk actions
And when there’s a lot to handle at once, you can select tasks and apply changes in bulk,
all at once
DUN-31
Core features
DUN-32
Create task
DUN-33
Edit/Delete task
DUN-34
Change status
3 Selected
Mark as done
Ctrl
+
+
diff --git a/mockups/canvases/dun-web/README.md b/mockups/canvases/dun-web/README.md
new file mode 100644
index 00000000..353cc75d
--- /dev/null
+++ b/mockups/canvases/dun-web/README.md
@@ -0,0 +1,264 @@
+# Dun web: the marketing page's feature section
+
+A `clone-prototype` run over one landscape capture: the eight-card bento grid
+on [Dun](https://dun.so)'s marketing page, where each pastel card carries a
+handwritten label, a paragraph, and a piece of the product's own UI. One
+replica board, six foundation boards, and the capture parked under it.
+
+Open it with `?canvas=dun-web`.
+
+| file | what it is |
+|---|---|
+| `00-design-tokens.html` | The contract. 94 tokens: two faces, 22 surfaces, 3 lines, 17 inks, 9 accents, 9 radii, 16 type shorthands, 16 metrics. Inlined byte-identically into every board. The sheet holds the colours and the radii. |
+| `00b-type.html` | The 16 type shorthands set as specimens, and the 16 metrics. On its own board because the swatches and the specimens together overflow one 478 × 980 shape by 573px. |
+| `00c`–`00f-evidence.html` | The measurement behind every one of the 94, four boards because the rows do not fit one shape either. `gen.py` fills an evidence board to a height rather than to a row count, and splits the table evenly across the boards it needs, so the last one is not a stub. |
+| `01-feature-grid.html` | The replica, 1954 × 982, its own `w`/`h` in `layout.json`. |
+| `ref-01-feature-grid.html` | The capture, same size, directly under it. Not checked in — see the last section. |
+| `gen.py` | Emits all of the above plus `layout.json` and `icon.png`. Edit this, never the HTML. |
+| `probes.json` | 90 probes: the machine half of the evidence table, replayed against the render by `refkit batch`. |
+| `crops.json`, `assets/art/` | The eight pieces of the capture that are pixels rather than CSS. |
+| `assets/icons/` | 17 line icons, inlined by `gen.py` so the canvas inspector can name each one. |
+
+The capture is **1×** — a web page screenshot, not a device one — so capture px
+and design px are the same number. There is no scale constant in `gen.py`, no
+`--pt` on any probe, and `crops.json` holds plain page coordinates.
+
+```bash
+python3 mockups/canvases/dun-web/gen.py
+```
+
+regenerates the folder byte-identically. It needs either `assets/refs/`
+absent or Pillow present: `cut()` and `app_icon()` import PIL, and with the
+capture in place system `python3` stops at `ModuleNotFoundError: No module
+named 'PIL'`. There is deliberately no fallback for it.
+
+## How close it lands
+
+Mean absolute delta against the capture, all three channels, no crop and no
+alignment shift — `refkit blend` scores `dy 0` best, so the two are registered
+as generated:
+
+| region | box | Δ |
+|---|---|---|
+| whole frame | 1954 × 982 | **4.86** |
+| Projects | 616,24 521 × 281 | 3.19 |
+| Workspace | 75,24 520 × 582 | 3.60 |
+| Tasks | 75,634 700 × 290 | 4.60 |
+| Bulk actions | 797,634 700 × 290 | 7.45 |
+| Subtasks | 1518,328 340 × 596 | 7.03 |
+| Teams | 616,326 521 × 280 | 6.96 |
+| Docs | 1157,24 340 × 582 | 8.99 |
+| Comments | 1518,24 340 × 283 | 9.21 |
+
+The spread is type density, not geometry. A Δ-block map puts every remaining
+hot spot on a glyph: Docs is four headings and 19 lines of 8px body text in a
+340px column, and Comments is two lines of 12px text across an otherwise empty
+card, so on those two the substituted face is most of what a pixel can
+disagree about.
+Workspace and Projects, whose content is art and one paragraph, land at 3.2–3.6
+with the same tokens.
+
+## Substitutions and what they cost
+
+**The body face is not identified.** `refkit font` on "Everything", the
+largest string on the board, returns **no call** — SF Pro Rounded .665 against
+SF Pro .629, and neither is close enough to promote. The real face is outside
+the macOS candidate set. The board ships the repo's platform stack and the bill
+arrives as width: across the 16 type probes the capture's ink width over mine
+runs **0.895 to 1.167** (mine 12% wide on "Damia", 14% narrow on "Ctrl"),
+median 0.99.
+
+**Sizes are solved from cap height, not from width.** Every `--x-t-*` size
+comes from the measured cap height at SF Pro's .714 cap ratio, then
+cross-checked against the string's ink width; where the two disagree the cap
+height wins, because the stand-in is not the real face. `--x-t-row` is the
+widest disagreement: "Competitor Analysis Research" measures a 12px cap
+(→ 16.8px) and 212px of ink (→ 14px), and it ships at 16px semibold. A width
+that then overruns a measured container widens the container; the type is never
+shrunk to make a width hold.
+
+**The hand labels are SignPainter.** `refkit font` on "Workspace" rates every
+candidate weak (Futura .383, Brush Script .341), so this is a look-alike rather
+than a match. Its size is calibrated, not solved: SignPainter's ink was
+measured against all eight reference labels (81/64/44/39/79/69/41/96 px wide)
+and they ask for 23.7–26.4px, so the token is 25px.
+
+## Four things a reader would otherwise take for measurement
+
+**The row lists fade, and the tile does not fade with its contents.** Both
+Subtasks and Bulk dim toward the bottom of their card, and the first model —
+one opacity per row — is wrong: it moved Subtasks from 7.29 to 7.71. The tile
+under a row keeps its white long after the row's ink has gone. Bulk row 3's
+tile solves .77 against contents at .55; the last Subtasks row's solves .78 in
+green and .77 in blue against contents at .38. So the tile is painted behind at
+a fixed `--x-tile-fade: rgba(255,255,255,.78)` and the opacity goes on a
+wrapper inside it. A checkbox belongs to the contents, not the tile: row 3's
+solves .57, with the contents.
+
+The estimator behind the five per-row numbers is worth writing down, because
+two obvious versions of it are silently wrong. For each row, ground = the
+median of the tile just above the glyphs, ink = the darkest 4% of the title
+band, and α = (ground − ink) / (ground₁ − ink₁) **measured in the same x window
+as row 1**. The right-edge fade cancels exactly in that ratio. Grounding on the
+card's token colour instead does not cancel it (row 5 then reads .8 by tile and
+.26 by ink), and comparing windows at different x folds the fade back in — the
+Subtasks titles on rows 3 and 4 are indented to x 1761 where row 1's ends at
+1783, which is what made two undimmed rows read .92 and .84. Validated against
+my own render: .70 → 0.713, .11 → 0.114, 1.0 → 1.0.
+
+**The dark pills are a gradient rebuilt from a fit, and its dark end is not a
+colour.** A least-squares plane over each pill's core, evaluated at the CSS
+gradient line's two ends, gives Mark-as-done −5.1 → 78.5 on a 10.7° axis and
+Save doc −2.1 → 78.2 on 13.0°. Both dark ends extrapolate **below black**, so
+`--x-dark` is `#000000` and `--x-dark-2` is `#4E4E4E` — the ramp the capture
+shows is steeper than any sRGB gradient over that box can be. Starting the
+gradient line 6% before the box closes the last 0.2 levels of the four
+gradient-only bands and is not worth a stop. The earlier `#0B0B0B`/`#434343`
+pair came from averaging the cores rather than fitting the plane, which
+flattened the ramp from both ends at once and cost 2.5 levels on those bands.
+
+**The card radius is 36px, and one command reports 28.** A `refkit bbox` on a
+corner crop reads the corner's chord, not its radius. The number that holds is
+a circle fit to where the fill starts on each row of the Tasks card's
+bottom-left corner: r 39.5 at rms 0.6, against 30.0 fitted the same way to my
+own 28px corner, so the fit runs 2 levels of threshold wide and the reference is
+37.5. A sweep over all 32 card corners bottoms out at 36 (2.798 against 3.790
+at 28), and the same sweep confirms `r-panel`, `r-tile` and `r-cur` where they
+already stood.
+
+**Two rotated tiles on Tasks, whose contents do not turn with them.** The
+foreground pill is `.56deg` (top edge 774.70 at x 160 → 779.55 at x 700, bottom
+831.53 → 836.33, two independent fits on +0.0097 rad) and the stack behind it
+`-1.04deg` (859.25 at x 200 → 850.88 at x 660). Every other tile, panel and
+toolbar on the board is flat to a third of a pixel. The row's icon, id, title
+and chip are set straight inside the rotated box, because that is what the
+capture shows.
+
+The right-edge fades on Subtasks and Bulk are card-coloured gradients over the
+rows (transparent → `--x-c-subt` from card-local 246 to 521, and →
+`--x-c-bulk` from 611 to 826), not opacity on the rows themselves, which is why
+a row's own alpha has to be measured in a window the fade treats equally.
+
+## What the source itself shows, and what its file format invented
+
+Text hidden behind a toolbar is **not** reconstructed. The Docs panel's last
+line runs under the floating toolbar and comes out as "nk B2C. We'll targ",
+written as that tail alone and positioned so its ink starts where the
+capture's does. The words in front of it are not recoverable from the pixels
+and inventing them would be inventing a fact. The same holds for the Bulk row
+the toolbar crosses.
+
+Four things in the capture are webp artifacts and are deliberately **not**
+reproduced:
+
+- faint grid lines across the Comments card,
+- a white glow along the Bulk actions rows,
+- a white ring around both cursor label pills,
+- ringing above and to the right of the Bulk toolbar's edge, which also biases
+ any probe placed near it. The "row 4 tile at .11" reading that sent the fade
+ model wrong came from a window at x 1360–1404 sitting inside that ringing;
+ the clean window at x 1402–1492 gives .03, which matches its ink at .039.
+
+**The capture's icons are soft.** At 12× every line glyph carries a visible
+glow, and a 1.5px coloured stroke in a soft 1× capture never reaches its own
+colour: the five stroke accents read 35 to 95 levels light in `refkit batch`
+(`blue` 65, `orange` 60, `fuchsia` 94, `green` 35, `red` 55) and those rows are
+expected to differ. What puts the whole set on the Tailwind palette is the one
+*filled* glyph, the amber half-circle, whose darkest pixel is `#FFBF28` — Tailwind
+amber-400 to the level, against the token's `#FBBF24` (Δ4). The two cursor
+arrows are crops, so `cur-blue` and `cur-green` land at Δ2.
+
+Two icons differ from the capture on purpose:
+
+- the **briefcase** carries a small clasp stub in the capture that lucide's
+ `briefcase` does not have; the replica is lucide's handle-and-rounded-rect
+ and the clasp is not reproduced,
+- the **megaphone** is authored on lucide's 24-grid rather than taken from it.
+ lucide's `megaphone` is a different shape; the capture's is a horn with a
+ hinged handle under it and a speaker arc at its right.
+
+## Reading probes.json
+
+`refkit batch probes.json --against scratch/shots` after a shoot. 55 colour
+probes at mean Δmax 7.9, almost all 0–3; 29 box probes at mean |dw| 2.07 and
+|dh| 0.93. The rows that do not close, and why:
+
+- the five stroke accents, above.
+- `dark` Δ11. A flat census inside a ramp reports whichever level happens to
+ repeat most, so it carries ~10 levels of slop; the window's means are 14.6
+ against 19.0, and those 4.4 are the part of the reference's ramp that runs
+ below black.
+- `doc-body` Δ28, the 8px body text: an ink percentile over the thinnest type
+ on the board is a glyph-coverage measurement, not a colour one.
+- `o-bulk-3` Δ12 and `o-subt-5` Δ13, ink cores under a faded row, same reason.
+- `row-pitch` and `bulk-pitch` print **differs**. `bands` collapses to its
+ first band, and the substituted face's ink band is 5px taller on Subtasks
+ and 2px on Bulk. The pitch, which is what the token says, matches: 85 and 66.
+
+Four scans were narrowed after the first pass, because `batch` compares a
+scan's **largest** colour step and a range holding two comparable edges flips
+which one that is between the capture and the render. `ring` now takes the
+Workspace card's right edge alone (588–602), `gap` the Projects card's left
+edge (608–622), `hairline` the X button's left outline (928–942) and `h` the
+Tasks card's bottom (900–940). All four then agree to a pixel. A probe window
+is wrong far more often than a measurement is, which is why every entry carries
+its own sanity note: `panel-bot` had to move twice to get clear of both the You
+pill and the 36px card corner, and it read `#1BB944` — the cursor green — until
+it did.
+
+## Assets: all cropped, none generated
+
+Eight boxes in `crops.json`, cut from the capture by `cut()` and placed back by
+`art()` at the same numbers, so an asset cannot drift from where it was
+measured:
+
+- the three workspace marks (60px discs on the panel's own near-white ramp, so
+ each square crop carries two levels of that ramp back with it),
+- the Projects folder and the three note cards fanned behind it — one crop, not
+ four, because the cards overlap and the fan angles are not measurable off
+ 169px of art,
+- two photographs, the Damia and task avatars: a drawn stand-in for a face is
+ the substitution a reader always notices,
+- the two collaborator arrows. Their pills are CSS, but an arrow is four sharp
+ vertices and at 1× every one of them is antialiased below any threshold —
+ fitting the edges put the James apex 4px above where the ink starts. Each box
+ is the ink grown to its ringing fringe, so the crop carries the one pixel of
+ overshoot the capture has and the render must not invent.
+
+Nothing here is generated artwork. `icon.png` is the one place the folder
+upscales rather than crops: the Dun mark exists in the capture only at 60px, so
+the sticker is a 4.27× LANCZOS upscale of the crop under a supersampled circle
+alpha (the crop is the mark's own bbox — the disc spans x 0..59 across its
+middle rows and 21..38 at the top). It is soft at 256 and there is no sharper
+source; redrawing the loop would make the sticker the one thing in this folder
+that is not the reference's own pixels.
+
+## Two defects that produced no error message
+
+- **`preserveAspectRatio`.** A square `viewBox` on a non-square span binds one
+ axis only under the default `xMidYMid meet`, so the 16 × 4 ellipsis rendered
+ at a quarter size and a size loop would have converged on the wrong axis
+ forever. Fixed by measuring each icon's own ink box on the 24-grid
+ (`GRID_INK`, read off a 10× render of the whole set) and setting
+ `preserveAspectRatio="none"` with both axes given.
+- **Pillow's variable-font axis order.** `SFNS.ttf`'s axes are
+ `[Width, Optical Size, GRAD, Weight]`, and Optical Size must equal the pixel
+ size. `set_variation_by_axes([wght])` — or the same values in the wrong
+ order — returns absurd, quantized advances with no complaint, which silently
+ corrupts every width measurement taken through it.
+
+## The reference board is not checked in
+
+Phase 5 parks the capture in `ref-01-feature-grid.html` and `layout.json` keeps
+a third row for it, so the reference sits column-for-column under the replica.
+The file itself is gitignored: it embeds a third-party page capture this repo
+does not redistribute. Put your own capture at `assets/refs/01.png` and re-run
+`gen.py` to rebuild it; without it the generator emits everything else and
+leaves that board alone.
+
+## Attribution
+
+Dun is a product of its owner and the name and logo are its trademarks. This
+board is an unaffiliated design study, kept as a record of the measurement
+workflow. It is not a Dun product, not endorsed by Dun, and the replica HTML is
+not meant to be shipped as a user-facing interface.
diff --git a/mockups/canvases/dun-web/assets/art/avatar-damia.png b/mockups/canvases/dun-web/assets/art/avatar-damia.png
new file mode 100644
index 00000000..61aa25f4
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/avatar-damia.png differ
diff --git a/mockups/canvases/dun-web/assets/art/avatar-task.png b/mockups/canvases/dun-web/assets/art/avatar-task.png
new file mode 100644
index 00000000..8612617e
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/avatar-task.png differ
diff --git a/mockups/canvases/dun-web/assets/art/cur-james.png b/mockups/canvases/dun-web/assets/art/cur-james.png
new file mode 100644
index 00000000..99e76b90
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/cur-james.png differ
diff --git a/mockups/canvases/dun-web/assets/art/cur-you.png b/mockups/canvases/dun-web/assets/art/cur-you.png
new file mode 100644
index 00000000..327511ee
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/cur-you.png differ
diff --git a/mockups/canvases/dun-web/assets/art/logo-dun.png b/mockups/canvases/dun-web/assets/art/logo-dun.png
new file mode 100644
index 00000000..1626ae5f
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/logo-dun.png differ
diff --git a/mockups/canvases/dun-web/assets/art/logo-fliso.png b/mockups/canvases/dun-web/assets/art/logo-fliso.png
new file mode 100644
index 00000000..b15af2a4
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/logo-fliso.png differ
diff --git a/mockups/canvases/dun-web/assets/art/logo-thisux.png b/mockups/canvases/dun-web/assets/art/logo-thisux.png
new file mode 100644
index 00000000..0764077e
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/logo-thisux.png differ
diff --git a/mockups/canvases/dun-web/assets/art/proj-art.png b/mockups/canvases/dun-web/assets/art/proj-art.png
new file mode 100644
index 00000000..e7070bc1
Binary files /dev/null and b/mockups/canvases/dun-web/assets/art/proj-art.png differ
diff --git a/mockups/canvases/dun-web/assets/icons/archive-check.svg b/mockups/canvases/dun-web/assets/icons/archive-check.svg
new file mode 100644
index 00000000..f9ef0c80
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/archive-check.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/briefcase.svg b/mockups/canvases/dun-web/assets/icons/briefcase.svg
new file mode 100644
index 00000000..13ebd184
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/briefcase.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/check.svg b/mockups/canvases/dun-web/assets/icons/check.svg
new file mode 100644
index 00000000..e67327d8
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/circle-check.svg b/mockups/canvases/dun-web/assets/icons/circle-check.svg
new file mode 100644
index 00000000..e7a4d845
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/circle-check.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/circle-empty.svg b/mockups/canvases/dun-web/assets/icons/circle-empty.svg
new file mode 100644
index 00000000..0a847ac6
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/circle-empty.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/circle-half.svg b/mockups/canvases/dun-web/assets/icons/circle-half.svg
new file mode 100644
index 00000000..cba4e696
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/circle-half.svg
@@ -0,0 +1 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/corner-down-left.svg b/mockups/canvases/dun-web/assets/icons/corner-down-left.svg
new file mode 100644
index 00000000..43973904
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/corner-down-left.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/ellipsis.svg b/mockups/canvases/dun-web/assets/icons/ellipsis.svg
new file mode 100644
index 00000000..8558adbb
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/ellipsis.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/eye.svg b/mockups/canvases/dun-web/assets/icons/eye.svg
new file mode 100644
index 00000000..cb732317
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/eye.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/megaphone.svg b/mockups/canvases/dun-web/assets/icons/megaphone.svg
new file mode 100644
index 00000000..214599e8
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/megaphone.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/pencil.svg b/mockups/canvases/dun-web/assets/icons/pencil.svg
new file mode 100644
index 00000000..4ca2fdf8
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/pencil.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/plus.svg b/mockups/canvases/dun-web/assets/icons/plus.svg
new file mode 100644
index 00000000..d9c1200d
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/square-pen.svg b/mockups/canvases/dun-web/assets/icons/square-pen.svg
new file mode 100644
index 00000000..e8b59641
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/square-pen.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/square-terminal.svg b/mockups/canvases/dun-web/assets/icons/square-terminal.svg
new file mode 100644
index 00000000..e5fbdc71
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/square-terminal.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/trash.svg b/mockups/canvases/dun-web/assets/icons/trash.svg
new file mode 100644
index 00000000..f49e05fe
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/trash.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/triangle-alert.svg b/mockups/canvases/dun-web/assets/icons/triangle-alert.svg
new file mode 100644
index 00000000..1e17d77d
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/triangle-alert.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/assets/icons/x.svg b/mockups/canvases/dun-web/assets/icons/x.svg
new file mode 100644
index 00000000..bfc06fc0
--- /dev/null
+++ b/mockups/canvases/dun-web/assets/icons/x.svg
@@ -0,0 +1,3 @@
+
diff --git a/mockups/canvases/dun-web/crops.json b/mockups/canvases/dun-web/crops.json
new file mode 100644
index 00000000..396f17f2
--- /dev/null
+++ b/mockups/canvases/dun-web/crops.json
@@ -0,0 +1,19 @@
+{
+ "_": "Art cut from assets/refs/01.png at measured page boxes, [ref, x0, y0, x1, y1]. The capture is 1x, so these are design px too. gen.py's cut() writes assets/art/.png and art() places each at the same numbers, so an asset cannot drift from where it was measured. Everything else on the board -- cards, pills, panels, toolbars, type, line icons -- is CSS or inline SVG.",
+
+ "_logos": "The three workspace marks in the Workspace panel. 60px circles on the panel's own near-white ramp, so each square crop carries 2 levels of that ramp with it and lands back on the same place.",
+ "logo-dun": ["01", 325, 233, 385, 293],
+ "logo-thisux": ["01", 325, 316, 385, 376],
+ "logo-fliso": ["01", 325, 400, 385, 460],
+
+ "_proj": "The Projects folder and the three note cards fanned behind it. One crop, not four: the cards overlap the folder and each other, and the fan angles are not measurable off a 169px piece of art.",
+ "proj-art": ["01", 928, 49, 1103, 222],
+
+ "_faces": "Two photographs. Nothing else on the board is a photograph, and a drawn stand-in for a face is the one substitution a reader always notices.",
+ "avatar-damia": ["01", 1557, 195, 1580, 218],
+ "avatar-task": ["01", 690, 798, 711, 819],
+
+ "_cursors": "The two collaborator arrows. Their pills are CSS -- box and radius measure to a pixel -- but an arrow is a four-point polygon with four sharp vertices, and at 1x every one of them is antialiased below any threshold: fitting the edges off this capture put the James apex 4px above where the ink starts. The box is the arrow ink grown to the ringing fringe around it, so the crop also carries the one pixel of cyan overshoot the capture has and a render must not invent. Both boxes clip the pill the arrow points out of, so the art is drawn first and the pill lands back on top of it.",
+ "cur-james": ["01", 185, 272, 236, 318],
+ "cur-you": ["01", 439, 531, 489, 576]
+}
diff --git a/mockups/canvases/dun-web/gen.py b/mockups/canvases/dun-web/gen.py
new file mode 100644
index 00000000..c1634edb
--- /dev/null
+++ b/mockups/canvases/dun-web/gen.py
@@ -0,0 +1,938 @@
+"""Emit mockups/canvases/dun-web/ from one 1954 x 982 capture.
+
+The feature section of Dun's marketing page: eight pastel cards in a bento
+grid, each with a handwritten label, a paragraph, and a piece of the product's
+own UI inside it. One landscape artboard, not a phone, so `layout.json` gives
+the board its own `w`/`h`.
+
+The capture is 1× -- it is a web page screenshot, not a device one -- so
+capture px and design px are the same number and every measurement below goes
+straight into the CSS with no scale in between. That is why there is no SCALE
+constant here and why `crops.json` boxes are plain page coordinates.
+
+Chrome is CSS: cards, pills, panels, toolbars, type. Three things are not.
+Photographs, the three brand marks and the two cursor arrows are cropped out
+of the capture at their measured boxes (`crops.json` -> `cut()` -> `assets/art/.png` -> `art()`),
+because a crop scores 0 against the reference by construction and a redrawn
+one does not. Line icons are inline SVG from `assets/icons/`, authored on
+lucide's 24 x 24 grid and placed at the measured ink box grown 2px a side,
+which is lucide's own padding.
+
+ python3 mockups/canvases/dun-web/gen.py
+
+Artboards are output. Edit this file, never the HTML.
+"""
+import base64
+import json
+import re
+from pathlib import Path
+
+OUT = Path(__file__).resolve().parent
+REFS_DIR = OUT / "assets" / "refs" # third-party capture, not committed
+ART_DIR = OUT / "assets" / "art"
+ICON_DIR = OUT / "assets" / "icons"
+CROPS = {k: v for k, v in json.loads((OUT / "crops.json").read_text()).items()
+ if not k.startswith("_")}
+
+NAME = "Dun"
+PAGE_NAME = "(example) " + NAME + " Web"
+P = "d"
+
+# ---------------------------------------------------------------- tokens ----
+# (group, name, value, evidence). Phase 2 order: font, then surface -> line ->
+# ink -> accent, then radii, type, metrics.
+TOKENS = [
+ ("Font", "font",
+ '-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",'
+ '"Helvetica Neue",Helvetica,Arial,sans-serif',
+ "refkit font on “Everything”: no call (SF Pro Rounded .665, SF Pro .629). "
+ "Real face is outside the macOS set; repo platform stack, width ratio in README"),
+ ("Font", "font-hand",
+ '"SignPainter","Bradley Hand","Brush Script MT",cursive',
+ "refkit font on the “Workspace” label: all candidates weak (Futura .383, "
+ "Brush Script .341). SignPainter closest on scratch/labelcmp2.png"),
+
+ ("Surface", "bg", "#F2F2F2", "flat-fill census of the page ground, 100% flat"),
+ ("Surface", "c-ws", "#D8E2E8", "flat-fill census inside the Workspace card"),
+ ("Surface", "c-proj", "#F9D2B9", "flat-fill census inside the Projects card"),
+ ("Surface", "c-team", "#E1E6C4", "flat-fill census inside the Teams card"),
+ ("Surface", "c-docs", "#F6C8CE", "flat-fill census inside the Docs card"),
+ ("Surface", "c-comm", "#D3ECE8", "flat-fill census inside the Comments card"),
+ ("Surface", "c-subt", "#F9E9B5", "flat-fill census inside the Subtasks card"),
+ ("Surface", "c-task", "#DDDAED", "flat-fill census inside the Tasks card"),
+ ("Surface", "c-bulk", "#ECEFBF", "flat-fill census inside the Bulk actions card"),
+ ("Surface", "white", "#FFFFFF", "flat-fill census inside a Teams pill, 2859 px"),
+ ("Surface", "panel-top", "#F5F9FA", "refkit scan col 560, Workspace panel at its top edge"),
+ ("Surface", "panel-bot", "#EBF2F2", "refkit scan col 560, same panel at the card’s bottom"),
+ ("Surface", "chip", "#F6F6F6",
+ "refkit scan row 812, the Tasks row’s Research chip; the Docs segmented field "
+ "solves #F7F7F7, one level away, so it is the same token and not two"),
+ ("Surface", "dark", "#000000",
+ "the Save-doc and Mark-as-done pills are a gradient, not a fill: a plane fit over "
+ "each core, evaluated at the gradient line’s two ends, gives Mark-as-done "
+ "−5.1 → 78.5 on a 10.7° axis and Save doc −2.1 → 78.2 on "
+ "13.0°. Both dark ends extrapolate below zero, so the ramp starts at black: "
+ "starting the gradient line 6% before the box instead closes the last 0.2 levels "
+ "of the four gradient-only bands and is not worth a stop"),
+ ("Surface", "dark-2", "#4E4E4E",
+ "the same two fits’ light end, 78.5 and 78.2 of 255. The first pass read "
+ "#434343 by averaging the cores instead of fitting the plane, which flattened "
+ "the ramp from both directions at once"),
+ ("Surface", "box", "#3A3B38", "mode of a Bulk checkbox core, 40 px"),
+ ("Surface", "kbd", "#2B2B2B", "mode of the Ctrl and Enter key chips on the Bulk toolbar"),
+ ("Surface", "tip", "#444746", "mode of the Dun tooltip core, 357 px"),
+ ("Surface", "pill-grey", "#EEEEEE", "mode of the ThisUX tooltip core, 347 px"),
+ ("Surface", "plus", "#E6EAEB", "mode of the add-workspace circle core, 1599 px"),
+ ("Surface", "tile-fade", "rgba(255,255,255,.78)",
+ "the deepest faded row on Bulk and on Subtasks keeps its tile long after its "
+ "ink has gone: Bulk row 3’s tile solves .77 and the last Subtasks row’s "
+ ".78 in green and .77 in blue, against contents at .55 and .38. So the tile "
+ "alpha rides on the background and the opacity goes on a wrapper inside it"),
+ ("Surface", "ghost", "rgba(255,255,255,.42)",
+ "the stacked card behind the Tasks row reads #EBEAF3 over --x-c-task "
+ "(#DDDAED): alpha .41 on red and .43 on green. Blue has 18 levels of "
+ "headroom against red’s 34 and solves .33, which is the noise floor, "
+ "not a third colour"),
+
+ ("Line", "ring", "rgba(255,255,255,.75)",
+ "1-2px near-white rim just outside every card fill (#F9FEFF/#FFFFFF); the ground "
+ "returns to #F2F2F2 at once, so there is no dark drop shadow to solve"),
+ ("Line", "hairline", "#ECECEC", "refkit scan row 527, the toolbar button outline"),
+ ("Line", "tree", "#E8D7A4",
+ "row scan at y614: the subtask connector is 1px wide and a tint of --x-c-subt, not a grey; a #D6D6D6 mask finds the DUN-NN text instead of the tree"),
+
+ ("Ink", "i-ws", "#1F4256", "ink core, darkest 2%, Workspace label and body (they match)"),
+ ("Ink", "i-proj", "#724324", "ink core, Projects label and body"),
+ ("Ink", "i-team", "#203E14", "ink core, Teams label and body"),
+ ("Ink", "i-docs", "#5A2D4F", "ink core, Docs label and body"),
+ ("Ink", "i-comm", "#215C5C", "ink core, Comments label and body"),
+ ("Ink", "i-subt", "#51430D", "ink core, Subtasks label and body"),
+ ("Ink", "i-task", "#5C476A", "ink core, Tasks label and body"),
+ ("Ink", "i-bulk", "#727358", "ink core, Bulk label and lines 1-2; line 3 is the same ink at --x-o-text"),
+ ("Ink", "ink", "#050505", "ink core of a row title (Develop MVP, Competitor Analysis Research)"),
+ ("Ink", "ink-2", "#656765", "ink core of the commenter name and the Research chip label"),
+ ("Ink", "ink-3", "#B2B2B2", "ink core of a DUN-NN row id, two cards"),
+ ("Ink", "ink-4", "#D1D1D1", "ink core of the 2h timestamp"),
+ ("Ink", "ink-dim","#9BA0A0",
+ "ink core of the ThisUX (#9DA09F) and Fli.so (#A0A5A5) tooltip labels and of the "
+ "add-workspace plus glyph (#919596, only 19 px of ink)"),
+ ("Ink", "ink-inv","#FFFFFF", "ink core on the dark pills and the cursor labels"),
+ ("Ink", "doc-ink","#2A2A2A", "ink core of the doc title, Product Requirement Doc"),
+ ("Ink", "doc-head","#343434","ink core of a doc section heading"),
+ ("Ink", "doc-body","#777876","ink core of a doc paragraph"),
+
+ ("Accent", "blue", "#3B82F6", "1.5px stroke; darkest px #488ED6, nearest Tailwind step to its hue"),
+ ("Accent", "amber", "#FBBF24", "filled half-circle, darkest px #FFBF28 = Tailwind amber-400"),
+ ("Accent", "orange", "#F59E0B", "1.5px stroke, terminal icon; darkest px #CB9744"),
+ ("Accent", "fuchsia", "#D946EF", "1.5px stroke, megaphone; darkest px #C435B0"),
+ ("Accent", "green", "#22C55E", "1.5px stroke, briefcase; darkest px #46AD66"),
+ ("Accent", "red", "#F87171", "1.5px stroke, warning triangle and the Research dot; darkest px #D4796C"),
+ ("Accent", "folder", "#FFDE77", "mode of the Projects folder core, 8842 px"),
+ ("Accent", "cur-blue","#3092FE", "mode of the James cursor core, 429 px"),
+ ("Accent", "cur-green","#1BB944","mode of the You cursor core, 334 px"),
+
+ ("Radius", "r-card", "36px",
+ "circle fit to where the fill starts on each row of the Tasks card’s "
+ "bottom-left corner: r 39.5 at rms 0.6, against 30.0 for my own 28px corner, "
+ "so the fit runs 2 levels of threshold wide and the reference is 37.5. A sweep "
+ "of the 32 card corners bottoms out at 36, and the same sweep confirms r-panel, "
+ "r-tile and r-cur where they already stood. A bbox on one corner crop read 28, "
+ "which is the corner’s chord, not its radius"),
+ ("Radius", "r-panel", "24px", "the Workspace and Docs panels’ one visible corner"),
+ ("Radius", "r-tip", "14px", "the Dun tooltip corner, and the Docs toolbar’s"),
+ ("Radius", "r-tile", "20px", "the Comments tile corner"),
+ ("Radius", "r-row", "18px", "solved on a Subtasks row, the Tasks pill and the Bulk toolbar; "
+ "the Tasks pill is 55px tall, so 18 is a real radius and not h/2"),
+ ("Radius", "r-box", "8px", "a Bulk checkbox and a toolbar button corner"),
+ ("Radius", "r-chip", "8px", "the Research chip corner"),
+ ("Radius", "r-cur", "16px", "the James and You label pills, 46 and 47px tall,\n so 16 is a real radius and not a full pill"),
+ ("Radius", "r-pill", "999px", "solved ≥ h/2 on the four Teams pills, so they are full pills"),
+
+ # Sizes are solved from the measured **cap height** at SF Pro's .714 cap ratio,
+ # then cross-checked against the string's measured ink width. Where the two
+ # disagree the cap height wins, because the stand-in is not the real face:
+ # SF Pro sets the same cap 4-19% wide here (see README). A width that then
+ # overruns a measured container widens the container, never the type.
+ ("Type", "t-label", "italic 700 25px/25px var(--x-font-hand)",
+ "ink top is card + 31 on all eight cards. Size is calibrated, not solved: "
+ "SignPainter is a substitution, so its 21px ink was measured against all eight "
+ "reference labels (81/64/44/39/79/69/41/96 px wide) and each asks for 23.7-26.4"),
+ ("Type", "t-body", "400 18px/28px var(--x-font)",
+ "body cap 13px → 18.2; line 1 ink 402px wide on Workspace → 17-18px. "
+ "Line 1 ink top is card + 65 and the pitch is 28 on all eight cards"),
+ ("Type", "t-pill", "400 19px/24px var(--x-font)",
+ "“Engineering” cap 14px → 19.6; ink 95px wide → 18px"),
+ ("Type", "t-row", "600 16px/20px var(--x-font)",
+ "“Competitor Analysis Research” cap 12px → 16.8; ink 212px wide "
+ "→ 14px. The widest disagreement on the board: SF Pro Semibold sets it +14%"),
+ ("Type", "t-row-id", "400 16px/20px var(--x-font)",
+ "“DUN-20” cap 12px → 16.8; ink 58px wide → 15px"),
+ ("Type", "t-chip", "400 13px/16px var(--x-font)",
+ "“Research” cap 10px → 14; ink 55px wide → 12-13px"),
+ ("Type", "t-btn", "600 16px/20px var(--x-font)",
+ "“3 Selected” cap 12px → 16.8, ink 75px → 14px; "
+ "“Mark as done” ink 121px → 18px. White-on-dark ink measures "
+ "dilated and dark-on-white eroded, so the two straddle 16"),
+ ("Type", "t-btn-s", "600 13px/17px var(--x-font)",
+ "“Save doc” cap 10px → 14, ink 57px → 12px. The Docs "
+ "toolbar is a smaller instance of the Bulk one (252 × 51 vs 528 × 56)"),
+ ("Type", "t-kbd", "500 11px/14px var(--x-font)",
+ "“Ctrl” ink is 18 × 8 on the Docs plate and 17 × 9 on the "
+ "Bulk one — the same size within a pixel, so one token, not two"),
+ ("Type", "t-tip", "600 21px/26px var(--x-font)",
+ "cap 16/16/15px and ink 38/72/48px wide on Dun / ThisUX / Fli.so; 21px satisfies "
+ "both on all three, the cleanest agreement on the board"),
+ ("Type", "t-cursor", "600 18px/22px var(--x-font)",
+ "“James” cap 14px → 19.6, ink 58px → 18px; “You” "
+ "cap 13px → 18.2"),
+ ("Type", "t-doc-t", "500 18px/22px var(--x-font)",
+ "“Product Requirement Doc” ink is 201 × 18, and cap + descender "
+ "is .9386em, so 18px. Weight from ink mass: the reference carries 1.125× "
+ "this string at 15px/700, where the same coverage sum on type that already "
+ "matches runs 1.00-1.23, which puts it near 500 rather than 700"),
+ ("Type", "t-doc-h", "500 11px/14px var(--x-font)",
+ "“Overview” ink 50px and “Description” 60px, both 11px; "
+ "700 sets Description 3px over and carries 1.1× the reference's ink mass"),
+ ("Type", "t-doc-b", "400 8px/11px var(--x-font)",
+ "the first doc paragraph is 222px of ink → 8px, and 8px ascender-to-descender "
+ "→ 8px; the measured pitch inside a paragraph is 11"),
+ ("Type", "t-comm-n", "600 13px/16px var(--x-font)",
+ "“Damia” cap 10px → 14, ink 34px → 10px; 13 splits them"),
+ ("Type", "t-comm-b", "400 12px/16px var(--x-font)",
+ "“The font size feels too small. Maybe we” cap 9px → 12.6, ink "
+ "220px → 11-12px; the measured line pitch is 16"),
+
+ ("Metrics", "w", "1954px", "the capture’s own width; the board is the section, 1x"),
+ ("Metrics", "h", "982px", "the capture’s own height"),
+ ("Metrics", "pad", "27px", "refkit bands: body ink at card left + 27 on all eight cards"),
+ ("Metrics", "label-top", "31px", "refkit bands: label ink top, card + 30 to + 33"),
+ ("Metrics", "body-top", "65px", "refkit bands: body line 1 ink top, card + 65 to + 67"),
+ ("Metrics", "gap", "21px", "column gap in band 1: 595→616, 1136→1157, 1497→1518"),
+ ("Metrics", "row-pitch", "85px", "refkit bands pitch on the five Subtasks rows"),
+ ("Metrics", "bulk-pitch", "66px", "refkit bands pitch on the four Bulk actions rows"),
+ ("Metrics", "o-text", ".82",
+ "Bulk’s third body line reads #878A6B where lines 1-2 read #727358: the same "
+ "ink at .82 over #ECEFBF solves 136, the measured 135"),
+ ("Metrics", "o-bulk-2", ".94",
+ "both row lists fade toward the bottom of their card, and a row’s own "
+ "opacity comes off the contrast between its title ink and the tile right above "
+ "that ink, divided by row 1’s: a ratio that cancels the right-edge fade, "
+ "which a fill sample does not. Bulk row 2 reads .935 on all three channels"),
+ ("Metrics", "o-bulk-3", ".55",
+ "Bulk row 3, .544/.540/.559. Its checkbox solves .57 instead, because it sits "
+ "3px above the toolbar inside the white ringing along that edge"),
+ ("Metrics", "o-bulk-4", ".04",
+ "Bulk row 4, .040/.037/.040, and its tile is 2 levels off the card fill in "
+ "blue and level with it in red and green"),
+ ("Metrics", "rot-pill", ".56deg",
+ "the Tasks tile is the one rotated box on the board: its top edge runs "
+ "774.70 at x 160 to 779.55 at x 700 and its bottom 831.53 to 836.33, two "
+ "independent fits on +0.0097 rad. Every other tile, panel and toolbar here "
+ "is flat to a third of a pixel"),
+ ("Metrics", "rot-ghost", "-1.04deg",
+ "the stack behind it turns the other way: its one visible edge is 859.25 at "
+ "x 200 and 850.88 at x 660, −0.0182 rad"),
+ ("Metrics", "o-subt-4", ".93",
+ "Subtasks row 4, .928 off its row id at x 1684. Its title reads .839 instead, "
+ "and the difference is the fade: the titles on rows 3 and 4 are indented to "
+ "x 1761 where the row ids and row 1 are not, and a ratio only cancels the "
+ "fade where both windows sit at the same x. Row 3 reads .99, so it is not "
+ "faded at all"),
+ ("Metrics", "o-subt-5", ".38",
+ "the last Subtasks row, .381/.389/.416 off its id and .33 off its title. The "
+ "two cards do not share one ramp: this row is 56px off its card’s bottom "
+ "and Bulk’s fourth is 46, and Bulk’s has all but gone"),
+]
+
+
+def _root():
+ """One :root block, byte-identical in every board. No `}` inside it:
+ refkit tokens reads it with a non-greedy regex."""
+ out, seen = [":root{"], None
+ for group, name, value, _ in TOKENS:
+ if group != seen:
+ out.append("" if seen else None)
+ out.append(" /* %s */" % group)
+ seen = group
+ out.append(" --x-%s:%s;" % (name, value))
+ return "\n".join(x for x in out if x is not None) + "\n}"
+
+
+TOKENS_CSS = _root()
+
+# ------------------------------------------------------------------ art ----
+def cut():
+ """Refresh assets/art/ from assets/refs/ at the boxes in crops.json."""
+ if not REFS_DIR.exists():
+ return
+ from PIL import Image # noqa: local dep
+ ART_DIR.mkdir(parents=True, exist_ok=True)
+ src, n = {}, 0
+ for cid, (ref, x0, y0, x1, y1) in CROPS.items():
+ f = REFS_DIR / (ref + ".png")
+ if not f.exists():
+ continue
+ if ref not in src:
+ src[ref] = Image.open(f).convert("RGB")
+ src[ref].crop((x0, y0, x1, y1)).save(ART_DIR / (cid + ".png"), optimize=True)
+ n += 1
+ print("%-24s %6d crops" % ("assets/art/", n))
+
+
+def app_icon():
+ """icon.png, the sticker the welcome card wears: the Dun mark, which the
+ capture holds at 60px and nowhere larger. The crop is the mark's own bbox
+ -- the disc spans x 0..59 across its middle rows and 21..38 at the top, so
+ it is a circle of r 30 at (29.5, 29.5) -- so the alpha is that circle
+ scaled to 128, supersampled 4x, and the mark itself is a 4.27x LANCZOS
+ upscale. It is soft at 256 and there is no sharper source; the alternative
+ was redrawing the loop, which would make the sticker the one thing in this
+ folder that is not the reference's pixels. Keeps the committed file when
+ Pillow is missing.
+ """
+ if not (ART_DIR / "logo-dun.png").exists():
+ return
+ try:
+ from PIL import Image, PngImagePlugin # noqa: local dep
+ import numpy as np
+ except ImportError:
+ print("icon.png kept: Pillow not installed")
+ return
+ N, ss = 256, 4
+ yy, xx = np.mgrid[0:N * ss, 0:N * ss] + .5
+ disc = ((xx - N * ss / 2) ** 2 + (yy - N * ss / 2) ** 2 <= (N * ss / 2) ** 2)
+ mask = disc.reshape(N, ss, N, ss).mean((1, 3))
+ im = Image.open(ART_DIR / "logo-dun.png").convert("RGB").resize((N, N), Image.LANCZOS)
+ im.putalpha(Image.fromarray(np.round(mask * 255).astype(np.uint8)))
+ # Every icon.png in mockups/canvases states its provenance in a PNG
+ # Source chunk; this one's is a crop of the capture rather than store art.
+ meta = PngImagePlugin.PngInfo()
+ meta.add_text("Source", "assets/art/logo-dun.png, crops.json logo-dun "
+ "(page 325,233 60 x 60), LANCZOS to 256")
+ im.save(OUT / "icon.png", pnginfo=meta)
+ print("%-24s %6s" % ("icon.png", "%dx%d" % im.size))
+
+
+def _uri(cid):
+ f = ART_DIR / (cid + ".png")
+ return ("data:image/png;base64," + base64.b64encode(f.read_bytes()).decode()
+ if f.exists() else "")
+
+
+def art(cid, ox, oy, extra=""):
+ """One , placed at the box it was measured from, relative to the card
+ it belongs to: every card is a positioned box at its own page offset
+ (ox, oy), so the crop's page box minus that offset is where it goes."""
+ _, x0, y0, x1, y1 = CROPS[cid]
+ return (''
+ % (cid, _uri(cid), x0 - ox, y0 - oy, x1 - x0, y1 - y0, extra))
+
+
+# Every icon's ink box on its own 24 x 24 grid, measured by rendering the whole
+# set at 10x (scratch/icongrid.html) and reading each cell's ink. Lucide's
+# nominal 2..22 inset is not what a stroked path covers -- several of these
+# reach 1..23, and the ellipsis covers 18 x 4 -- so the viewBox is set to the
+# measured box and the span to the measured px box. Scale is then 1:1 on both
+# axes with nothing to converge. The default xMidYMid meet binds one axis only,
+# which is how a 16 x 4 ellipsis renders at a quarter of its size and a size
+# loop never notices.
+GRID_INK = {
+ "archive-check": (1, 2, 22, 20),
+ "briefcase": (1, 1, 22, 21),
+ "check": (3, 5, 18, 13),
+ "circle-check": (1, 1, 22, 22),
+ "circle-empty": (1, 1, 22, 22),
+ "circle-half": (0, 0, 24, 24),
+ "corner-down-left": (3, 3, 18, 18),
+ "ellipsis": (3, 10, 18, 4),
+ "eye": (1, 3.5, 22, 17),
+ "megaphone": (2, 2.5, 19.5, 19.3),
+ "pencil": (1, 0, 22.1, 23),
+ "plus": (4, 4, 16, 16),
+ "square-pen": (2, 0.9, 21.1, 21.1),
+ "square-terminal": (2, 2, 20, 20),
+ "trash": (2, 1, 20, 22),
+ "triangle-alert": (1, 2, 22, 20),
+ "x": (5, 5, 14, 14),
+}
+
+
+def icon(name, x, y, w, colour="currentColor", h=None):
+ """Inline assets/icons/.svg at a measured ink box, in card
+ coordinates. (x, y, w, h) is the ink, not the 24-grid."""
+ svg = (ICON_DIR / (name + ".svg")).read_text().strip().replace("\n", "")
+ return (svg
+ .replace('viewBox="0 0 24 24"',
+ 'viewBox="%g %g %g %g"' % GRID_INK[name], 1)
+ .replace("