diff --git a/mockups/canvases/README.md b/mockups/canvases/README.md
index b0a304a1..fc5210ac 100644
--- a/mockups/canvases/README.md
+++ b/mockups/canvases/README.md
@@ -273,6 +273,28 @@ folder are invisible to it.
painted over five text classes without an error. `assets/art/` and
`assets/art-gen/` are committed; the eight `ref-*` boards are
gitignored, so a fresh clone has 13.
+- `flashcard-onboarding/`: the run to read when the only source is a **screen
+ recording**. Four onboarding screens of a flashcard app, from a clip posted
+ on X, in 13 boards across three rows: a token board, three evidence boards
+ for 28 tokens, an art board for 7 crops, the four replicas, and the video
+ frame behind each one column-for-column underneath. Mean absolute delta is
+ 6.17-7.05 levels (of 255), two to four times `duolingo-ios`', and the gap is
+ the medium: at 1.337 capture px per design pt a glyph is three pixels tall
+ through an H.264 encoder, so a sharp render scores an error the geometry
+ does not contain - the cropped hero art sits at 1.0-3.5 while the type zone
+ sits at 7.7-14.2 with every string inside 1.5pt of its reference box. Its
+ `README.md` carries the three findings that cost the most:
+ **`letter-spacing` inherits as a computed length**, so an `em` set on `body`
+ reaches a 36px title as body's own -0.4px and tracks it at a third of the
+ intent, which reads exactly like a display size that wants its own value;
+ the whole design is tracked at a single **-.025em**, proved by character
+ count rather than by width, which is also what told SF Pro from SF Pro
+ Rounded; and **LANCZOS undershoot** in the downscale step, which read as a
+ 20-level ink-core error until the resample became an area average. It also
+ prices the one deliberate deviation - the source's phone frame is a mockup
+ asset, with a 118 x 4.5 home indicator at bottom 2.5 - at exactly 1.00 delta
+ per screen. `assets/art/` is committed; the four `ref-*` boards are
+ gitignored, so a fresh clone has 9.
- `spotify-ios/`: the run to read when the brand face is unavailable and the
screens are mostly type. Five screens of the Spotify iOS app - the home
feed at two scroll positions, two full-screen promo modals and the Spotify
@@ -339,6 +361,33 @@ folder are invisible to it.
is worthless on grey, and that a blur which monotonically improves a band is
hiding a content defect rather than an antialiasing one. The 25 `ref-*`
boards are gitignored, so a fresh clone has 29.
+- `skillpath-ios/`: the run to read when the source is a **zooming** screen
+ recording, so there is no such thing as *the* capture scale. Five screens of
+ an unnamed gamified self-improvement app, from a clip posted on X, in 23
+ boards across three rows: six token boards and seven evidence boards for 73
+ tokens, the five replicas, and the video frame behind each one
+ column-for-column underneath. Those foundation boards are plural for a
+ reason worth borrowing: 73 rows is 4000px of table in a 980px artboard, and
+ `refkit shoot --check-overflow` only says so when you leave `--crop-phone`
+ off, and never says so at all about a table running off the *side*. Mean
+ absolute delta is 5.88-9.04 levels (of 255). Its `README.md` records the
+ three things that decided those numbers. **The device is 402 x 874, not the
+ 393 x 852 the rest of this repo uses** - an iPhone 17 Pro - and it was found
+ by scale agreement, where solving each frame's screen-inner rect against 402
+ x 874 makes its width and height scales agree to 0.07% against 0.34% for the
+ smaller frame. **The clip zooms, so every frame carries its own px-per-pt**
+ (1.01194 to 1.07985) and every one of the 59 rows in `probes.json` carries
+ its own `"pt"`, which is what `refkit batch` needs to replay them all in one
+ pass. And **the renderer's cap metrics are not the face's**: a probe of
+ eight font stacks came back byte-identical, so the boards are set in the
+ platform fallback, whose cap is 0.733 em and sits 0.350 x font-size below
+ the line box rather than SF Pro's 0.245 - inverting the nominal relation
+ instead of the measured one put every run on the board low by 3.3pt at
+ 31.5px and cost 1.50 delta on the busiest screen. All 20 type sizes are
+ fitted on ink width against a named probe rather than on cap height, which a
+ video frame inflates by 1-2pt; `CURRENT STREAK` read as 14px that way and
+ set 18% wide. `assets/art/` is committed for its 17 crops; the five `ref-*`
+ boards are gitignored, so a fresh clone has 18.
- `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/flashcard-onboarding/00-design-tokens.html b/mockups/canvases/flashcard-onboarding/00-design-tokens.html
new file mode 100644
index 00000000..b709d210
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/00-design-tokens.html
@@ -0,0 +1,102 @@
+
+
+
+
+Flashcard Onboarding - Design Tokens
+
+
+
+Flashcard Onboarding Four onboarding screens, measured off a 1080 × 1350 video at 1.337 capture px per design pt. Every value below is a measurement; the row that justifies it is on 00b, and the probe that produced it is in probes.json.
Colour
--f-pill-dark linear-gradient(180deg,#8A8A8A 0,#3C3C3C 6.8px,#141414 15.7px,#0F0F0F 30px,#090909 41px,#040404 100%)
--f-pill-grey linear-gradient(180deg,#DADADA 0,#D2D2D2 4.5px,#C4C4C4 9.8px,#C1C1C1 30px,#C1C1C1 100%)
--f-btn-dark linear-gradient(180deg,#8A8A8A 0,#3C3C3C 5px,#000000 12px,#000000 100%) Radius Type Grumpy wizards --f-t-eyebrow · 600 15px/18px
Grumpy wizards --f-t-title · 700 36px/42.3px
Grumpy wizards --f-t-sub · 400 15px/18.7px
Grumpy wizards --f-t-btn · 600 20px/24px
Grumpy wizards --f-t-legal · 400 13px/17.2px
Grumpy wizards --f-t-time · 590 17px/22px
Metrics --f-w: 393px --f-h: 852px --f-status: 54px --f-nav-y: 768.7px --f-nav-h: 56.8px
+
+
diff --git a/mockups/canvases/flashcard-onboarding/00b-evidence.html b/mockups/canvases/flashcard-onboarding/00b-evidence.html
new file mode 100644
index 00000000..6cac0f10
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/00b-evidence.html
@@ -0,0 +1,102 @@
+
+
+
+
+Flashcard Onboarding - Evidence 1/3
+
+
+
+--f-font system-ui,-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif SF Pro, not Rounded: the terminals on 'Create Account' are cut, not round, at 3x. Four measured strings run 3.3-5.2% narrower than system-ui, a 1.9-point spread that --f-tracking absorbs, against 2.5-7.1% and a 4.6-point spread for SF Pro Rounded --f-tracking -.025em every string is narrower than its untracked setting by an amount that scales with character count, not with width: 5.0px over 13 gaps on the eyebrow and 12.8 over 35 on the subtitle, ratio .391 against .371 predicted by tracking and .533 by a narrower face. The four strings solve to -.0257, -.0244, -.0248 and -.0266em across 15px and 36px, so one figure covers the whole ramp. It has to be set on each rule: letter-spacing inherits as a computed length, so an em on body reaches a 36px title as body's own -.4px and tracks it at a third of the intent --f-bg #FFFFFF flat census on c4 y700-760 is 100% #FDFDFD, but 2801 px elsewhere hit 255: the 2-level floor is H.264 flat-field quantization, not an off-white --f-card #FDFAF4 flat run c4 row 185, x 97.4..285.9 -- the warm panel behind the trophy --f-track #F4F4F4 flat run c1 col 120, y 770.2..787.4 and 800.9..824.8 -- the nav track --f-pill-off #C1C1C1 flat census inside the disabled back pill, 50.6% of 3221 flat px --f-hairline #EFEFEF Login outline: c4 row 651 x 364.0..365.5, c4 col 40 y 623.3..625.5 --f-ink #000000 ink core (darkest 2%) of the eyebrow, both title lines and the legal bold --f-ink-mid #3A3A3A ink core of the Login label, c4 --f-ink-2 #7E7E7E subtitle ink core #7D7D7D on c1 and c4, legal-line grey #7F7F7F; one token at the midpoint
+
+
diff --git a/mockups/canvases/flashcard-onboarding/00c-evidence.html b/mockups/canvases/flashcard-onboarding/00c-evidence.html
new file mode 100644
index 00000000..5b7ca04b
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/00c-evidence.html
@@ -0,0 +1,102 @@
+
+
+
+
+Flashcard Onboarding - Evidence 2/3
+
+
+
+--f-ink-inv #FFFFFF brightest 2% of the back arrow is #FEFEFE, on the same 2-level floor as bg --f-accent #F39D05 flat fill of the walked segment of the progress path, c2 and c3; the nodes themselves are 25pt across at 1.337 px/pt, too small for a census. Every amber element on a screen is cropped art, so this token is used only by the foundations boards --f-pill-dark linear-gradient(180deg,#8A8A8A 0,#3C3C3C 6.8px,#141414 15.7px,#0F0F0F 30px,#090909 41px,#040404 100%) column profile c2 col 336 (next) and col 55 (back), y 768.7..825.5; the two pills profile identically, so an enabled back pill is the same fill --f-pill-grey linear-gradient(180deg,#DADADA 0,#D2D2D2 4.5px,#C4C4C4 9.8px,#C1C1C1 30px,#C1C1C1 100%) column profile c1 col 55, y 768.7..825.5 -- back is disabled on 01 only --f-btn-dark linear-gradient(180deg,#8A8A8A 0,#3C3C3C 5px,#000000 12px,#000000 100%) column profile c4 col 340, y 560.5..616.6 -- Create Account --f-r-pill 999px by construction: both nav pills and both buttons are h/2 caps --f-r-phone 52px circular stand-in for the 55pt continuous display corner --f-t-eyebrow 600 15px/18px var(--f-font) cap 11.2 at 573.7..584.9 on c1; 'SMART LEARNING' spans 125.7 tracked --f-t-title 700 36px/42.3px var(--f-font) flat cap 'R' 25.4 on c4 (25.4/0.7046 = 36.0); baselines 629.8 and 672.1 --f-t-sub 400 15px/18.7px var(--f-font) flat cap 'E' 11.2 on c1 subtitle; line tops 688.1 and 706.8
+
+
diff --git a/mockups/canvases/flashcard-onboarding/00d-evidence.html b/mockups/canvases/flashcard-onboarding/00d-evidence.html
new file mode 100644
index 00000000..549756ad
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/00d-evidence.html
@@ -0,0 +1,102 @@
+
+
+
+
+Flashcard Onboarding - Evidence 3/3
+
+
+
+--f-t-btn 600 20px/24px var(--f-font) 'Login' cap top 643.2 to descender 661.2 solves 20.0 at cap .7046 desc .213 --f-t-legal 400 13px/17.2px var(--f-font) band 12.7 ascender-to-descender at 769.6..782.3, pitch 17.2 --f-t-time 590 17px/22px var(--f-font) status bar digits 12.7 tall at 20.2..32.9, spanning 32.2 untracked -- iOS chrome, so it opts out of --f-tracking --f-w 393px iPhone 15/16 logical width --f-h 852px iPhone 15/16 logical height --f-status 54px iOS status bar, Dynamic Island devices --f-nav-y 768.7px c1 col 336, the next pill's top edge --f-nav-h 56.8px c1 col 336, 768.7..825.5
+
+
diff --git a/mockups/canvases/flashcard-onboarding/00e-art.html b/mockups/canvases/flashcard-onboarding/00e-art.html
new file mode 100644
index 00000000..3ef68461
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/00e-art.html
@@ -0,0 +1,123 @@
+
+
+
+
+Flashcard Onboarding - Art assets
+
+
+
+Art assets 7 crops, cut from the four frames at the pt boxes in crops.json and placed back at the same numbers. Nothing here is generated and nothing here is redrawn: on 03 the Winner Board panel covers the medals behind it, so the medals cannot be recovered from any frame in the clip.
every screen with its chrome removed, so what is left is exactly what is an image
Every crop, in manifest order 01-hero c1 · 365×474 pt02-hero c2 · 365×474 pt03-hero c3 · 365×474 pt01-path c1 · 198×57 pt02-path c2 · 198×57 pt03-path c3 · 198×57 pt04-cup c4 · 282×236 pt
+
+
diff --git a/mockups/canvases/flashcard-onboarding/01-learn.html b/mockups/canvases/flashcard-onboarding/01-learn.html
new file mode 100644
index 00000000..02944e64
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/01-learn.html
@@ -0,0 +1,99 @@
+
+
+
+
+Flashcard Onboarding - Learn
+
+
+
+SMART LEARNING
Learn Anything, One Card at a Time
Explore different categories and build your vocabulary
+
+
diff --git a/mockups/canvases/flashcard-onboarding/02-challenges.html b/mockups/canvases/flashcard-onboarding/02-challenges.html
new file mode 100644
index 00000000..74b7dab5
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/02-challenges.html
@@ -0,0 +1,99 @@
+
+
+
+
+Flashcard Onboarding - Challenges
+
+
+
+COMPETE & ACHIEVE
Take on Challenges, Earn Your Medals
Complete challenges and collect medals as you learn and improve.
+
+
diff --git a/mockups/canvases/flashcard-onboarding/03-rankings.html b/mockups/canvases/flashcard-onboarding/03-rankings.html
new file mode 100644
index 00000000..d096b111
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/03-rankings.html
@@ -0,0 +1,99 @@
+
+
+
+
+Flashcard Onboarding - Rankings
+
+
+
+GLOBAL RANKING
Climb the Global Rankings
Earn medals, improve your rank, and compete with learners from around the world.
+
+
diff --git a/mockups/canvases/flashcard-onboarding/04-master.html b/mockups/canvases/flashcard-onboarding/04-master.html
new file mode 100644
index 00000000..d28e1f34
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/04-master.html
@@ -0,0 +1,99 @@
+
+
+
+
+Flashcard Onboarding - Master
+
+
+
+Ready to Master It?
Create an account and start learning
Create Account
Login
By continuing, you agree to our Terms of Use andPrivacy Policy
+
+
diff --git a/mockups/canvases/flashcard-onboarding/README.md b/mockups/canvases/flashcard-onboarding/README.md
new file mode 100644
index 00000000..1548ef2c
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/README.md
@@ -0,0 +1,189 @@
+# Flashcard onboarding
+
+The four-screen onboarding of a flashcard app, rebuilt from a screen
+recording posted by [@FarzadGodarz](https://x.com/FarzadGodarz/status/2096601915807187433).
+Three walkthrough pages and the sign-up screen, plus the token board, three
+evidence boards and the art board behind them. 13 boards, four of which park
+the source frame under its replica.
+
+| # | Board | What it shows |
+| --- | --- | --- |
+| 01 | `learn` | "Learn Anything, One Card at a Time", nav at step 1 |
+| 02 | `challenges` | "Take on Challenges, Earn Your Medals", step 2 |
+| 03 | `rankings` | "Climb the Global Rankings", step 3, Winner Board panel |
+| 04 | `master` | "Ready to Master It?", two buttons, legal line |
+| 00 | `design-tokens` | The 28 tokens, one `:root` shared by every board |
+| 00b–00d | `evidence` | What each token was measured on |
+| 00e | `art` | The seven crops, and where each one sits |
+
+A fresh clone builds the first nine of those. The four `ref-*` boards hold
+third-party video frames, so they are gitignored and `gen.py` skips them when
+`assets/refs/` is absent. `layout.json` still names them either way — the
+canvas drops a row entry whose file it cannot find, and a row that loses all
+of them, so a committed layout that mentions the references does not go dirty
+on a capture-less regeneration.
+
+## How close it lands
+
+Mean absolute delta against the source frames, whole 393 × 852 frame, phone
+crop, in levels of 255:
+
+| Screen | Δ | Screen | Δ |
+| --- | --- | --- | --- |
+| 01 Learn | 6.50 | 03 Rankings | 6.17 |
+| 02 Challenges | 6.64 | 04 Master | 7.05 |
+
+`refkit batch` replays all 30 Phase-1 probes against the renders: **13 colour
+probes at a mean Δmax of 0.9 and a worst case of 7**, and every edge scan
+within one source pixel. Nine of the 13 colour probes return an exact match.
+Three of the four box probes land within 1.5pt; the fourth is `sb-time`, which
+measures the source's clock and is deliberately no longer what the boards draw
+— see the chrome section below.
+
+These numbers are two to four times `duolingo-ios`', and the reason is the
+source, not the work. **A video frame is not a screenshot.** At 1.337 capture
+px per design pt every glyph is three pixels tall through an H.264 encoder,
+and a sharp render diffed against a soft frame scores an error the geometry
+does not contain. The zone breakdown says so: the hero art, which is cropped
+from the frame itself, sits at Δ 1.0–3.5, while the type zone sits at 7.7–14.2
+with every string matching the reference's ink box to within 1.5pt.
+
+Three structural items make up about 2.0 of each screen's score, all
+deliberate:
+
+- **The phone frame is the repo's, not the source's** — see below, 1.00.
+- **The status bar is the repo's too** — see below, 0.42.
+- **The rectangular crop keeps the bezel**, so the left and right 30pt of
+ every frame score 10.2 against a phone-cropped render's masked corners,
+ where the middle 333pt scores 5.5 to 6.5.
+
+## The face is SF Pro, and the whole design is tracked at -2.5%
+
+Called wrong twice before it was called right, and `refkit font` would not
+settle it (SF Pro Rounded 0.903, SF Pro 0.856). What settled it was width.
+
+At 3× the terminals on "Create Account" are **cut, not round**. Then the
+arithmetic: four strings across two sizes, ink width against the untracked
+setting of each candidate face.
+
+| String | size | ref | SF Pro | SF Pro Rounded |
+| --- | --- | --- | --- | --- |
+| `Ready to Master It?` | 36 | 299.9 | 311.7 | 321.7 |
+| `Learn Anything,` | 36 | 246.8 | 255.3 | 263.3 |
+| `Create an account and start learning` | 15 | 234.9 | 247.7 | 241.0 |
+| `SMART LEARNING` | 15 | 125.7 | 132.0 | 135.3 |
+
+SF Pro is uniformly 3.3–5.2% wide; SF Pro Rounded is 2.5–7.1% wide and
+inconsistent about it. A uniform residual is tracking; an inconsistent one is
+the wrong face.
+
+**The residual is tracking, and the test for that is character count.** A
+narrower face would shed width in proportion to the string's width; tracking
+sheds it in proportion to the number of gaps. Between the 13-gap eyebrow and
+the 35-gap subtitle at the same size, the observed reduction ratio is 0.391,
+against 0.371 predicted by tracking and 0.533 by a narrower face. Solved per
+string the four give -.0257, -.0244, -.0248 and -.0266em: **one figure,
+-.025em, across the whole ramp.**
+
+### The trap that hid it for three passes
+
+`letter-spacing` inherits as a **computed length**, not as a ratio. Setting
+`-.025em` on `body` computes against body's own 16px, and every child
+inherits the resulting **-0.4px** whatever its font-size. The 15px type
+looked right — -0.4px is within noise of the -0.375px it wanted — and the
+36px title got a third of its tracking, which read as "the title needs its
+own tighter value". Two rounds went into fitting a `--x-tracking-lg` that
+does not exist. Setting the same `var(--x-tracking)` on each type rule, so
+the `em` resolves at each element's own size, took the four screens from
+8.20/8.18/7.01/7.66 to **6.09/6.23/5.77/6.66** in one edit and deleted the
+second token.
+
+## Crop it, do not draw it
+
+Seven assets, listed in `crops.json`, cut from `assets/refs/cN.png` at a
+measured pt box, written to `assets/art/.png` and placed back by `art()`
+at the same numbers. 434 KB total.
+
+| id | what | size |
+| --- | --- | --- |
+| `01/02/03-hero` | the three hero illustrations, 365 × 474pt | 159/107/95 KB |
+| `01/02/03-path` | the progress path between the nav pills, 198 × 57pt | 7/9/11 KB |
+| `04-cup` | the trophy, 282 × 236pt | 54 KB |
+
+**Everything else is CSS**: both nav pills and their gradients, the track
+behind them, both arrows as inline SVG, the two buttons, all the type, and the
+whole status bar, island and home indicator, which come from the template.
+
+The heroes are cropped whole rather than rebuilt because screen 03 cannot be
+rebuilt at all — the Winner Board panel is a frosted overlay sitting on top of
+the medals, and what it covers is not on any frame in the clip. Cropping the
+three heroes to one rule keeps them consistent with it.
+
+## The chrome is the repo's, not the source's
+
+Measured off the frames:
+
+| | source | iOS / this repo |
+| --- | --- | --- |
+| Dynamic Island | 123 × 38.5 at top 8.6 | 125 × 36 at top 11 |
+| home indicator | 118.1 × 4.5 at bottom 2.5 | 139 × 5 at bottom 8 |
+
+Neither is close enough to be a rounding difference, and the two err in the
+same direction, so this is a designer's phone frame in the mockup rather than
+a real device. **The boards keep the repo's standard frame**, which every
+other canvas here uses, and that decision was priced rather than assumed:
+regenerating all four screens with the source's own numbers drops the tail
+band from Δ 49.0 to 22.6 and the status band from 16.8 to 13.9, for a
+**1.00 improvement in each screen's mean**. Consistency with the other
+canvases in this folder is worth one level.
+
+The status bar goes the same way, and for the same reason. The source's clock
+sits at ink top 20.2 centred on 60.2, and its right-hand cluster carries a
+fourth glyph between wifi and the battery that reads "32" — not an iOS bar
+either. The boards take `statusbar()`, the `.sb` rules and the inline-SVG
+glyph cluster **verbatim from `templates/gen.py`**, so the clock lands at ink
+top 23.2 centred on 71.2 and the cluster is the template's three glyphs. That
+costs a flat **0.42 per screen** — 6.08/6.22/5.75/6.63 became
+6.50/6.64/6.17/7.05, the same figure on all four — and it is why the `sb-time`
+probe no longer matches: its window ends at x 80 to stop short of the source's
+location arrow, and the repo's clock runs past it, so `batch` reports a clipped
+25.4 against the source's 32.2 rather than a narrow clock.
+
+## Defects in the source, kept
+
+- **Screen 04's buttons are not centred.** Both span x 12.5 to 365.8, so they
+ centre on 189.2 with a left margin of 12.5 and a right margin of 27.2,
+ while every text block on all four screens centres on 196.4. Replicated as
+ measured.
+- **Screens 02 and 03 look washed out**, and that is the design, not a
+ transition caught mid-fade. A 10 fps per-frame difference map over the whole
+ clip puts t = 1.50, 6.60, 12.00 and 17.60s each inside a still run of
+ half a second or more; the frosted Winner Board panel and the gradient-faded
+ list under it are painted that way.
+
+## Two measurement notes
+
+- **The white floor is #FDFDFD, not #FFFFFF.** A flat census of the page
+ ground returns 100% #FDFDFD, but 2801 pixels elsewhere in the same frame hit
+ 255. That is H.264 flat-field quantization across the whole capture, so `bg`
+ is `#FFFFFF` and every colour probe carries a systematic 2 levels against it.
+- **Downscale renders with an area average, not LANCZOS.** The verify loop
+ shoots at 3× and resamples to the capture's 525 × 1140. LANCZOS rings, and
+ the undershoot lands exactly where ink probes read: the Login label's ink
+ core came back #262626 against the reference's #3A3A3A, and the subtitle's
+ #6E6E6E against a #7E7E7E token, both of which read correctly on the
+ un-resampled 3× shot. Switching to `Image.BOX` took the 13 colour probes
+ from a mean Δmax of 4.6 and a worst case of 20 to **0.9 and 7**, and
+ improved the frame diff slightly as well.
+
+## Rebuilding
+
+```bash
+python3 mockups/canvases/flashcard-onboarding/gen.py
+refkit tokens mockups/canvases/flashcard-onboarding
+```
+
+`gen.py` is the only source of truth; the `NN-*.html` boards are its output.
+It re-cuts `assets/art/` from `assets/refs/` when the captures are present and
+leaves the committed crops alone when they are not, so the nine non-reference
+boards regenerate byte-identically from a fresh clone.
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/01-hero.png b/mockups/canvases/flashcard-onboarding/assets/art/01-hero.png
new file mode 100644
index 00000000..fcbdb8d9
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/01-hero.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/01-path.png b/mockups/canvases/flashcard-onboarding/assets/art/01-path.png
new file mode 100644
index 00000000..ae3632de
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/01-path.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/02-hero.png b/mockups/canvases/flashcard-onboarding/assets/art/02-hero.png
new file mode 100644
index 00000000..a68075a1
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/02-hero.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/02-path.png b/mockups/canvases/flashcard-onboarding/assets/art/02-path.png
new file mode 100644
index 00000000..6804bc77
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/02-path.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/03-hero.png b/mockups/canvases/flashcard-onboarding/assets/art/03-hero.png
new file mode 100644
index 00000000..355813da
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/03-hero.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/03-path.png b/mockups/canvases/flashcard-onboarding/assets/art/03-path.png
new file mode 100644
index 00000000..d1e2b899
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/03-path.png differ
diff --git a/mockups/canvases/flashcard-onboarding/assets/art/04-cup.png b/mockups/canvases/flashcard-onboarding/assets/art/04-cup.png
new file mode 100644
index 00000000..5fe9c846
Binary files /dev/null and b/mockups/canvases/flashcard-onboarding/assets/art/04-cup.png differ
diff --git a/mockups/canvases/flashcard-onboarding/crops.json b/mockups/canvases/flashcard-onboarding/crops.json
new file mode 100644
index 00000000..49a67989
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/crops.json
@@ -0,0 +1,52 @@
+{
+ "_": "Art cut from assets/refs/cN.png at measured pt boxes, 1.337 px per pt. gen.py writes assets/art/.png and places each back at its own box, so an asset cannot drift from where it was measured. Everything else on these screens -- the type, the nav track, both pills, the arrows, the two buttons, the status bar clock -- is CSS.",
+ "01-hero": [
+ "c1",
+ 13.5,
+ 56.2,
+ 378.5,
+ 530.5
+ ],
+ "02-hero": [
+ "c2",
+ 13.5,
+ 56.2,
+ 378.5,
+ 530.5
+ ],
+ "03-hero": [
+ "c3",
+ 13.5,
+ 56.2,
+ 378.5,
+ 530.5
+ ],
+ "01-path": [
+ "c1",
+ 97.2,
+ 768.7,
+ 295.5,
+ 825.5
+ ],
+ "02-path": [
+ "c2",
+ 97.2,
+ 768.7,
+ 295.5,
+ 825.5
+ ],
+ "03-path": [
+ "c3",
+ 97.2,
+ 768.7,
+ 295.5,
+ 825.5
+ ],
+ "04-cup": [
+ "c4",
+ 58.0,
+ 172.0,
+ 340.0,
+ 408.0
+ ]
+}
diff --git a/mockups/canvases/flashcard-onboarding/gen.py b/mockups/canvases/flashcard-onboarding/gen.py
new file mode 100644
index 00000000..df71d9d6
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/gen.py
@@ -0,0 +1,598 @@
+"""Emit mockups/canvases/flashcard-onboarding/ from four frames of a video.
+
+Four onboarding screens at 393 x 852 pt, cloned from a 1080 x 1350 H.264 clip
+of a flashcard app walkthrough. The phone's screen window inside that clip is
+525 x 1140 px, so the scale is 1.337 px per pt (525/393 = 1.3359, 1140/852 =
+1.3380, 0.16% apart), and every number below was read at that scale.
+
+Chrome is CSS -- type, the nav track, both pills, the arrows, the two buttons,
+the status bar clock. Illustration is cropped out of the frames at its own
+measured box: `crops.json` names each box, `cut()` writes assets/art/.png
+and `art()` places the back at the same numbers, so an asset cannot
+drift from where it was measured. See the README for what that costs here.
+
+ python3 mockups/canvases/flashcard-onboarding/gen.py
+
+Artboards are output. Edit this file, never the HTML.
+"""
+import base64
+import json
+from pathlib import Path
+
+OUT = Path(__file__).resolve().parent
+REFS_DIR = OUT / "assets" / "refs"
+ART_DIR = OUT / "assets" / "art"
+CROPS = {k: v for k, v in json.loads((OUT / "crops.json").read_text()).items()
+ if not k.startswith("_")}
+SCALE = 1.337 # capture px per design pt
+
+NAME = "Flashcard Onboarding"
+PAGE_NAME = "(example) " + NAME
+P = "f"
+
+# ---------------------------------------------------------------- tokens ----
+# (group, name, value, evidence). Every box below is design pt on the 525 x
+# 1140 crops in assets/refs, replayable from probes.json with --pt 1.337.
+TOKENS = [
+ ("Font", "font",
+ 'system-ui,-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif',
+ "SF Pro, not Rounded: the terminals on 'Create Account' are cut, not round, "
+ "at 3x. Four measured strings run 3.3-5.2% narrower than system-ui, a "
+ "1.9-point spread that --x-tracking absorbs, against 2.5-7.1% and a "
+ "4.6-point spread for SF Pro Rounded"),
+ ("Font", "tracking", "-.025em",
+ "every string is narrower than its untracked setting by an amount that "
+ "scales with character count, not with width: 5.0px over 13 gaps on the "
+ "eyebrow and 12.8 over 35 on the subtitle, ratio .391 against .371 "
+ "predicted by tracking and .533 by a narrower face. The four strings solve "
+ "to -.0257, -.0244, -.0248 and -.0266em across 15px and 36px, so one "
+ "figure covers the whole ramp. It has to be set on each rule: "
+ "letter-spacing inherits as a computed length, so an em on body reaches a "
+ "36px title as body's own -.4px and tracks it at a third of the intent"),
+
+ ("Surface", "bg", "#FFFFFF",
+ "flat census on c4 y700-760 is 100% #FDFDFD, but 2801 px elsewhere hit 255: "
+ "the 2-level floor is H.264 flat-field quantization, not an off-white"),
+ ("Surface", "card", "#FDFAF4",
+ "flat run c4 row 185, x 97.4..285.9 -- the warm panel behind the trophy"),
+ ("Surface", "track", "#F4F4F4",
+ "flat run c1 col 120, y 770.2..787.4 and 800.9..824.8 -- the nav track"),
+ ("Surface", "pill-off", "#C1C1C1",
+ "flat census inside the disabled back pill, 50.6% of 3221 flat px"),
+
+ ("Line", "hairline", "#EFEFEF",
+ "Login outline: c4 row 651 x 364.0..365.5, c4 col 40 y 623.3..625.5"),
+
+ ("Ink", "ink", "#000000",
+ "ink core (darkest 2%) of the eyebrow, both title lines and the legal bold"),
+ ("Ink", "ink-mid", "#3A3A3A", "ink core of the Login label, c4"),
+ ("Ink", "ink-2", "#7E7E7E",
+ "subtitle ink core #7D7D7D on c1 and c4, legal-line grey #7F7F7F; "
+ "one token at the midpoint"),
+ ("Ink", "ink-inv", "#FFFFFF",
+ "brightest 2% of the back arrow is #FEFEFE, on the same 2-level floor as bg"),
+
+ ("Accent", "accent", "#F39D05",
+ "flat fill of the walked segment of the progress path, c2 and c3; the nodes "
+ "themselves are 25pt across at 1.337 px/pt, too small for a census. Every "
+ "amber element on a screen is cropped art, so this token is used only by "
+ "the foundations boards"),
+
+ ("Fill", "pill-dark",
+ "linear-gradient(180deg,#8A8A8A 0,#3C3C3C 6.8px,#141414 15.7px,"
+ "#0F0F0F 30px,#090909 41px,#040404 100%)",
+ "column profile c2 col 336 (next) and col 55 (back), y 768.7..825.5; the "
+ "two pills profile identically, so an enabled back pill is the same fill"),
+ ("Fill", "pill-grey",
+ "linear-gradient(180deg,#DADADA 0,#D2D2D2 4.5px,#C4C4C4 9.8px,"
+ "#C1C1C1 30px,#C1C1C1 100%)",
+ "column profile c1 col 55, y 768.7..825.5 -- back is disabled on 01 only"),
+ ("Fill", "btn-dark",
+ "linear-gradient(180deg,#8A8A8A 0,#3C3C3C 5px,#000000 12px,#000000 100%)",
+ "column profile c4 col 340, y 560.5..616.6 -- Create Account"),
+
+ ("Radius", "r-pill", "999px",
+ "by construction: both nav pills and both buttons are h/2 caps"),
+ ("Radius", "r-phone", "52px",
+ "circular stand-in for the 55pt continuous display corner"),
+
+ ("Type", "t-eyebrow", "600 15px/18px var(--x-font)",
+ "cap 11.2 at 573.7..584.9 on c1; 'SMART LEARNING' spans 125.7 tracked"),
+ ("Type", "t-title", "700 36px/42.3px var(--x-font)",
+ "flat cap 'R' 25.4 on c4 (25.4/0.7046 = 36.0); baselines 629.8 and 672.1"),
+ ("Type", "t-sub", "400 15px/18.7px var(--x-font)",
+ "flat cap 'E' 11.2 on c1 subtitle; line tops 688.1 and 706.8"),
+ ("Type", "t-btn", "600 20px/24px var(--x-font)",
+ "'Login' cap top 643.2 to descender 661.2 solves 20.0 at cap .7046 desc .213"),
+ ("Type", "t-legal", "400 13px/17.2px var(--x-font)",
+ "band 12.7 ascender-to-descender at 769.6..782.3, pitch 17.2"),
+ ("Type", "t-time", "590 17px/22px var(--x-font)",
+ "status bar digits 12.7 tall at 20.2..32.9, spanning 32.2 untracked -- "
+ "iOS chrome, so it opts out of --x-tracking"),
+
+ ("Metrics", "w", "393px", "iPhone 15/16 logical width"),
+ ("Metrics", "h", "852px", "iPhone 15/16 logical height"),
+ ("Metrics", "status", "54px", "iOS status bar, Dynamic Island devices"),
+ ("Metrics", "nav-y", "768.7px", "c1 col 336, the next pill's top edge"),
+ ("Metrics", "nav-h", "56.8px", "c1 col 336, 768.7..825.5"),
+]
+
+
+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 # a fresh clone has no captures; art/ is committed
+ try:
+ from PIL import Image # noqa: local dep
+ except ImportError:
+ print("%-24s %6s (no Pillow; assets/art/ left as committed)"
+ % ("assets/art/", "-"))
+ return
+ 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")
+ box = tuple(round(v * SCALE) for v in (x0, y0, x1, y1))
+ src[ref].crop(box).save(ART_DIR / (cid + ".png"), optimize=True)
+ n += 1
+ print("%-24s %6d crops" % ("assets/art/", n))
+
+
+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):
+ """One , placed at the box it was measured from."""
+ _, x0, y0, x1, y1 = CROPS[cid]
+ return (' ' % (_uri(cid), x0, y0, x1 - x0, y1 - y0))
+
+
+# ------------------------------------------------- placing type by its ink ----
+# Every type position in this folder was measured as a baseline, because a
+# baseline is the one thing a string of any content puts in the same place: an
+# ink top moves with the tallest glyph, and "Climb the Global" has no
+# descender where "Take on Challenges," has two. SF Pro Rounded reports hhea
+# ascender .952 and descender .213 per em, and a browser centres that
+# 1.165em content box in the line box, so:
+ASC, DESC = 0.952, 0.213
+
+
+def bt(baseline, fs, lh):
+ """The `top` that lands a single line's baseline where it was measured."""
+ return baseline - ((lh - (ASC + DESC) * fs) / 2 + ASC * fs)
+
+
+# ------------------------------------------------------------ phone frame ----
+BASE = """*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:var(--x-font);-webkit-font-smoothing:antialiased;
+ display:flex;justify-content:center;padding:24px}"""
+
+PHONE = """.phone{position:relative;flex:none;width:var(--x-w);height:var(--x-h);
+ border-radius:var(--x-r-phone);overflow:hidden;background:var(--x-bg);color:var(--x-ink);transform:translateZ(0);
+ box-shadow:0 0 0 11px #1D191A,0 0 0 12.5px #3A3735,0 24px 60px rgba(29,25,26,.28)}
+.sb{position:absolute;left:0;top:0;width:var(--x-w);height:var(--x-status);z-index:6}
+.sb .time{position:absolute;left:0;top:18.2px;width:142.4px;text-align:center;
+ font:var(--x-t-time)}
+.sb svg{position:absolute;display:block;fill:currentColor}
+.sb .island{position:absolute;top:11px;left:50%;transform:translateX(-50%);
+ width:125px;height:36px;border-radius:20px;background:#000}
+.home{position:absolute;left:50%;bottom:8px;transform:translateX(-50%);
+ width:139px;height:5px;border-radius:3px;background:currentColor;z-index:6}
+img.a{position:absolute;display:block}"""
+
+# Cellular, wifi, battery at their measured status-bar positions, inheriting
+# currentColor so one call recolours the whole bar.
+SB_ICONS = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ ''
+ ' '
+ ''
+ ' '
+ ' ')
+
+# The repo's standard status bar, lifted from templates/gen.py, chrome and all.
+# The source's own bar differs -- the clock sits 2.5pt higher and the right-hand
+# cluster carries a fourth glyph reading "32" between wifi and the battery --
+# but the frame around a board is the repo's, not the source's, the same call
+# the Dynamic Island and the home indicator already make. See the README.
+def statusbar(colour="var(--x-ink)", time="9:41"):
+ return (''
+ % (colour, time, SB_ICONS))
+
+
+def home(colour="var(--x-ink)"):
+ return '
' % colour
+
+
+# ----------------------------------------------------------------- emit ----
+def page(title, body, extra_css=""):
+ html = ('\n\n\n \n'
+ '%s \n\n\n\n%s\n\n\n'
+ % (title, TOKENS_CSS, BASE, PHONE, extra_css, body))
+ return html.replace("--x-", "--%s-" % P)
+
+
+def write(name, html):
+ (OUT / (name + ".html")).write_text(html)
+ print(name, len(html))
+
+
+# --------------------------------------------------- foundations boards ----
+SHEET = """body{padding:0;background:var(--x-bg);color:var(--x-ink)}
+.sheet{width:478px;height:980px;padding:20px;overflow:hidden}
+h1{font:600 17px/22px var(--x-font);margin-bottom:2px}
+header p{font:400 11px/15px var(--x-font);color:var(--x-ink-2);margin-bottom:14px}
+h2{font:600 9px/12px var(--x-font);letter-spacing:.8px;text-transform:uppercase;
+ color:var(--x-ink-2);margin:12px 0 5px}
+.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
+.sw .chip{height:26px;border-radius:6px;border:1px solid var(--x-hairline)}
+.sw b{display:block;margin-top:3px;font:600 8.5px/11px ui-monospace,Menlo,monospace}
+.sw i{display:block;font:400 8px/11px ui-monospace,Menlo,monospace;
+ color:var(--x-ink-2);font-style:normal;word-break:break-all}
+.rad{display:flex;gap:9px}
+.rb{width:44px;height:26px;background:var(--x-card);border:1px solid var(--x-hairline)}
+.rad em{display:block;margin-top:2px;font:400 8.5px/11px var(--x-font);
+ color:var(--x-ink-2);font-style:normal;text-align:center}
+.tr{display:flex;align-items:baseline;justify-content:space-between;gap:10px;
+ padding-bottom:2px;border-bottom:1px solid var(--x-hairline)}
+.tr span{white-space:nowrap;overflow:hidden}
+.tr em{font:400 8px/11px ui-monospace,Menlo,monospace;color:var(--x-ink-2);
+ font-style:normal;white-space:nowrap;flex:none}
+.met{font:400 9px/13px ui-monospace,Menlo,monospace;color:var(--x-ink-2)}
+table.ev{width:100%;border-collapse:collapse}
+table.ev td{vertical-align:top;padding:2.5px 6px 2.5px 0;
+ border-bottom:1px solid var(--x-hairline);font:400 8.5px/11px var(--x-font)}
+td.t,td.v{font-family:ui-monospace,Menlo,monospace}
+td.t{color:var(--x-accent);white-space:nowrap}
+td.v{color:var(--x-ink-mid);max-width:132px;word-break:break-all}
+td.e{color:var(--x-ink-2)}"""
+
+
+def _of(group):
+ return [t for t in TOKENS if t[0] == group]
+
+
+def token_board():
+ swatches = "".join(
+ '' % (n, n, v)
+ for g in ("Surface", "Line", "Ink", "Accent", "Fill") for _, n, v, _ in _of(g))
+ radii = "".join(
+ '' % (v, v)
+ for _, n, v, _ in _of("Radius") if n != "r-phone")
+ type_ = "".join(
+ 'Grumpy wizards '
+ '--x-%s · %s
' % (n, n, v.split(" var")[0])
+ for _, n, v, _ in _of("Type"))
+ met = " ".join("--x-%s: %s" % (n, v) for _, n, v, _ in _of("Metrics"))
+ return page(NAME + " - Design Tokens",
+ '%s '
+ 'Four onboarding screens, measured off a 1080 × 1350 video '
+ 'at 1.337 capture px per design pt. Every value below is a '
+ 'measurement; the row that justifies it is on 00b, and the probe '
+ 'that produced it is in probes.json.
'
+ '
Colour %s
'
+ '
Radius %s
'
+ '
Type %s'
+ '
Metrics %s
'
+ % (NAME, swatches, radii, type_, met), SHEET)
+
+
+EV_ROWS = 10 # rows that fit the 478 x 980 box; the table splits past this
+ART_BOARD = "00%s-art" % "bcdefg"[-(-len(TOKENS) // EV_ROWS)] # after the last one
+
+
+def evidence_boards():
+ """The evidence table, split across as many boards as it needs. It is the
+ deliverable of Phase 1: trim the board count, never the rows."""
+ pages = [TOKENS[i:i + EV_ROWS] for i in range(0, len(TOKENS), EV_ROWS)]
+ for i, chunk in enumerate(pages):
+ rows = "".join(
+ '--x-%s %s %s '
+ % (n, v, e) for _, n, v, e in chunk)
+ of = " %d/%d" % (i + 1, len(pages)) if len(pages) > 1 else ""
+ yield ("00%s-evidence" % "bcdefg"[i],
+ page(NAME + " - Evidence" + of,
+ '' % (of, rows), SHEET))
+
+
+# ------------------------------------------------------------ art board ----
+# The board that answers "which of this is a picture?". Every crop is declared
+# once as a background-image class and used twice: at its measured pt box on a
+# scaled-down phone, and again in the contact sheet.
+MINI_W = 100.0
+MINI_S = MINI_W / 393.0
+
+ART_BOARD_CSS = SHEET + """
+.rule{font:400 9px/13px ui-monospace,Menlo,monospace;color:var(--x-ink-2);
+ margin:-10px 0 13px}
+.minis{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:2px}
+.mini{position:relative;width:%.1fpx;height:%.1fpx;overflow:hidden;
+ border-radius:9px;border:1px solid var(--x-hairline);background:var(--x-bg)}
+.mini .in{position:absolute;left:0;top:0;width:393px;height:852px;
+ transform:scale(%.5f);transform-origin:0 0}
+.mini em{position:absolute;left:5px;bottom:4px;font-style:normal;
+ font:600 8px/11px ui-monospace,Menlo,monospace;color:var(--x-ink-mid);
+ background:rgba(255,255,255,.9);padding:1px 4px;border-radius:3px}
+b.i{display:block;background-repeat:no-repeat}
+.mini b.i{position:absolute;background-size:100%% 100%%}
+.cs{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
+.cs figure{border:1px solid var(--x-hairline);border-radius:5px;padding:4px;
+ background:var(--x-bg)}
+.cs b.i{height:74px;background-size:contain;background-position:center}
+.cs figcaption{margin-top:3px;font:400 8px/11px ui-monospace,Menlo,monospace;
+ color:var(--x-ink-2)}
+.cs figcaption b{color:var(--x-ink-mid)}
+""" % (MINI_W, round(852 * MINI_S, 1), MINI_S)
+
+
+def art_classes():
+ """One background-image rule per crop, so each data URI is embedded once."""
+ return "".join(".i-%s{background-image:url(%s)}" % (c, _uri(c))
+ for c in CROPS if _uri(c))
+
+
+def tile(cid, style=""):
+ return ' ' % (cid, ' style="%s"' % style if style else "")
+
+
+def mini(ref, label):
+ """One screen scaled down, carrying its art and nothing else."""
+ ids = [c for c in CROPS if c.startswith(ref + "-")]
+ inner = "".join(
+ tile(c, "left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx"
+ % (CROPS[c][1], CROPS[c][2],
+ CROPS[c][3] - CROPS[c][1], CROPS[c][4] - CROPS[c][2]))
+ for c in ids)
+ # data-clip-ok: .in keeps its 393x852 layout box under the transform,
+ # so the overflow check sees a clip that is not one.
+ return ('' % (inner, label, len(ids)))
+
+
+def art_board():
+ minis = "".join(mini(s[:2], s[3:]) for s, _, _ in SCREENS)
+ sheet_ = "".join(
+ '%s%s %s · %.0f×%.0f pt'
+ ' '
+ % (tile(c), c, CROPS[c][0], CROPS[c][3] - CROPS[c][1],
+ CROPS[c][4] - CROPS[c][2])
+ for c in CROPS if _uri(c))
+ return page(
+ NAME + " - Art assets",
+ 'Art assets '
+ '%d crops, cut from the four frames at the pt boxes in '
+ 'crops.json and placed back at the same numbers. Nothing '
+ 'here is generated and nothing here is redrawn: on 03 the Winner Board '
+ 'panel covers the medals behind it, so the medals cannot be recovered '
+ 'from any frame in the clip.
'
+ '
every screen with its chrome removed, so what is left '
+ 'is exactly what is an image
'
+ '
%s
'
+ '
Every crop, in manifest order '
+ '
%s
'
+ % (len(CROPS), minis, sheet_),
+ ART_BOARD_CSS + "\n" + art_classes())
+
+
+# --------------------------------------------------------------- screens ----
+# Every `top` below is bt() applied to a measured baseline, so the numbers in
+# the source read as the numbers in probes.json.
+SCREEN_CSS = """.c{position:absolute;left:0;width:var(--x-w);text-align:center;
+ letter-spacing:var(--x-tracking)}
+.eyebrow{font:var(--x-t-eyebrow)}
+.title{font:var(--x-t-title)}
+.sub{font:var(--x-t-sub);color:var(--x-ink-2)}
+
+.track{position:absolute;left:12.7px;top:769.5px;width:365.8px;height:55.3px;
+ border-radius:var(--x-r-pill);background:var(--x-track)}
+.pill{position:absolute;top:var(--x-nav-y);height:var(--x-nav-h);
+ border-radius:var(--x-r-pill);color:var(--x-ink-inv)}
+.pill.back{left:12.7px;width:84.5px;background:var(--x-pill-grey)}
+.pill.back.on{background:var(--x-pill-dark)}
+.pill.next{left:295.5px;width:83px;background:var(--x-pill-dark)}
+.pill svg{position:absolute;left:50%;top:21.9px;margin-left:-9.35px;
+ display:block;width:18.7px;height:14.2px}
+
+.btn{position:absolute;left:12.7px;width:352.8px;border-radius:var(--x-r-pill)}
+.btn1{top:560.5px;height:56.1px;background:var(--x-btn-dark)}
+.btn2{top:623.1px;height:56.1px;background:var(--x-bg);
+ border:1.5px solid var(--x-hairline)}
+.lbl{position:absolute;left:12.7px;width:352.8px;text-align:center;
+ letter-spacing:var(--x-tracking);
+ font:var(--x-t-btn)}
+.legal{font:var(--x-t-legal);color:var(--x-ink-2)}
+.legal b{font-weight:600;color:var(--x-ink)}"""
+
+ARROW = (''
+ ' ')
+ARROW_BACK = ARROW.replace('%s' % (cls, top, html)
+
+
+def nav(ref):
+ """The paged nav. Back is disabled on 01 -- there is no page behind it --
+ and enabled from 02 on, where it takes the same fill as next: the two
+ pills profile identically down c2 col 55 and col 336."""
+ return ('
%s'
+ '%s
%s
'
+ % (art(ref + "-path"), "" if ref == "01" else " on",
+ ARROW_BACK, ARROW))
+
+
+def onboarding(ref, eyebrow, title, sub):
+ """Screens 01-03: hero card, three type levels, the paged nav."""
+ return ('%s%s%s%s%s%s%s
'
+ % (statusbar(), art(ref + "-hero"),
+ blk("eyebrow", bt(584.9, 15, 18), eyebrow),
+ blk("title", bt(629.8, 36, 42.3), title),
+ blk("sub", bt(699.3, 15, 18.7), sub),
+ nav(ref), home()))
+
+
+def s01():
+ return page(NAME + " - Learn", onboarding(
+ "01", "SMART LEARNING",
+ "Learn Anything, One Card at a Time",
+ "Explore different categories and build your vocabulary"), SCREEN_CSS)
+
+
+def s02():
+ return page(NAME + " - Challenges", onboarding(
+ "02", "COMPETE & ACHIEVE",
+ "Take on Challenges, Earn Your Medals",
+ "Complete challenges and collect medals as you learn and improve."),
+ SCREEN_CSS)
+
+
+def s03():
+ return page(NAME + " - Rankings", onboarding(
+ "03", "GLOBAL RANKING",
+ "Climb the Global Rankings",
+ "Earn medals, improve your rank, and compete "
+ "with learners from around the world."), SCREEN_CSS)
+
+
+def s04():
+ # The two buttons are 12.7 from the left edge and 27.5 from the right, so
+ # they centre on 189.1 while every other block on the screen centres on
+ # 196.4. That is the source's, not this board's: see the README.
+ return page(NAME + " - Master", '%s%s%s%s'
+ '
'
+ '
Create Account
'
+ '
Login
'
+ '%s%s
'
+ % (statusbar(), art("04-cup"),
+ blk("title", bt(498.9, 36, 42.3), "Ready to Master It?"),
+ blk("sub", bt(525.8, 15, 18.7),
+ "Create an account and start learning"),
+ bt(595.2, 20, 24), bt(657.3, 20, 24),
+ blk("legal", bt(779.5, 13, 17.2),
+ "By continuing, you agree to our Terms of Use and "
+ "Privacy Policy "),
+ home()), SCREEN_CSS)
+
+
+SCREENS = [("01-learn", "Learn", s01), ("02-challenges", "Challenges", s02),
+ ("03-rankings", "Rankings", s03), ("04-master", "Master", s04)]
+
+# ------------------------------------------------- Phase 5: the reference ----
+# Each frame, cropped to the phone's screen window and otherwise unretouched,
+# on its own board. The caption is what makes the replica auditable later.
+REF_CSS = """.rboard{width:430px;height:932px;background:#151311;border-radius:20px;
+ padding:14px 20px 12px;color:#fff;position:relative;overflow:hidden}
+.rboard h1{font:600 14px/18px var(--x-font);letter-spacing:-.1px}
+.rboard p{font:400 9.5px/13px ui-monospace,Menlo,monospace;color:rgba(255,255,255,.5);margin-top:2px}
+.rboard .shot{margin-top:9px;display:flex;justify-content:center}
+.rboard img{height:844px;width:auto;display:block;border-radius:6px}
+.rboard .near{color:#F1CD8A}"""
+
+# (screen file, label, capture, note)
+REFS = [("01-learn", "Learn", "c1", "exact - t=1.50s"),
+ ("02-challenges", "Challenges", "c2", "exact - t=6.60s"),
+ ("03-rankings", "Rankings", "c3", "exact - t=12.00s"),
+ ("04-master", "Master", "c4", "exact - t=17.60s")]
+
+
+def ref_boards():
+ """One board per parked frame. A fresh clone has no captures -- they are
+ third-party and gitignored -- so this yields nothing and the reference row
+ drops out of layout.json rather than shipping four broken images."""
+ for name, label, cap, note in REFS:
+ f = REFS_DIR / (cap + ".png")
+ if not f.exists():
+ continue
+ uri = "data:image/png;base64," + base64.b64encode(f.read_bytes()).decode()
+ cls = "" if note.startswith("exact") else ' class="near"'
+ body = ('%s — reference '
+ '
%s · video frame · 525×1140 @1.337x · '
+ '%s
'
+ '
'
+ % (label, name, cls, note, uri, label))
+ yield "ref-" + name, page(NAME + " - reference: " + label, body, REF_CSS)
+
+
+# ------------------------------------------------------------------ run ----
+cut()
+write("00-design-tokens", token_board())
+for name, html in evidence_boards():
+ write(name, html)
+write(ART_BOARD, art_board())
+for name, _, fn in SCREENS:
+ write(name, fn())
+for name, html in ref_boards():
+ write(name, html)
+
+LAYOUT = {
+ "name": PAGE_NAME,
+ "rows": [
+ {"title": "Foundations",
+ "files": [{"file": "00-design-tokens", "label": "Design tokens"}]
+ + [{"file": n, "label": "Evidence"} for n, _ in evidence_boards()]
+ + [{"file": ART_BOARD, "label": "Art assets"}]},
+ {"title": "Screens", "numbered": True,
+ "files": [{"file": n, "label": l} for n, l, _ in SCREENS]},
+ # Same order as the row above: the canvas lays every row out from x = 0 at
+ # one pitch, so item N here lands column-for-column under item N up there.
+ # The row stays in layout.json even on a clone with no captures, where the
+ # boards it names do not exist; the canvas skips an entry it cannot find,
+ # and keeping the row means the file does not go dirty on regeneration.
+ {"title": "Source of truth: video frames", "numbered": True,
+ "files": [{"file": "ref-" + n, "label": l} for n, l, _, _ in REFS]},
+ ],
+}
+(OUT / "layout.json").write_text(json.dumps(LAYOUT, indent=2) + "\n")
+print("layout.json", len(LAYOUT["rows"]), "rows")
+print("\nnext: refkit tokens", OUT)
diff --git a/mockups/canvases/flashcard-onboarding/layout.json b/mockups/canvases/flashcard-onboarding/layout.json
new file mode 100644
index 00000000..0647be60
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/layout.json
@@ -0,0 +1,74 @@
+{
+ "name": "(example) Flashcard Onboarding",
+ "rows": [
+ {
+ "title": "Foundations",
+ "files": [
+ {
+ "file": "00-design-tokens",
+ "label": "Design tokens"
+ },
+ {
+ "file": "00b-evidence",
+ "label": "Evidence"
+ },
+ {
+ "file": "00c-evidence",
+ "label": "Evidence"
+ },
+ {
+ "file": "00d-evidence",
+ "label": "Evidence"
+ },
+ {
+ "file": "00e-art",
+ "label": "Art assets"
+ }
+ ]
+ },
+ {
+ "title": "Screens",
+ "numbered": true,
+ "files": [
+ {
+ "file": "01-learn",
+ "label": "Learn"
+ },
+ {
+ "file": "02-challenges",
+ "label": "Challenges"
+ },
+ {
+ "file": "03-rankings",
+ "label": "Rankings"
+ },
+ {
+ "file": "04-master",
+ "label": "Master"
+ }
+ ]
+ },
+ {
+ "title": "Source of truth: video frames",
+ "numbered": true,
+ "files": [
+ {
+ "file": "ref-01-learn",
+ "label": "Learn"
+ },
+ {
+ "file": "ref-02-challenges",
+ "label": "Challenges"
+ },
+ {
+ "file": "ref-03-rankings",
+ "label": "Rankings"
+ },
+ {
+ "file": "ref-04-master",
+ "label": "Master"
+ }
+ ]
+ }
+ ]
+}
diff --git a/mockups/canvases/flashcard-onboarding/probes.json b/mockups/canvases/flashcard-onboarding/probes.json
new file mode 100644
index 00000000..a23dd170
--- /dev/null
+++ b/mockups/canvases/flashcard-onboarding/probes.json
@@ -0,0 +1,408 @@
+[
+ {
+ "id": "bg",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 30,
+ 700,
+ 360,
+ 760
+ ],
+ "only": "flat",
+ "_": "page ground on 04, between the Login button (ends 678.6) and the legal block (starts 769.6), inside the 12.7/365.5 button column: no ink, no art",
+ "_head": "Phase 1 measurements, replayable. Every box is design pt on the 525 x 1140 screen crops in assets/refs, cut out of a 1080 x 1350 video frame; run with --pt 1.337. --against wants a directory of my own renders at the same 525 x 1140 (refkit shoot --scale 3 --crop-phone, then LANCZOS down to 525 x 1140 -- scratch/verify.sh does both), one file per `mine`. Ink probes carry --only ink, because batch compares the first colour a probe prints and sample prints its flat census first."
+ },
+ {
+ "id": "card",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 100,
+ 182,
+ 280,
+ 196
+ ],
+ "only": "flat",
+ "_": "the warm panel behind the trophy, x 97.4..285.9 by the row-185 scan; this band is above the cup's rim (the art starts at y 200) and inside the panel"
+ },
+ {
+ "id": "track",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 110,
+ 772,
+ 135,
+ 785
+ ],
+ "only": "flat",
+ "_": "nav track between the back pill (ends 97.2) and the path crop (starts 97.2 but its ink starts later), above the wavy path at 787.4"
+ },
+ {
+ "id": "pill-off",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 20,
+ 800,
+ 90,
+ 820
+ ],
+ "only": "flat",
+ "_": "inside the disabled back pill (12.7..97.2 x 768.7..825.5), below the arrow (ends 804.8) and inside the cap on both sides"
+ },
+ {
+ "id": "hairline",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 200,
+ 623.3,
+ 300,
+ 624.4
+ ],
+ "only": "flat",
+ "_": "the Login button's top outline only: the button spans 623.1..679.2 and this band is the core of its 1.5pt stroke, clear of the label at 643.2"
+ },
+ {
+ "id": "ink-title",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 50,
+ 646,
+ 344,
+ 672
+ ],
+ "only": "ink",
+ "_": "title line 2 'One Card at a Time', ascender top 645.5 to baseline 672.4, x 47.9..344.1: the only ink in the band"
+ },
+ {
+ "id": "ink-eyebrow",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 130,
+ 573,
+ 262,
+ 585
+ ],
+ "only": "ink",
+ "_": "'SMART LEARNING' cap band 573.7..584.9, x 133.1..258.8, all-caps so the band bottom is the baseline"
+ },
+ {
+ "id": "ink-legal-bold",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 245,
+ 769,
+ 320,
+ 783
+ ],
+ "only": "ink",
+ "_": "'Terms of Use' inside legal line 1 (56.1..335.8): the bold run, right of 'you agree to our' and left of the trailing 'and'"
+ },
+ {
+ "id": "ink-mid",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 166,
+ 642,
+ 213,
+ 662
+ ],
+ "only": "ink",
+ "_": "the 'Login' label, bbox 166.8..211.7 x 643.2..661.2, on the white secondary button; nothing else is in that band"
+ },
+ {
+ "id": "ink-2-sub",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 59,
+ 688,
+ 333,
+ 702
+ ],
+ "only": "ink",
+ "_": "subtitle line 1 'Explore different categories and build your', bbox 59.8..332.8 x 688.1..702.3"
+ },
+ {
+ "id": "ink-2-legal",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "sample",
+ "box": [
+ 56,
+ 769,
+ 220,
+ 783
+ ],
+ "only": "ink",
+ "_": "the grey run 'By continuing, you agree to our' at the head of legal line 1, stopping before the bold 'Terms of Use' at x 235"
+ },
+ {
+ "id": "ink-inv-arrow",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "sample",
+ "box": [
+ 329,
+ 792,
+ 345,
+ 803
+ ],
+ "only": "ink",
+ "bright": true,
+ "_": "inside the next arrow's ink box (327.6..346.3 x 790.6..804.8), inset by 1.5pt so the probe misses the antialiased edge against the black pill"
+ },
+ {
+ "id": "accent-path",
+ "img": "assets/refs/c3.png",
+ "mine": "c3.png",
+ "cmd": "sample",
+ "box": [
+ 139,
+ 792,
+ 157,
+ 799
+ ],
+ "only": "flat",
+ "_": "a straight run of the amber path on 03, between the first node and the bend; the path is ~5pt thick here, which is 7px at 1.337 px/pt, enough for a flat census where a 25pt node is not"
+ },
+ {
+ "id": "hero-left",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 300,
+ "range": [
+ 0,
+ 30
+ ],
+ "_": "row 300 is inside the hero card (56.2..530.5) and clear of every illustrated object; the first transition is the card's left edge"
+ },
+ {
+ "id": "hero-right",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 300,
+ "range": [
+ 368,
+ 386
+ ],
+ "_": "same row, the card's right edge back to the page ground; the range stops at 386 because the 52pt bezel corner is black inside a rectangular crop and would win the scan"
+ },
+ {
+ "id": "hero-top",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "col",
+ "at": 200,
+ "range": [
+ 48,
+ 68
+ ],
+ "_": "column 200 is under the Dynamic Island, so the scan starts below it at y 48 and the first transition off #FAFAFA is the card's top edge"
+ },
+ {
+ "id": "hero-bottom",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "col",
+ "at": 200,
+ "range": [
+ 520,
+ 542
+ ],
+ "_": "same column, the card's bottom edge; the eyebrow does not start until 573.7"
+ },
+ {
+ "id": "nav-next-top",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "col",
+ "at": 336,
+ "range": [
+ 762,
+ 790
+ ],
+ "_": "column 336 is the next pill's horizontal centre (295.5..378.5), so this reads the pill's top edge and the gradient ramp under it, not a cap"
+ },
+ {
+ "id": "nav-pill-left",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 797,
+ "range": [
+ 6,
+ 40
+ ],
+ "_": "row 797 is the pills' vertical centre (768.7..825.5), so the back pill's left edge is at its widest here. The range starts at 6 to clear the bezel corner and stops at 40, short of the arrow, which would give the scan a second edge to pick from"
+ },
+ {
+ "id": "nav-pill-right",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 797,
+ "range": [
+ 285,
+ 300
+ ],
+ "_": "same row, the next pill's left edge at 295.5"
+ },
+ {
+ "id": "type-bands",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "bands",
+ "box": [
+ 14,
+ 555,
+ 379,
+ 735
+ ],
+ "_": "the whole type block on 01, from below the hero card (ends 530.5) to above the nav (starts 768.7): eyebrow, two title lines, two subtitle lines"
+ },
+ {
+ "id": "type-bands-04",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "bands",
+ "box": [
+ 14,
+ 465,
+ 379,
+ 540
+ ],
+ "_": "title and subtitle on 04, between the cup art (ends 402.4) and the Create Account button (starts 560.5)"
+ },
+ {
+ "id": "legal-bands",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "bands",
+ "box": [
+ 40,
+ 760,
+ 360,
+ 806
+ ],
+ "_": "the two legal lines on 04, below the Login button (ends 678.6) and above the home indicator"
+ },
+ {
+ "id": "title-cap-04",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "bbox",
+ "box": [
+ 45,
+ 468,
+ 71,
+ 512
+ ],
+ "_": "the 'R' of 'Ready to Master It?' alone -- a flat cap, so its height is cap height with no round overshoot; the 'e' beside it starts at x 71"
+ },
+ {
+ "id": "sub-cap",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "bbox",
+ "box": [
+ 59,
+ 685,
+ 72,
+ 705
+ ],
+ "_": "the 'E' of 'Explore' alone, another flat cap; 'x' starts at x 74.8. Read it as a size ratio, not a size: a #7E7E7E glyph thresholded dark loses its own antialiasing, so the render returns 9.7 where the geometry is 10.6 and the blurrier capture returns 11.2"
+ },
+ {
+ "id": "btn-label",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "bbox",
+ "box": [
+ 100,
+ 570,
+ 280,
+ 610
+ ],
+ "bright": 60,
+ "_": "'Create Account', white on the black button, so this matches bright pixels; the box is inside the button (560.5..616.6) on all four sides"
+ },
+ {
+ "id": "btn1-edges",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "scan",
+ "axis": "col",
+ "at": 40,
+ "range": [
+ 550,
+ 700
+ ],
+ "_": "column 40 is inside both buttons (12.7..365.5) and left of both labels (Create Account starts at 123.4), so it reads four clean edges"
+ },
+ {
+ "id": "btn-right",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 651,
+ "range": [
+ 355,
+ 386
+ ],
+ "_": "row 651 is the Login button's vertical centre; its right edge at 365.5 is 27.5 from the screen edge while its left edge is 12.7 -- the source's own asymmetry, kept. Range stops short of the bezel"
+ },
+ {
+ "id": "sb-time",
+ "img": "assets/refs/c1.png",
+ "mine": "c1.png",
+ "cmd": "bbox",
+ "box": [
+ 24,
+ 16,
+ 80,
+ 40
+ ],
+ "_": "'9:41' alone: the box starts at x 24 to clear the screen's 52pt corner, which is bezel black inside a rectangular crop, and ends before the location arrow at 83.0. It is a measurement of the source only. The boards carry the repo's standard status bar, whose clock sits 10.5pt right and 3.0pt lower, so the render's digits run past x 80 and batch reports a short width for a window that clips them, not for a narrow clock"
+ },
+ {
+ "id": "cup-panel",
+ "img": "assets/refs/c4.png",
+ "mine": "c4.png",
+ "cmd": "scan",
+ "axis": "row",
+ "at": 185,
+ "range": [
+ 60,
+ 340
+ ],
+ "_": "row 185 crosses the warm panel behind the trophy above the cup's rim; the handles do not reach this high"
+ }
+]
diff --git a/mockups/canvases/skillpath-ios/00-design-tokens.html b/mockups/canvases/skillpath-ios/00-design-tokens.html
new file mode 100644
index 00000000..e2efb997
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 1/6
+
+
+
+Design tokens 1/6 13 of the 73 tokens. One shared :root, byte-identical in every board.
Font --sp-font-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Helvetica Neue",Helvetica,Arial,sans-serifSurface --sp-sheet#FDFDFDSurface --sp-strip#F5F9FCSurface --sp-level#E6F7EFSurface --sp-sky-1#9DCDE8Surface --sp-sky-2#94C6E2Surface --sp-sky-3#90C3E2Surface --sp-day-on#E2E9F5Surface --sp-chiprgba(255,255,255,.40)Surface --sp-chip-on#F6FAFDSurface --sp-node#FCFEFFSurface --sp-node-off#BDD6E4Surface --sp-cta#5B8BA8
+
+
diff --git a/mockups/canvases/skillpath-ios/00b-design-tokens.html b/mockups/canvases/skillpath-ios/00b-design-tokens.html
new file mode 100644
index 00000000..389fc12d
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00b-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 2/6
+
+
+
+Design tokens 2/6 12 of the 73 tokens. One shared :root, byte-identical in every board.
Surface --sp-cta-lip#43708CSurface --sp-scrimrgba(26,60,74,.415)Line --sp-dash#CFDDE9Line --sp-ring#DDE3EALine --sp-card-edge#F8F9FBInk --sp-ink#0E1114Ink --sp-ink-2#575757Ink --sp-ink-3#8B959EInk --sp-ink-4#B9BBC1Ink --sp-ink-sub#A4A9AFInk --sp-ink-sky#22323EInk --sp-ink-sky-2#255471
+
+
diff --git a/mockups/canvases/skillpath-ios/00c-design-tokens.html b/mockups/canvases/skillpath-ios/00c-design-tokens.html
new file mode 100644
index 00000000..d9b1d0e7
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00c-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 3/6
+
+
+
+Design tokens 3/6 12 of the 73 tokens. One shared :root, byte-identical in every board.
Ink --sp-ink-sky-3#6795B3Ink --sp-ink-inv#FDFDFDInk --sp-ink-hdrrgba(253,253,253,.72)Accent --sp-green#16BE70Accent --sp-green-2#29BA76Accent --sp-green-pale#D7FFEEAccent --sp-track-off#C5ECD7Accent --sp-xp#3FBE84Accent --sp-tab-on#E3EDF3Accent --sp-tab-on-ink#9FB2BFAccent --sp-tab-off-ink#4E4E4EAccent --sp-play#5F8AA4
+
+
diff --git a/mockups/canvases/skillpath-ios/00d-design-tokens.html b/mockups/canvases/skillpath-ios/00d-design-tokens.html
new file mode 100644
index 00000000..f38d82ef
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00d-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 4/6
+
+
+
+Design tokens 4/6 12 of the 73 tokens. One shared :root, byte-identical in every board.
Accent --sp-gem#B23BD8Accent --sp-lock#80A4BBRadius --sp-r-card28pxRadius --sp-r-level24pxRadius --sp-r-row22pxRadius --sp-r-cta20pxRadius --sp-r-pill999pxRadius --sp-r-phone55pxType --sp-t-hero700 31.5px/38.4px var(--sp-font)Type --sp-t-title700 24.5px/29.9px var(--sp-font)Type --sp-t-h1700 17.5px/21.3px var(--sp-font)Type --sp-t-sect700 17px/20.7px var(--sp-font)
+
+
diff --git a/mockups/canvases/skillpath-ios/00e-design-tokens.html b/mockups/canvases/skillpath-ios/00e-design-tokens.html
new file mode 100644
index 00000000..21eb9dbd
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00e-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 5/6
+
+
+
+Design tokens 5/6 12 of the 73 tokens. One shared :root, byte-identical in every board.
Type --sp-t-h2700 24.5px/29.9px var(--sp-font)Type --sp-t-greet700 20px/24.4px var(--sp-font)Type --sp-t-count700 16px/19.5px var(--sp-font)Type --sp-t-node600 16.6px/20.3px var(--sp-font)Type --sp-t-row600 15.5px/18.9px var(--sp-font)Type --sp-t-chipname600 15px/18.3px var(--sp-font)Type --sp-t-cta700 15px/18.3px var(--sp-font)Type --sp-t-body500 15px/18.3px var(--sp-font)Type --sp-t-meta500 13.5px/16.5px var(--sp-font)Type --sp-t-date400 15.5px/18.9px var(--sp-font)Type --sp-t-sub400 13px/15.9px var(--sp-font)Type --sp-t-cap600 11.7px/14.3px var(--sp-font)
+
+
diff --git a/mockups/canvases/skillpath-ios/00f-design-tokens.html b/mockups/canvases/skillpath-ios/00f-design-tokens.html
new file mode 100644
index 00000000..fa883626
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00f-design-tokens.html
@@ -0,0 +1,130 @@
+
+
+
+
+Skill Path - Design tokens 6/6
+
+
+
+Design tokens 6/6 12 of the 73 tokens. One shared :root, byte-identical in every board.
Type --sp-t-tab500 12px/14.6px var(--sp-font)Type --sp-t-day500 11px/13.4px var(--sp-font)Type --sp-t-count2600 10.7px/13.1px var(--sp-font)Type --sp-t-chip600 9.3px/11.3px var(--sp-font)Type --sp-t-time590 17px/22px var(--sp-font)Metrics --sp-w402pxMetrics --sp-h874pxMetrics --sp-status54pxMetrics --sp-gutter20pxMetrics --sp-row85.2pxMetrics --sp-road37pxMetrics --sp-bend39px
+
+
diff --git a/mockups/canvases/skillpath-ios/00g-evidence.html b/mockups/canvases/skillpath-ios/00g-evidence.html
new file mode 100644
index 00000000..a2cb13cc
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00g-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 1/7
+
+
+
+Evidence 1/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-fontrefkit font: "3" of 3 Days = no call, weak (0.721 SF Pro); "S" of Skill Path = no call, weak (0.510 SF Pro Rounded). Platform stack, cut not claimed --sp-sheetflat census, c2 quest sheet (150-250, 440-460) and c5 lesson sheet: 100%. This is the recording's white plateau -- H.264 flat-field quantisation puts paper white at 253, and 255 appears only as ringing beside dark text --sp-stripflat census, c2 weekday strip (222-252, 242-292): 100% --sp-levelflat census, c2 level card interior (200-260, 340-360): 100% --sp-sky-1flat census, c4 sky at y 66-74, both sides of the island: 100% --sp-sky-2flat census, c2 home sky at y 300-310, left of the streak card: 100% --sp-sky-3flat census, c4 path sky at y 300-320 and y 520-528: 100% --sp-day-onmode of the c2 Mon check disc (47-59, 263-271), all pixels: no flat core at 20pt --sp-chipc4 inactive chip reads #C5DBED flat over #9DCDE8 sky: (197-157)/(255-157) = .41 --sp-chip-onflat census, c4 Skincare chip right of its label (144-153, 112-140): 67% --sp-nodemode of the c4 Barrier Repair node core (50-60, 420-450)
+
+
diff --git a/mockups/canvases/skillpath-ios/00h-evidence.html b/mockups/canvases/skillpath-ios/00h-evidence.html
new file mode 100644
index 00000000..61580b3b
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00h-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 2/7
+
+
+
+Evidence 2/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-node-offflat census, c4 locked Weekly Exfoliation node (158-178, 565-575): 84% --sp-ctaflat census, c5 START LESSON button interior (150-250, 770-790): 99% --sp-cta-lipcolumn scan c5 x 200: the button's bottom lip, y 820.8-824.5 --sp-scrimc5 over c4 at two grounds: sky 144,195,226 -> 95,139,163 and locked node 188,214,228 -> 121,150,165. (1-a) = 26/44 = 0.59 on red, 11/19 = 0.58 on green --sp-dashmode of the c4 road centre dash core (97-100, 566-572) --sp-ringc2 quest-progress ring, coverage solve against the #FDFDFD sheet --sp-card-edgec2 column scan x 25: the quest row edges read 248/253 against the sheet --sp-inkink core, darkest 15% of c2 3 Days (47-143, 181-209): #060606, and of Today's Quest: #1E1E1E --sp-ink-2ink core, c2 1/5 completed (270-358, 469-482) --sp-ink-3ink core, c2 CURRENT STREAK (46-153, 157-168) --sp-ink-4ink core, c2 Quests resets at midnight (38-195, 496-510)
+
+
diff --git a/mockups/canvases/skillpath-ios/00i-evidence.html b/mockups/canvases/skillpath-ios/00i-evidence.html
new file mode 100644
index 00000000..23df9b9a
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00i-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 3/7
+
+
+
+Evidence 3/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-ink-subink core, c2 quest row subtitles (78-380, 549-563) --sp-ink-skyink core, c5 Barrier Repair title (101-256, 690-712) --sp-ink-sky-2ink core, c4 Under-Eye Care node label (200-310, 277-292) --sp-ink-sky-3ink core, c4 Weekly Exfoliation, the locked label --sp-ink-invthe sheet white; white type is the same plateau --sp-ink-hdrc2 Hi, James reads #CFF0FF brightest-15% over #9BCBE6 sky --sp-greenmean of the c2 level-node core (38-42, 386-397) --sp-green-2mean of the c2 level track core (82-98, 388-394) --sp-green-palemean of the c2 locked level node (269-275, 385-397) --sp-track-offmean of the c2 unfilled level track (312-333, 388-394) --sp-xpc2 +25 XP chip label, ink core widened to the glyph
+
+
diff --git a/mockups/canvases/skillpath-ios/00j-evidence.html b/mockups/canvases/skillpath-ios/00j-evidence.html
new file mode 100644
index 00000000..9b5d4096
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00j-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 4/7
+
+
+
+Evidence 4/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-tab-onflat census, c4 active tab pill, 8% of the tab-bar window --sp-tab-on-inkink core, c4 active Path label (140-178, 832-843) --sp-tab-off-inkink core, c4 inactive Home icon (56-80, 807-826) --sp-playink core of the c4 Barrier Repair play triangle (55-78, 428-450): #5F8AA4, and of the c5 sheet triangle (45-62, 700-722): #608AA4 --sp-gemink core, c2 +2 gem chip label (153-170, 574-585) --sp-lockmode of the c4 keyhole glyph (163-171, 586-598), all pixels --sp-r-cardc2 streak-card corner, row scans at y 131 and y 296 --sp-r-levelc2 level-card corner, row scans at y 334 and y 431 --sp-r-rowc2 quest-row corner --sp-r-ctac5 START LESSON corner
+
+
diff --git a/mockups/canvases/skillpath-ios/00k-evidence.html b/mockups/canvases/skillpath-ios/00k-evidence.html
new file mode 100644
index 00000000..44e4a819
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00k-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 5/7
+
+
+
+Evidence 5/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-r-pillby construction, not measured --sp-r-phonecircular stand-in for the 17 Pro's continuous display corner; refkit --crop-phone masks the same number --sp-t-heroprobe t-hero, c2 "3 Days": ref ink 96.8 pt wide, board 96.8 --sp-t-titleprobe t-title, c4 "Skill Path": ref 100.5, board 100.5 --sp-t-h1probe t-h1, c2 "Level 4": ref 54.9, board 55.8, +1.6% --sp-t-sectprobe t-sect, c2 "Today’s Quest": ref 112.6, board 112.6. Half a point smaller than h1, and the two do not merge: forcing this run to 17.5 costs c1 and c2 0.10 and 0.15 --sp-t-h2probe t-h2, c5 "Barrier Repair": ref 153.7, board 150.9. The width asks for 25px and the frame does not: 25px costs c5 0.31, so the -1.8% stands --sp-t-greetprobe t-greet, c2 "Welcome Back": ref 134.9, board 134.0 --sp-t-countprobe t-count, c2 "235" in the gem pill: ref 27.9, board 28.9. A wider box takes the pill border with it, so this one was settled on a zoom pair as well, scratch/p_gem.png --sp-t-nodeprobe t-node, c4 "Under-Eye Care": ref 120.2, board 120.2
+
+
diff --git a/mockups/canvases/skillpath-ios/00l-evidence.html b/mockups/canvases/skillpath-ios/00l-evidence.html
new file mode 100644
index 00000000..ca426231
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00l-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 6/7
+
+
+
+Evidence 6/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-t-rowprobe t-row, c2 quest row 1 title: ref 130.3, board 131.2 --sp-t-chipnameprobe t-chipname, c4 "Oral Posture": ref 86.7, board 85.7 --sp-t-ctaprobe t-cta, c5 "START LESSON": ref 116.7, board 118.5. The width is what set the tracking: .09em ran 125.9 wide, .045em lands within 2% --sp-t-bodyprobe t-body, c2 "Hi, James": ref 67.0, board 65.1 --sp-t-metaprobe t-meta, c2 "95/120 XP": ref 63.3, board 63.3 --sp-t-dateprobe t-date, c2 the Thu numeral "12": ref 14.9, board 15.8. Two glyphs is a thin sample; 14.6px measures better and renders worse, so the round number stands --sp-t-subprobe t-sub, c2 quest row 1 subtitle: ref 196.4, board 195.4 --sp-t-capprobe t-cap, c2 "CURRENT STREAK": ref 108.0, board 108.9. A cap-height read said 14px, which set it 18% too wide --sp-t-tabprobe t-tab, c2 "Home": ref 32.6, board 31.6. The four labels band at 49.3-81.9 / 144.3-168.5 / 232.7-261.5 / 313.6-363.0; "Progress" is 49.3 wide against 49.3 --sp-t-dayprobe t-day, c2 "Mon": ref 20.5, board 20.5
+
+
diff --git a/mockups/canvases/skillpath-ios/00m-evidence.html b/mockups/canvases/skillpath-ios/00m-evidence.html
new file mode 100644
index 00000000..9cf371cf
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/00m-evidence.html
@@ -0,0 +1,127 @@
+
+
+
+
+Skill Path - Evidence 7/7
+
+
+
+Evidence 7/7 One row per token, and what it was measured on. A token with no evidence is a guess.
--sp-t-count2probe t-count2, c2 "1/5 completed": ref 72.6, board 72.6. 14px was 26% too wide --sp-t-chipprobe t-chip, c2 the quest-row "+25 XP": ref 36.3, board 36.3. 12px was 8% too wide --sp-t-timerepo standard chrome (templates/gen.py); the source's clock is not measured --sp-wiPhone 17 Pro logical width --sp-hiPhone 17 Pro logical height --sp-statusrepo standard chrome (templates/gen.py), iOS Dynamic Island status bar --sp-gutterc2 streak-card row scan: left edge 20.5, right edge 382.5, on a 402 pt frame --sp-rowc2 quest titles at 527.1 / 611.8 / 697.4: pitch 84.7 and 85.6 --sp-roadc4 road verticals run x 80-117 and x 287-324 on every row between bends --sp-bendcircle fit on the c4 bend at y 214.5: predicted left edge 85.2 / 82.4 / 80.7 at y 234 / 240 / 246 against 85 / 82 / 81 measured
+
+
diff --git a/mockups/canvases/skillpath-ios/01-home.html b/mockups/canvases/skillpath-ios/01-home.html
new file mode 100644
index 00000000..f4f3727d
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/01-home.html
@@ -0,0 +1,129 @@
+
+
+
+
+Skill Path - Home
+
+
+
+
Hi, James
Welcome Back
235
CURRENT STREAK
3 Days
Mon
Tue
Wed
Thu
12
Fri
13
Sat
14
Sun
15
Level 4
95/120 XP
30 XP
60 XP
90 XP
120 XP
150 XP
Today’s Quest
Quests resets at midnight
0/5 completed
Morning Skincare Routine
Apply cleanser, moisturizer and Shampoo
+25 XP
+2
Push Day Workout
Complete today’s push workout…
+25 XP
+2
Mewing Practice
5 minutes of proper tongue posture and
+25 XP
+2
Morning Skincare Routine
Apply cleanser, moisturizer and Shampoo
+25 XP
+2
Home
Path
Shop
Progress
+
+
diff --git a/mockups/canvases/skillpath-ios/02-home-quest-done.html b/mockups/canvases/skillpath-ios/02-home-quest-done.html
new file mode 100644
index 00000000..77b0d3c9
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/02-home-quest-done.html
@@ -0,0 +1,129 @@
+
+
+
+
+Skill Path - Home, one cleared
+
+
+
+
Hi, James
Welcome Back
235
CURRENT STREAK
3 Days
Mon
Tue
Wed
Thu
12
Fri
13
Sat
14
Sun
15
Level 4
95/120 XP
30 XP
60 XP
90 XP
120 XP
150 XP
Today’s Quest
Quests resets at midnight
1/5 completed
Push Day Workout
Complete today’s push workout…
+25 XP
+2
Mewing Practice
5 minutes of proper tongue posture and
+25 XP
+2
Morning Skincare Routine
Apply cleanser, moisturizer and Shampoo
+25 XP
+2
Morning Skincare Routine
Apply cleanser, moisturizer and Shampoo
+25 XP
+2
Home
Path
Shop
Progress
+
+
diff --git a/mockups/canvases/skillpath-ios/03-path.html b/mockups/canvases/skillpath-ios/03-path.html
new file mode 100644
index 00000000..be0d6b1a
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/03-path.html
@@ -0,0 +1,129 @@
+
+
+
+
+Skill Path - Skill Path
+
+
+
+
Morning Cleanse
+25 XP
Under-Eye Care
+25 XP
Barrier Repair
+25 XP
Weekly Exfoliation
+25 XP
Face Shape & Cut
+25 XP
Washing Correctly
+25 XP
Skill Path
Fitness
Skincare
Hair
Oral Posture
Home
Path
Shop
Progress
+
+
diff --git a/mockups/canvases/skillpath-ios/04-path-scrolled.html b/mockups/canvases/skillpath-ios/04-path-scrolled.html
new file mode 100644
index 00000000..d60b1e70
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/04-path-scrolled.html
@@ -0,0 +1,129 @@
+
+
+
+
+Skill Path - Skill Path scrolled
+
+
+
+
Morning Cleanse
+25 XP
Under-Eye Care
+25 XP
Barrier Repair
+25 XP
Weekly Exfoliation
+25 XP
Face Shape & Cut
+25 XP
Washing Correctly
+25 XP
Skill Path
Fitness
Skincare
Hair
Oral Posture
Home
Path
Shop
Progress
+
+
diff --git a/mockups/canvases/skillpath-ios/05-lesson-sheet.html b/mockups/canvases/skillpath-ios/05-lesson-sheet.html
new file mode 100644
index 00000000..d4ab0fd1
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/05-lesson-sheet.html
@@ -0,0 +1,129 @@
+
+
+
+
+Skill Path - Lesson sheet
+
+
+
+
Morning Cleanse
+25 XP
Under-Eye Care
+25 XP
Barrier Repair
+25 XP
Weekly Exfoliation
+25 XP
Face Shape & Cut
+25 XP
Washing Correctly
+25 XP
Skill Path
Fitness
Skincare
Hair
Oral Posture
Home
Path
Shop
Progress
Barrier Repair
+25 XP
START LESSON
+
+
diff --git a/mockups/canvases/skillpath-ios/README.md b/mockups/canvases/skillpath-ios/README.md
new file mode 100644
index 00000000..6eb14f64
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/README.md
@@ -0,0 +1,281 @@
+# Skill Path
+
+Five screens of an unnamed gamified self-improvement app, rebuilt from a nine
+second screen recording posted by
+[@BreejeAnadkat](https://x.com/BreejeAnadkat/status/2096553815071993964). The
+clip pans and zooms across three iPhone 17 Pro simulator windows; two of them
+hold this app. A Home tab with a streak card, a level bar and a quest list, and
+a Path tab with a serpentine road of lessons. The folder is named for the Path
+tab's own title, because the app never names itself.
+
+23 boards, five of which park the source frame under its replica.
+
+| # | Board | What it shows |
+| --- | --- | --- |
+| 01 | `home` | Home, no quest cleared, "0/5 completed" |
+| 02 | `home-quest-done` | the same screen one quest later, row 1 ticked |
+| 03 | `path` | the Path tab at the top of its scroll |
+| 04 | `path-scrolled` | the same content 184.3pt further down |
+| 05 | `lesson-sheet` | the Barrier Repair sheet over a scrimmed Path |
+| 00, 00b–00f | `design-tokens 1/6` … `6/6` | the 73 tokens, 13/12/12/12/12/12 to a board, one `:root` shared by every one of the 23 |
+| 00g–00m | `evidence 1/7` … `7/7` | what each of those 73 was measured on, 11/11/11/10/10/10/10 to a board |
+
+A fresh clone builds the first 18. The five `ref-*` boards hold third-party
+video frames, so they are gitignored and `gen.py` skips them when
+`assets/refs/` is absent. `layout.json` names them either way — the canvas
+drops a row entry whose file it cannot find, so a committed layout that
+mentions the references does not go dirty on a capture-less regeneration.
+
+### Why the foundation boards are plural
+
+73 tokens and 73 evidence rows are about 4000px of table, and the artboard is
+980px tall, so a single pair of boards showed roughly a quarter of itself and
+clipped the rest in silence. The fix is to split, never to trim — the evidence
+is the deliverable — so `gen.py` chunks both tables at `TOK_ROWS` and
+`EV_ROWS`, and both numbers are measured rather than eyeballed: the tallest
+chunk of each candidate is rendered on its own and put to
+
+```bash
+refkit shoot ./0*.html -o scratch/ovf --scale 2 --check-overflow
+```
+
+14 token rows fit and 15 overflow by 10px — and it is the one chunk that
+carries `--sp-font`'s 107-character stack that decides it, not the row count on
+its own. 11 evidence rows fit and 12 overflow by 5px. `TOK_ROWS` and `EV_ROWS`
+are then read as caps rather than as slice sizes: six boards of
+13/12/12/12/12/12 rather than five of 14 and a three-row stub, every chunk
+still under the count that was measured. Two things about that check are worth
+knowing, because both of them hid this:
+
+- **Do not pass `--crop-phone`.** With it a full-bleed board is cropped to the
+ phone rect before the check runs, and a table 3000px past the bottom never
+ shows up at all. That is why the five screen boards, which are checked with
+ the crop, were fine and these two were not.
+- **It does not see horizontal overrun.** The probe flags an element whose own
+ `overflow` is `hidden` and scrolls, not the page running wider than the
+ board. The token table had sized itself to `--sp-font`'s 107-character stack:
+ its row dividers measure 1033px across, starting at the 24px body padding and
+ ending 579px past the right edge of a 478px artboard, so the entire value
+ column was off the board — and `--check-overflow` reported `fits` on every
+ run. It was found by looking at the render. Both tables are now pinned to
+ `TW = 430px`, which is 478 less the 2 × 24px body padding, with
+ `table-layout: fixed` and `overflow-wrap: anywhere` so a long value wraps
+ down the column instead of stretching the board sideways.
+
+## How close it lands
+
+Mean absolute delta against the source frames, whole 402 × 874 frame, phone
+crop, in levels of 255:
+
+| Board | frame | Δ | Board | frame | Δ |
+| --- | --- | --- | --- | --- | --- |
+| 01 Home | c1 | 9.04 | 04 Path scrolled | c3 | 7.95 |
+| 02 Home, one cleared | c2 | 8.11 | 05 Lesson sheet | c5 | 5.88 |
+| 03 Skill Path | c4 | 6.88 | | | |
+
+`refkit batch probes.json --against scratch/mine` replays all **59 Phase-1
+probes** against the renders:
+
+- **21 box probes** — every string whose size is a token — at a mean |dw| of
+ **0.62pt** and a mean |dh| of 0.81pt. Eleven of the 21 match the capture's ink
+ width exactly; the worst is `t-date`, the two-glyph "12" of the selected
+ weekday, at 5.7%, and the worst on a whole string is `t-h2` at 1.9%.
+- **7 edge scans** — both card gutters, both road verticals, the button lip,
+ the sheet top and the tab bar top — all within **1.9pt**, five of them within
+ 1.0.
+- **31 colour probes**, mean Δmax 9.1. All 11 flat-fill probes are exact
+ matches. All 9.1 of the mean lives in the ink cores, and it is one
+ systematic effect, described below.
+
+These numbers sit alongside `flashcard-onboarding`'s 6.17–7.05, the repo's
+other canvas cloned from a video, and well above `duolingo-ios`'s 1.32–2.93,
+which was cloned from stills. **A video frame is not a screenshot.** At 1.01 to
+1.08 capture px per design pt a 12px label is eleven pixels tall through an
+H.264 encoder, and a sharp render diffed against a soft frame scores an error
+the geometry does not contain.
+
+What the residual is made of, measured rather than assumed:
+
+| | c1 | c2 | c3 | c4 | c5 |
+| --- | --- | --- | --- | --- | --- |
+| whole frame | 9.04 | 8.11 | 7.95 | 6.88 | 5.88 |
+| excluding the four 60pt corners | −0.81 | −0.88 | −0.91 | −0.96 | −0.72 |
+| best whole-frame shift within ±2px | −0.79 | −0.28 | −0.11 | −0.05 | −0.00 |
+| Gaussian blur on the render, best radius | −0.64 | −0.61 | −0.33 | −0.29 | −0.27 |
+
+The corners differ because the capture keeps whatever was outside the device's
+rounded corner and the board shows the artboard's page. The shift column says
+registration is right to within a pixel on four of the five. The blur column is
+the price of a sharp render against a soft source and it is not payable — the
+rest is glyph antialiasing across the whole frame.
+
+The one band that is worse than the frame on every screen is **y 0–40, at
+Δ 24–33**. That is the status bar, and it is deliberate; see below.
+
+## The face is not called
+
+`refkit font` returns **"no call, weak"** twice: on the largest glyph on Home
+(the "3" of 3 Days, 0.721 SF Pro) and on the Path title (the "S" of Skill Path,
+0.510 SF Pro Rounded). A 1.01× video frame of a 24pt cap cannot separate SF Pro
+from SF Pro Rounded, and the ranking is not promoted into a claim. The source
+is a simulator build with no custom face in it, so `--sp-font` is the platform
+stack and the cut is not stated.
+
+**The stand-in's bill arrives as width, and every size in the file was fitted
+to it.** SF Pro is not installed in this renderer either — a probe of eight
+stacks (`-apple-system`, `BlinkMacSystemFont`, `SF Pro Text`, `SF Pro Display`,
+`SF Compact Text`, `Helvetica Neue`, `Helvetica`, `Arial`) came back
+byte-identical — so the boards are set in the platform fallback. The 20 rows of
+`TYPE` in `gen.py` each carry the reference's ink width, the board's ink width
+and the probe id that measures both, and `refkit batch` re-runs all 20 on
+demand. Nothing in the ramp was read off a cap height, because a cap measured
+through H.264 blur is 1–2pt taller than the glyph: `CURRENT STREAK` read as
+14px that way and set 18% too wide, and `1/5 completed` read as 14px and set
+26% too wide.
+
+### The renderer's metrics, not SF Pro's
+
+`tx()` places a run by the cap top the grid actually shows, which means
+inverting the font's own metrics. Using SF Pro's nominal 0.714 em cap put every
+run on the board low by 0.105 × font-size — 3.3pt on the 31.5px "3 Days".
+`scratch/met.html` renders `HXE` at 12 / 18 / 25 / 31.5 / 40px inside a block
+of known top: the fallback's cap top lands at `lh/2 − 0.350·fs` below the block
+top, to ±0.008 across all five sizes, and its cap measures **0.733 em**. That
+one constant is worth **1.50 on c1 and 1.31 on c2**, and 0.2–0.3 on the
+three Path screens, which carry less type: putting `CAP_TOP` back to the
+0.245 that SF Pro's metrics imply takes the five screens to
+10.53/9.40/8.19/7.11/6.20.
+
+## The chrome is the repo's, not the source's
+
+The phone frame and the status bar both come from this repo, not from the
+recording, on the same principle: standard chrome wins, so that a board reads
+next to the other canvases in this folder.
+
+- **The phone frame** is `--sp-r-phone: 55px`, a circular stand-in for the 17
+ Pro's continuous display corner, and the same number `refkit --crop-phone`
+ masks.
+- **The status bar** is `statusbar()`, the `.sb` / `.sb .time` / `.sb .island`
+ rules and the `SB_ICONS` inline-SVG cluster, copied **verbatim from
+ `templates/gen.py`**: clock at `top:18.2px; width:142.4px`, island 125 × 36
+ at top 11, the three glyphs at their fixed left/top. The source's own bar was
+ never measured and none of its glyphs were cropped.
+
+That decision is priced, not assumed: it is most of the Δ 24–33 in the top
+40pt band of every screen, and it is the reason **there is no status-bar probe
+in `probes.json`**. There is nothing to score there that would be scoring the
+source. The only thing the boards take from the source's bar is the clock
+*string* — 5:54, 5:55 and 6:09, the simulator's own time in the three windows —
+because that is content, not chrome.
+
+## Crop it, do not draw it
+
+17 assets, listed in `crops.json`, cut from `assets/refs/cN.png` at a measured
+pt box, written to `assets/art/.png` and placed back by `art()` at the same
+numbers. **84 KB total**, nothing over 8 KB.
+
+| ids | what |
+| --- | --- |
+| `medal`, `crown`, `avatar` | the streak medal, the level crown, the header avatar |
+| `home-clouds`, `cloud-l` | the two photographic cloud washes |
+| `ic-dumbbell`, `ic-mewing`, `ic-skincare`, `ic-razor` | the four quest-row icons |
+| `chip-skincare`, `chip-hair`, `chip-oral` | the three category-chip glyphs |
+| `bolt-lv`, `bolt-chip`, `gem-hdr`, `gem-chip`, `lock` | the XP bolts, the gems, the keyhole |
+
+Each box carries its own background rather than being cut out to alpha, because
+each is placed back at the pt it was cut from, over ground the generator paints
+to the same token. A tight cut-out would only add a matte line.
+
+**Everything else is CSS**: the streak and level cards, the quest rows and their
+progress rings, the weekday strip, the level track and its nodes, the whole
+serpentine road and its dashes, the category pills, the lesson sheet, the
+START LESSON button, the tab bar and all four of its icons, and every glyph of
+type.
+
+Two things that look croppable are not:
+
+- **The lesson sheet's scalloped top.** Its measured boundary fits five circles
+ whose centres all land on y 705, so it is five `border-radius` discs. A crop
+ there would carry the scrimmed road behind it and could not be reused at the
+ other scroll position.
+- **The header's frosted veil.** `backdrop-filter: blur(13px)` over
+ `rgba(157,205,232,.55)`, solved from the sky at #9BCBE6 reading #C5DBED where
+ the road passes under the header. Cropping it would freeze one scroll
+ position into the art.
+
+## Defects in the source, kept
+
+- **Three of the five quest rows are the same quest.** Rows 1, 4 and 5 all read
+ "Morning Skincare Routine / Apply cleanser, moisturizer and Shampoo", with
+ three different icons — a jar, a jar again and a razor. Replicated as read.
+- **The recorder's cursor is in three frames.** An arrow sits over the Barrier
+ Repair node in c4, over the Weekly Exfoliation XP pill in c3 and over quest
+ row 1 in c1. It is not drawn on any board, so those three regions carry a
+ small permanent delta.
+- **c3, c4 and c5 include one row of bezel.** Row 0 of those three captures
+ reads #0D252D, #112A27 and #1F2B33 where the screen-inner rect should already
+ be content. The scale solve is unaffected — it is fitted on the full rect —
+ but it is part of the top band's score.
+
+## Guessed, not measured
+
+**One string.** Node `n1`'s label on the Path screen sits behind the frosted
+header on every frame in the clip; only a capital C is legible. The board says
+"Morning Cleanse". It is a plausible reading of one letter and the app's
+vocabulary, and nothing else in the file is like it.
+
+## Three measurement notes
+
+- **There is no capture scale, there are five.** The clip zooms, so `SCALES` in
+ `gen.py` carries one px-per-pt figure per frame — c1 1.01194, c2 1.07450,
+ c3 1.01490, c4 1.01490, c5 1.07985 — and every probe row in `probes.json`
+ carries its own `"pt"`. Boxes are always design pt.
+- **The device is 402 × 874, not 393 × 852.** That is an iPhone 17 Pro. Solving
+ each frame's screen-inner rect against 402 × 874 makes its width and height
+ scales agree to **0.07%**; against 393 × 852 they disagree by 0.34%.
+- **Downscale renders with an area average, not LANCZOS.** The verify loop
+ shoots at 3× and resamples to each capture's pixel size. LANCZOS rings, and
+ the ringing lands exactly where the ink probes read. Switching
+ `scratch/rs.py` to `Image.BOX` took the five screens from
+ 9.15/8.26/8.05/6.98/5.97 to 9.03/8.09/7.95/6.88/5.88 — the table above
+ reads 9.04 and 8.11 on the first two because a later crop fix to
+ `bolt-chip` cost them 0.01 and 0.02 — and made every ink
+ probe on the render return its declared token *exactly* — `#0E1114`,
+ `#575757`, `#8B959E`, `#22323E`, `#9FB2BF`. That is the useful result: the
+ render reaches the token, and the whole of the colour probes' Δ column is the
+ capture's own blur bias. `gem` is the extreme case at Δ 64, a 10px magenta
+ glyph two pixels wide in a 1.07× frame, whose darkest 2% comes back
+ desaturated at #8D6898 against the board's #B23BD8.
+
+## Rebuilding
+
+```bash
+python3 mockups/canvases/skillpath-ios/gen.py
+refkit tokens mockups/canvases/skillpath-ios
+```
+
+`gen.py` is the only source of truth; the `NN-*.html` boards are its output.
+It re-cuts `assets/art/` from `assets/refs/` when the captures are present and
+leaves the committed crops alone when they are not, so the seven non-reference
+boards regenerate byte-identically from a fresh clone.
+
+**`cut()` needs Pillow, and it fails quietly without it.** The import sits
+inside a `try`, so a bare system `python3` regenerates every board and silently
+skips `assets/art/` — a changed box in `crops.json` then looks applied and is
+not. Editing a crop box means regenerating with an interpreter that has Pillow:
+
+```bash
+~/.local/share/uv/tools/super-prototyping-tools/bin/python \
+ mockups/canvases/skillpath-ios/gen.py
+```
+
+That path is where `uv tool install` puts `super-prototyping-tools`; any
+interpreter with Pillow will do. Not a hypothetical: `bolt-chip`'s box
+originally reached 5.5pt into the "+" of "+25 XP" and put a stray fragment on
+every quest row, and the first fix appeared to do nothing for exactly this
+reason.
+
+`icon.png` is drawn, not cropped: the app is unnamed and its own icon never
+appears in the recording, so the icon is this board's own Path tab glyph over
+its own sky tokens, built by `scratch/icon.py`. Nothing in it is claimed as
+measured.
diff --git a/mockups/canvases/skillpath-ios/assets/art/avatar.png b/mockups/canvases/skillpath-ios/assets/art/avatar.png
new file mode 100644
index 00000000..c9e54042
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/avatar.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/bolt-chip.png b/mockups/canvases/skillpath-ios/assets/art/bolt-chip.png
new file mode 100644
index 00000000..2d3cc19f
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/bolt-chip.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/bolt-lv.png b/mockups/canvases/skillpath-ios/assets/art/bolt-lv.png
new file mode 100644
index 00000000..31f8d6a3
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/bolt-lv.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/chip-hair.png b/mockups/canvases/skillpath-ios/assets/art/chip-hair.png
new file mode 100644
index 00000000..22bef0e5
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/chip-hair.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/chip-oral.png b/mockups/canvases/skillpath-ios/assets/art/chip-oral.png
new file mode 100644
index 00000000..6b6ddf2a
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/chip-oral.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/chip-skincare.png b/mockups/canvases/skillpath-ios/assets/art/chip-skincare.png
new file mode 100644
index 00000000..03c186cf
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/chip-skincare.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/cloud-l.png b/mockups/canvases/skillpath-ios/assets/art/cloud-l.png
new file mode 100644
index 00000000..970d77ff
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/cloud-l.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/crown.png b/mockups/canvases/skillpath-ios/assets/art/crown.png
new file mode 100644
index 00000000..e1703730
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/crown.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/gem-chip.png b/mockups/canvases/skillpath-ios/assets/art/gem-chip.png
new file mode 100644
index 00000000..970d1dfa
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/gem-chip.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/gem-hdr.png b/mockups/canvases/skillpath-ios/assets/art/gem-hdr.png
new file mode 100644
index 00000000..12bd38c2
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/gem-hdr.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/home-clouds.png b/mockups/canvases/skillpath-ios/assets/art/home-clouds.png
new file mode 100644
index 00000000..536a9c53
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/home-clouds.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/ic-dumbbell.png b/mockups/canvases/skillpath-ios/assets/art/ic-dumbbell.png
new file mode 100644
index 00000000..395f3be0
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/ic-dumbbell.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/ic-mewing.png b/mockups/canvases/skillpath-ios/assets/art/ic-mewing.png
new file mode 100644
index 00000000..e75d806c
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/ic-mewing.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/ic-razor.png b/mockups/canvases/skillpath-ios/assets/art/ic-razor.png
new file mode 100644
index 00000000..80f32a45
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/ic-razor.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/ic-skincare.png b/mockups/canvases/skillpath-ios/assets/art/ic-skincare.png
new file mode 100644
index 00000000..9d4db921
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/ic-skincare.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/lock.png b/mockups/canvases/skillpath-ios/assets/art/lock.png
new file mode 100644
index 00000000..67115fb2
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/lock.png differ
diff --git a/mockups/canvases/skillpath-ios/assets/art/medal.png b/mockups/canvases/skillpath-ios/assets/art/medal.png
new file mode 100644
index 00000000..adb4a3e8
Binary files /dev/null and b/mockups/canvases/skillpath-ios/assets/art/medal.png differ
diff --git a/mockups/canvases/skillpath-ios/crops.json b/mockups/canvases/skillpath-ios/crops.json
new file mode 100644
index 00000000..22f4508a
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/crops.json
@@ -0,0 +1,124 @@
+{
+ "_": "Art cut from assets/refs/cN.png at measured pt boxes; each capture has its own scale (SCALES in gen.py) because the source is a zooming screen recording, not a still. gen.py writes assets/art/.png and places each back at its own box, so an asset cannot drift from where it was measured. Chrome -- cards, pills, buttons, the road, type -- is CSS.",
+ "_note": "Every box carries its own background, because every one of them is placed back at the pt it was cut from, over ground the generator paints to the same token. A tight alpha cut-out would only add a matte line.",
+ "medal": [
+ "c2",
+ 298.0,
+ 147.0,
+ 360.0,
+ 220.0
+ ],
+ "avatar": [
+ "c2",
+ 341.0,
+ 72.0,
+ 385.0,
+ 118.0
+ ],
+ "gem-hdr": [
+ "c2",
+ 263.5,
+ 87.5,
+ 282.5,
+ 102.0
+ ],
+ "crown": [
+ "c2",
+ 30.0,
+ 346.0,
+ 55.0,
+ 367.0
+ ],
+ "bolt-lv": [
+ "c2",
+ 291.0,
+ 350.0,
+ 304.0,
+ 365.0
+ ],
+ "bolt-chip": [
+ "c2",
+ 77.0,
+ 573.0,
+ 89.5,
+ 586.0
+ ],
+ "gem-chip": [
+ "c2",
+ 135.0,
+ 571.0,
+ 152.0,
+ 584.0
+ ],
+ "ic-dumbbell": [
+ "c2",
+ 35.7,
+ 521.6,
+ 69.7,
+ 555.6
+ ],
+ "ic-mewing": [
+ "c2",
+ 35.7,
+ 606.8,
+ 69.7,
+ 640.8
+ ],
+ "ic-skincare": [
+ "c2",
+ 35.7,
+ 692.0,
+ 69.7,
+ 726.0
+ ],
+ "ic-razor": [
+ "c2",
+ 35.7,
+ 777.2,
+ 69.7,
+ 794.0
+ ],
+ "chip-skincare": [
+ "c4",
+ 58.0,
+ 110.0,
+ 80.0,
+ 144.0
+ ],
+ "chip-hair": [
+ "c4",
+ 172.0,
+ 110.0,
+ 198.0,
+ 144.0
+ ],
+ "chip-oral": [
+ "c4",
+ 258.0,
+ 110.0,
+ 284.0,
+ 144.0
+ ],
+ "cloud-l": [
+ "c4",
+ 2.0,
+ 548.0,
+ 80.0,
+ 630.0
+ ],
+ "lock": [
+ "c4",
+ 155.0,
+ 572.0,
+ 182.0,
+ 606.0
+ ],
+ "home-clouds": [
+ "c2",
+ 2.0,
+ 296.0,
+ 400.0,
+ 313.0
+ ],
+ "_note2": "home-clouds and cloud-l are the two photographic cloud washes; the lesson sheet's scalloped top is not a crop, because its measured boundary fits five circles whose centres all land on y 705 and a crop there would carry the scrimmed road behind it."
+}
diff --git a/mockups/canvases/skillpath-ios/gen.py b/mockups/canvases/skillpath-ios/gen.py
new file mode 100644
index 00000000..f8d57484
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/gen.py
@@ -0,0 +1,882 @@
+"""Emit mockups/canvases/skillpath-ios/ from five frames of one screen recording.
+
+The source is a 9-second pan-and-zoom over three iPhone 17 Pro simulator
+windows (https://x.com/BreejeAnadkat/status/2096553815071993964). Two of the
+three windows hold the same unnamed gamified self-improvement app: a Home tab
+with a streak card, a level bar and a quest list, and a Path tab with a
+serpentine road of lessons. This folder rebuilds five of its screens.
+
+Because the recording zooms, every frame has its own scale, so there is no one
+"capture scale" here: SCALES below carries one px-per-pt figure per frame and
+every measurement in this file was read at the scale of the frame it came from.
+The device is 402 x 874 pt, not the 393 x 852 the rest of this repo uses --
+that is an iPhone 17 Pro, and the numbers only close on 402 x 874 (width and
+height scales agree to 0.07% there and disagree by 0.34% at 393 x 852).
+
+Chrome is CSS: cards, pills, the tab bar, the progress bar, the road and every
+glyph of type. Illustration is cropped out of the capture at its own measured
+box -- `crops.json` names each box, `cut()` writes assets/art/.png and
+`art()` places the back at the same numbers.
+
+ python3 mockups/canvases/skillpath-ios/gen.py
+
+Artboards are output. Edit this file, never the HTML.
+"""
+import base64
+import json
+from pathlib import Path
+
+OUT = Path(__file__).resolve().parent
+REFS_DIR = OUT / "assets" / "refs"
+ART_DIR = OUT / "assets" / "art"
+CROPS = {k: v for k, v in json.loads((OUT / "crops.json").read_text()).items()
+ if not k.startswith("_")}
+
+# capture px per design pt, one per frame: the recording zooms between them.
+# Each is (frame width / 402 + frame height / 874) / 2 on the screen-inner rect
+# found by scanning the bezel transition.
+SCALES = {"c1": 1.01194, "c2": 1.07450, "c3": 1.01490,
+ "c4": 1.01490, "c5": 1.07985}
+
+NAME = "Skill Path"
+PAGE_NAME = "(example) " + NAME
+P = "sp"
+
+W, H = 402.0, 874.0 # iPhone 17 Pro logical size
+
+# ---------------------------------------------------------------- tokens ----
+# `refkit font` returns "no call, weak" on both the largest glyph on the Home
+# screen ("3" of 3 Days, 0.721) and on the Path title ("S" of Skill Path,
+# 0.510): a 1.01x video frame of a 24pt cap cannot separate SF Pro from SF Pro
+# Rounded. The app is a simulator build with no custom face anywhere in it, so
+# the family recorded is the platform stack and the cut is not claimed.
+FONT = ('-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",'
+ '"Helvetica Neue",Helvetica,Arial,sans-serif')
+
+# name -> (weight, px, evidence). The px column is the size at which the board's
+# ink width matches the capture's, measured on the string named in the row.
+TYPE = {
+ "hero": (700, 31.5, 'probe t-hero, c2 "3 Days": ref ink 96.8 pt wide, board 96.8'),
+ "title": (700, 24.5, 'probe t-title, c4 "Skill Path": ref 100.5, board 100.5'),
+ "h1": (700, 17.5, 'probe t-h1, c2 "Level 4": ref 54.9, board 55.8, +1.6%'),
+ "sect": (700, 17.0, 'probe t-sect, c2 "Today\u2019s Quest": ref 112.6, board 112.6. '
+ 'Half a point smaller than h1, and the two do not merge: '
+ 'forcing this run to 17.5 costs c1 and c2 0.10 and 0.15'),
+ "h2": (700, 24.5, 'probe t-h2, c5 "Barrier Repair": ref 153.7, board 150.9. '
+ 'The width asks for 25px and the frame does not: 25px '
+ 'costs c5 0.31, so the -1.8% stands'),
+ "greet": (700, 20.0, 'probe t-greet, c2 "Welcome Back": ref 134.9, board 134.0'),
+ "count": (700, 16.0, 'probe t-count, c2 "235" in the gem pill: ref 27.9, board '
+ '28.9. A wider box takes the pill border with it, so this '
+ 'one was settled on a zoom pair as well, scratch/p_gem.png'),
+ "node": (600, 16.6, 'probe t-node, c4 "Under-Eye Care": ref 120.2, board 120.2'),
+ "row": (600, 15.5, 'probe t-row, c2 quest row 1 title: ref 130.3, board 131.2'),
+ "chipname": (600, 15.0, 'probe t-chipname, c4 "Oral Posture": ref 86.7, board 85.7'),
+ "cta": (700, 15.0, 'probe t-cta, c5 "START LESSON": ref 116.7, board 118.5. '
+ 'The width is what set the tracking: .09em ran 125.9 wide, '
+ '.045em lands within 2%'),
+ "body": (500, 15.0, 'probe t-body, c2 "Hi, James": ref 67.0, board 65.1'),
+ "meta": (500, 13.5, 'probe t-meta, c2 "95/120 XP": ref 63.3, board 63.3'),
+ "date": (400, 15.5, 'probe t-date, c2 the Thu numeral "12": ref 14.9, board 15.8. '
+ 'Two glyphs is a thin sample; 14.6px measures better and '
+ 'renders worse, so the round number stands'),
+ "sub": (400, 13.0, 'probe t-sub, c2 quest row 1 subtitle: ref 196.4, board 195.4'),
+ "cap": (600, 11.7, 'probe t-cap, c2 "CURRENT STREAK": ref 108.0, board 108.9. '
+ 'A cap-height read said 14px, which set it 18% too wide'),
+ "tab": (500, 12.0, 'probe t-tab, c2 "Home": ref 32.6, board 31.6. The four '
+ 'labels band at 49.3-81.9 / 144.3-168.5 / 232.7-261.5 / '
+ '313.6-363.0; "Progress" is 49.3 wide against 49.3'),
+ "day": (500, 11.0, 'probe t-day, c2 "Mon": ref 20.5, board 20.5'),
+ "count2": (600, 10.7, 'probe t-count2, c2 "1/5 completed": ref 72.6, board 72.6. '
+ '14px was 26% too wide'),
+ "chip": (600, 9.3, 'probe t-chip, c2 the quest-row "+25 XP": ref 36.3, board '
+ '36.3. 12px was 8% too wide'),
+ }
+TYPE_TOKENS = [("Type", "t-" + k, "%d %gpx/%gpx var(--x-font)"
+ % (w, fs, round(fs * 1.22, 1)), ev)
+ for k, (w, fs, ev) in TYPE.items()]
+# The clock is not fitted like the rest: it is the repo's standard chrome,
+# so it keeps the template's own 17px/22px rather than this table's 1.22 lead.
+TYPE_TOKENS.append(("Type", "t-time", "590 17px/22px var(--x-font)",
+ "repo standard chrome (templates/gen.py); the source's "
+ "clock is not measured"))
+
+TOKENS = [
+ ("Font", "font", FONT,
+ 'refkit font: "3" of 3 Days = no call, weak (0.721 SF Pro); "S" of Skill '
+ 'Path = no call, weak (0.510 SF Pro Rounded). Platform stack, cut not claimed'),
+
+ ("Surface", "sheet", "#FDFDFD",
+ "flat census, c2 quest sheet (150-250, 440-460) and c5 lesson sheet: 100%. "
+ "This is the recording's white plateau -- H.264 flat-field quantisation puts "
+ "paper white at 253, and 255 appears only as ringing beside dark text"),
+ ("Surface", "strip", "#F5F9FC",
+ "flat census, c2 weekday strip (222-252, 242-292): 100%"),
+ ("Surface", "level", "#E6F7EF",
+ "flat census, c2 level card interior (200-260, 340-360): 100%"),
+ ("Surface", "sky-1", "#9DCDE8",
+ "flat census, c4 sky at y 66-74, both sides of the island: 100%"),
+ ("Surface", "sky-2", "#94C6E2",
+ "flat census, c2 home sky at y 300-310, left of the streak card: 100%"),
+ ("Surface", "sky-3", "#90C3E2",
+ "flat census, c4 path sky at y 300-320 and y 520-528: 100%"),
+ ("Surface", "day-on", "#E2E9F5",
+ "mode of the c2 Mon check disc (47-59, 263-271), all pixels: no flat core at 20pt"),
+ ("Surface", "chip", "rgba(255,255,255,.40)",
+ "c4 inactive chip reads #C5DBED flat over #9DCDE8 sky: (197-157)/(255-157) = .41"),
+ ("Surface", "chip-on", "#F6FAFD",
+ "flat census, c4 Skincare chip right of its label (144-153, 112-140): 67%"),
+ ("Surface", "node", "#FCFEFF",
+ "mode of the c4 Barrier Repair node core (50-60, 420-450)"),
+ ("Surface", "node-off", "#BDD6E4",
+ "flat census, c4 locked Weekly Exfoliation node (158-178, 565-575): 84%"),
+ ("Surface", "cta", "#5B8BA8",
+ "flat census, c5 START LESSON button interior (150-250, 770-790): 99%"),
+ ("Surface", "cta-lip", "#43708C",
+ "column scan c5 x 200: the button's bottom lip, y 820.8-824.5"),
+ ("Surface", "scrim", "rgba(26,60,74,.415)",
+ "c5 over c4 at two grounds: sky 144,195,226 -> 95,139,163 and locked node "
+ "188,214,228 -> 121,150,165. (1-a) = 26/44 = 0.59 on red, 11/19 = 0.58 on green"),
+
+ ("Line", "dash", "#CFDDE9",
+ "mode of the c4 road centre dash core (97-100, 566-572)"),
+ ("Line", "ring", "#DDE3EA",
+ "c2 quest-progress ring, coverage solve against the #FDFDFD sheet"),
+ ("Line", "card-edge", "#F8F9FB",
+ "c2 column scan x 25: the quest row edges read 248/253 against the sheet"),
+
+ ("Ink", "ink", "#0E1114",
+ "ink core, darkest 15% of c2 3 Days (47-143, 181-209): #060606, and of "
+ "Today's Quest: #1E1E1E"),
+ ("Ink", "ink-2", "#575757", "ink core, c2 1/5 completed (270-358, 469-482)"),
+ ("Ink", "ink-3", "#8B959E", "ink core, c2 CURRENT STREAK (46-153, 157-168)"),
+ ("Ink", "ink-4", "#B9BBC1", "ink core, c2 Quests resets at midnight (38-195, 496-510)"),
+ ("Ink", "ink-sub", "#A4A9AF", "ink core, c2 quest row subtitles (78-380, 549-563)"),
+ ("Ink", "ink-sky", "#22323E", "ink core, c5 Barrier Repair title (101-256, 690-712)"),
+ ("Ink", "ink-sky-2", "#255471", "ink core, c4 Under-Eye Care node label (200-310, 277-292)"),
+ ("Ink", "ink-sky-3", "#6795B3", "ink core, c4 Weekly Exfoliation, the locked label"),
+ ("Ink", "ink-inv", "#FDFDFD", "the sheet white; white type is the same plateau"),
+ ("Ink", "ink-hdr", "rgba(253,253,253,.72)",
+ "c2 Hi, James reads #CFF0FF brightest-15% over #9BCBE6 sky"),
+
+ ("Accent", "green", "#16BE70", "mean of the c2 level-node core (38-42, 386-397)"),
+ ("Accent", "green-2", "#29BA76", "mean of the c2 level track core (82-98, 388-394)"),
+ ("Accent", "green-pale", "#D7FFEE", "mean of the c2 locked level node (269-275, 385-397)"),
+ ("Accent", "track-off", "#C5ECD7", "mean of the c2 unfilled level track (312-333, 388-394)"),
+ ("Accent", "xp", "#3FBE84", "c2 +25 XP chip label, ink core widened to the glyph"),
+ ("Accent", "tab-on", "#E3EDF3", "flat census, c4 active tab pill, 8% of the tab-bar window"),
+ ("Accent", "tab-on-ink", "#9FB2BF", "ink core, c4 active Path label (140-178, 832-843)"),
+ ("Accent", "tab-off-ink", "#4E4E4E", "ink core, c4 inactive Home icon (56-80, 807-826)"),
+ ("Accent", "play", "#5F8AA4",
+ "ink core of the c4 Barrier Repair play triangle (55-78, 428-450): #5F8AA4, "
+ "and of the c5 sheet triangle (45-62, 700-722): #608AA4"),
+ ("Accent", "gem", "#B23BD8",
+ "ink core, c2 +2 gem chip label (153-170, 574-585)"),
+ ("Accent", "lock", "#80A4BB",
+ "mode of the c4 keyhole glyph (163-171, 586-598), all pixels"),
+
+ ("Radius", "r-card", "28px", "c2 streak-card corner, row scans at y 131 and y 296"),
+ ("Radius", "r-level", "24px", "c2 level-card corner, row scans at y 334 and y 431"),
+ ("Radius", "r-row", "22px", "c2 quest-row corner"),
+ ("Radius", "r-cta", "20px", "c5 START LESSON corner"),
+ ("Radius", "r-pill", "999px", "by construction, not measured"),
+ ("Radius", "r-phone", "55px",
+ "circular stand-in for the 17 Pro's continuous display corner; refkit "
+ "--crop-phone masks the same number"),
+
+ # Type. Every size here was fitted to the reference's own ink width with
+ # refkit bbox, not read off a cap height: SF Pro is not in the renderer, the
+ # stand-in does not set to the same width, and a cap measured through H.264
+ # blur is 1-2 pt taller than the glyph. Each row carries the reference width,
+ # the width the board rendered at the size that was replaced, and the string
+ # both were measured on.
+ *TYPE_TOKENS,
+
+ ("Metrics", "w", "402px", "iPhone 17 Pro logical width"),
+ ("Metrics", "h", "874px", "iPhone 17 Pro logical height"),
+ ("Metrics", "status", "54px",
+ "repo standard chrome (templates/gen.py), iOS Dynamic Island status bar"),
+ ("Metrics", "gutter", "20px",
+ "c2 streak-card row scan: left edge 20.5, right edge 382.5, on a 402 pt frame"),
+ ("Metrics", "row", "85.2px",
+ "c2 quest titles at 527.1 / 611.8 / 697.4: pitch 84.7 and 85.6"),
+ ("Metrics", "road", "37px",
+ "c4 road verticals run x 80-117 and x 287-324 on every row between bends"),
+ ("Metrics", "bend", "39px",
+ "circle fit on the c4 bend at y 214.5: predicted left edge 85.2 / 82.4 / "
+ "80.7 at y 234 / 240 / 246 against 85 / 82 / 81 measured"),
+]
+
+
+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, each
+ at the scale of the frame it came from."""
+ if not REFS_DIR.exists():
+ return
+ try:
+ from PIL import Image # noqa: local dep
+ except ImportError: # no Pillow, no cut
+ return
+ 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")
+ k = SCALES[ref]
+ src[ref].crop(tuple(round(v * k) for v in (x0, y0, x1, y1))).save(
+ ART_DIR / (cid + ".png"), optimize=True)
+ n += 1
+ print("%-24s %6d crops" % ("assets/art/", n))
+
+
+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, dx=0.0, dy=0.0, z=None):
+ """One , placed at the box it was measured from (dx/dy only for the
+ same asset reused on a screen that scrolls it)."""
+ _, x0, y0, x1, y1 = CROPS[cid]
+ return (' '
+ % (_uri(cid), x0 + dx, y0 + dy, x1 - x0, y1 - y0,
+ ";z-index:%d" % z if z else ""))
+
+
+# ------------------------------------------------------------ phone frame ----
+BASE = """*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:var(--x-font);-webkit-font-smoothing:antialiased;
+ display:flex;justify-content:center;padding:24px}
+img.a{position:absolute;display:block}"""
+
+PHONE = """.phone{position:relative;flex:none;width:var(--x-w);height:var(--x-h);
+ border-radius:var(--x-r-phone);overflow:hidden;background:var(--x-sheet);color:var(--x-ink);
+ transform:translateZ(0);
+ box-shadow:0 0 0 11px #1D191A,0 0 0 12.5px #3A3735,0 24px 60px rgba(29,25,26,.28)}
+.sb{position:absolute;left:0;top:0;width:var(--x-w);height:var(--x-status);z-index:9}
+.sb .time{position:absolute;left:0;top:18.2px;width:142.4px;text-align:center;font:var(--x-t-time)}
+.sb .island{position:absolute;top:11px;left:50%;transform:translateX(-50%);
+ width:125px;height:36px;border-radius:20px;background:#000}
+.sb svg{position:absolute;display:block;fill:currentColor}"""
+
+# The status bar is the repo's standard chrome, copied verbatim from
+# mockups/canvases/templates/gen.py. The source's own bar was not measured
+# and its glyphs were not cropped: the repo's chrome wins over the source's,
+# the same way the phone frame does.
+# Cellular, wifi, battery at their measured status-bar positions, inheriting
+# currentColor so one call recolours the whole bar.
+SB_ICONS = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ ''
+ ' '
+ ''
+ ' '
+ ' ')
+
+
+def statusbar(colour="var(--x-ink)", time="9:41", island=True):
+ """island=False for the shell boards: the art draws its own camera housing."""
+ return (''
+ % (colour, '
' if island else "", time, SB_ICONS))
+
+
+# ----------------------------------------------------------------- emit ----
+def page(title, body, extra_css=""):
+ html = ('\n\n\n \n'
+ '%s \n\n\n\n%s\n\n\n'
+ % (title, TOKENS_CSS, BASE, PHONE, extra_css, body))
+ return html.replace("--x-", "--%s-" % P)
+
+
+def write(name, html):
+ (OUT / (name + ".html")).write_text(html)
+ print("%-24s %6d" % (name, len(html)))
+
+
+# --------------------------------------------------------------- helpers ----
+# Measured, not assumed. SF Pro is not installed in the renderer (a probe of
+# eight stacks -- -apple-system, "SF Pro Text", "SF Pro Display", "Helvetica
+# Neue", Helvetica, Arial, "SF Compact Text" -- came back byte-identical), so
+# the board is set in the platform fallback and the fallback's metrics are the
+# ones that decide where a cap lands. scratch/met.html renders HXE at 12 / 18 /
+# 25 / 31.5 / 40 px inside a block of a known top; the cap top comes out at
+# lh/2 - CAP_TOP*fs below that top, with CAP_TOP = 0.350 to +-0.008 across all
+# five sizes, and the cap itself measures 0.733 em (SF Pro's is 0.714). tx()
+# inverts the measured relation, not the nominal one: assuming SF Pro's 0.714
+# put every run low by 0.105*fs, which is 3.3 pt on the 31.5 px "3 Days".
+CAP_TOP = 0.350
+
+def tx(x, cap_top, text, style, colour="var(--x-ink)", weight=None,
+ extra="", cls="t"):
+ """Absolutely place one run of text by the cap-top the grid actually shows.
+ `style` names a row of TYPE, so the size that decides the position is the
+ same one the stylesheet sets; `weight` overrides that row's weight alone."""
+ w, fs, _ = TYPE[style]
+ lh = round(fs * 1.22, 1)
+ top = cap_top - lh / 2.0 + CAP_TOP * fs
+ return ('%s
'
+ % (cls, x, top, style,
+ ";font-weight:%d" % weight if weight and weight != w else "",
+ colour, extra, text))
+
+
+def box(x, y, w, h, r=0.0, css="", cls="b"):
+ return ('
'
+ % (cls, x, y, w, h, ";border-radius:%.1fpx" % r if r else "", css))
+
+
+def art_at(cid, x, y, w, h):
+ """The same measured asset placed a second time. Used only for the bolt and
+ gem glyphs, which the source repeats at several sizes; the crop box in
+ crops.json is still the one instance that was measured."""
+ return (' ' % (_uri(cid), x, y, w, h))
+
+
+EXTRA = """.t,.b{position:absolute}
+.t{white-space:nowrap;letter-spacing:-.01em}
+.sky{position:absolute;left:0;top:0;width:402px}
+.card{position:absolute;background:var(--x-sheet)}
+.pill{position:absolute;border-radius:999px}
+.road{position:absolute;left:0;top:0;width:402px;height:874px}
+.blur{position:absolute;left:0;top:0;width:402px;height:152px;
+ background:rgba(157,205,232,.55);
+ -webkit-backdrop-filter:blur(13px);backdrop-filter:blur(13px);z-index:4}
+.node{position:absolute;width:47px;height:47px;border-radius:24px;
+ background:var(--x-node);box-shadow:0 3px 7px rgba(31,74,97,.16)}
+.tabbar{position:absolute;left:23.5px;top:794.3px;width:355px;height:59.7px;
+ border-radius:30px;background:var(--x-sheet);
+ box-shadow:0 6px 22px rgba(40,64,80,.13);z-index:6}"""
+
+
+# ------------------------------------------------------------- tab bar ----
+TAB_C = [67.5, 158.0, 248.5, 339.0] # column centres, pitch 90.5
+TABS = ["Home", "Path", "Shop", "Progress"]
+
+TAB_ICON = {
+ "Home": ' '
+ ' ',
+ "Path": ' '
+ ' ',
+ "Shop": ' '
+ ' ',
+ "Progress": ' '
+ ' '
+ ' '
+ ' ',
+}
+
+
+def tabbar(active):
+ out = ['
']
+ i = TABS.index(active)
+ out.append(box(TAB_C[i] - 46.8, 798.3, 93.6, 52.0, 26.0,
+ ";background:var(--x-tab-on);z-index:7"))
+ for j, name in enumerate(TABS):
+ on = j == i
+ colour = "var(--x-tab-on-ink)" if on else "var(--x-tab-off-ink)"
+ out.append('%s '
+ % (TAB_C[j] - 9.35, colour, TAB_ICON[name]))
+ out.append(tx(0, 833.4, name, "tab", colour,
+ extra=";width:%gpx;left:%.1fpx;text-align:center;z-index:8"
+ % (120, TAB_C[j] - 60)))
+ return "".join(out)
+
+
+# ---------------------------------------------------------------- home ----
+DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
+DAY_C = [53.0, 102.5, 152.0, 201.5, 251.0, 300.5, 350.0] # pitch 49.5
+LEVEL_X = [48.25, 125.1, 202.0, 278.9, 355.75] # pitch 76.875
+LEVEL_XP = ["30 XP", "60 XP", "90 XP", "120 XP", "150 XP"]
+
+# emoji box per quest row: the ink centres on x 52.7, 27.6 below the row top,
+# so a 34pt box lands at 35.7. ic-razor is cut short at the tab bar.
+ICON = {"ic-dumbbell": (10.6, 34.0, 34.0), "ic-mewing": (10.6, 34.0, 34.0),
+ "ic-skincare": (10.6, 34.0, 34.0), "ic-razor": (10.6, 34.0, 16.8)}
+
+QUESTS = [
+ ("ic-skincare", "Morning Skincare Routine", "Apply cleanser, moisturizer and Shampoo"),
+ ("ic-dumbbell", "Push Day Workout", "Complete today’s push workout…"),
+ ("ic-mewing", "Mewing Practice", "5 minutes of proper tongue posture and"),
+ ("ic-skincare", "Morning Skincare Routine", "Apply cleanser, moisturizer and Shampoo"),
+ ("ic-razor", "Morning Skincare Routine", "Apply cleanser, moisturizer and Shampoo"),
+]
+
+CHECK = (''
+ ' ')
+
+
+def home(done):
+ """done = how many of the five quests have been cleared; the source shows
+ 0/5 (c1) and 1/5 (c2), and the cleared row leaves the top of the list."""
+ o = ['
',
+ art("home-clouds"),
+ box(0, 312, 402, 562, 0,
+ ";background:var(--x-sheet);border-radius:36px 36px 0 0"),
+ tx(22.3, 75.4, "Hi, James", "body", "var(--x-ink-hdr)"),
+ tx(22.3, 98.7, "Welcome Back", "greet", "var(--x-ink-inv)"),
+ box(252.2, 78.2, 75.4, 36.3, 18.15,
+ ";background:rgba(255,255,255,.16);"
+ "border:1px solid rgba(255,255,255,.46)"),
+ art("gem-hdr"),
+ tx(287.5, 86.5, "235", "count", "var(--x-ink-inv)"),
+ art("avatar")]
+
+ # streak card
+ o += [box(20.5, 130.5, 362.0, 166.0, 28.0, ";background:var(--x-sheet);"
+ "box-shadow:0 6px 18px rgba(40,72,92,.10)"),
+ box(20.5, 231.0, 362.0, 65.5, 0, ";background:var(--x-strip);"
+ "border-radius:0 0 28px 28px"),
+ tx(45.6, 157.3, "CURRENT STREAK", "cap", "var(--x-ink-3)",
+ extra=";letter-spacing:.02em"),
+ tx(46.5, 180.6, "3 Days", "hero", "var(--x-ink)"),
+ art("medal")]
+ for i, d in enumerate(DAYS):
+ on = i < 3
+ o.append(tx(0, 248.5, d, "day", "var(--x-ink-3)",
+ extra=";width:80px;left:%.1fpx;text-align:center%s"
+ % (DAY_C[i] - 40,
+ ";color:var(--x-ink-2);font-weight:700" if i == 3 else "")))
+ if on:
+ o.append(box(DAY_C[i] - 10, 261.0, 20.0, 20.0, 10.0,
+ ";background:var(--x-day-on)"))
+ o.append(CHECK % (DAY_C[i] - 7, 264.0, 14, 14, "var(--x-ink-3)", 2.6))
+ else:
+ o.append(tx(0, 264.3, str(9 + i), "date",
+ "var(--x-ink-2)" if i == 3 else "var(--x-ink-3)",
+ weight=700 if i == 3 else 400,
+ extra=";width:80px;left:%.1fpx;text-align:center"
+ % (DAY_C[i] - 40)))
+ o.append(box(198.5, 283.5, 6.0, 6.0, 3.0, ";background:var(--x-ink-2)"))
+
+ # level card
+ o += [box(19.5, 333.8, 362.5, 98.2, 24.0, ";background:var(--x-level)"),
+ art("crown"),
+ tx(62.4, 349.0, "Level 4", "h1", "#0F1B15"),
+ art("bolt-lv"),
+ tx(307.1, 350.9, "95/120 XP", "meta", "#72887D")]
+ o.append(box(LEVEL_X[0], 386.9, LEVEL_X[-1] - LEVEL_X[0], 9.4, 4.7,
+ ";background:var(--x-track-off)"))
+ o.append(box(LEVEL_X[0], 386.9, 231.0 - LEVEL_X[0], 9.4, 4.7,
+ ";background:var(--x-green-2)"))
+ for i, x in enumerate(LEVEL_X):
+ on = i < 3
+ o.append(box(x - 13.5, 382.6, 27.0, 18.0, 9.0,
+ ";background:var(--x-green)" if on
+ else ";background:var(--x-green-pale)"))
+ if on:
+ o.append(CHECK % (x - 6.5, 385.1, 13, 13, "var(--x-ink-inv)", 2.8))
+ else:
+ o.append(' '
+ ' ' % (x - 3))
+ o.append(tx(0, 410.4, LEVEL_XP[i], "meta", "#435849",
+ extra=";width:120px;left:%.1fpx;text-align:center" % (x - 60)))
+
+ # quest card
+ rows = QUESTS[done:done + 4]
+ o += [box(20.5, 450.0, 361.0, 382.0, 24.0, ";background:var(--x-sheet);"
+ "border:1px solid var(--x-card-edge)"),
+ tx(37.2, 468.1, "Today’s Quest", "sect", "var(--x-ink)"),
+ tx(38.2, 496.0, "Quests resets at midnight", "sub", "var(--x-ink-4)"),
+ box(256.0, 465.0, 111.5, 22.5, 11.25, ";background:var(--x-sheet);"
+ "border:1px solid var(--x-card-edge)"),
+ ''
+ ' '
+ ' '
+ % (10.7 * done),
+ tx(285.0, 470.6, "%d/5 completed" % done, "count2", "var(--x-ink-2)")]
+ for i, (icon, title, sub) in enumerate(rows):
+ top = 511.0 + i * 85.2
+ o.append(box(27.5, top, 350.2, 82.0, 22.0, ";background:var(--x-strip)"))
+ dy, w, h = ICON[icon]
+ o.append(art_at(icon, 35.7, top + dy, w, h))
+ o.append(tx(77.3, top + 16.1, title, "row", "var(--x-ink)"))
+ o.append(tx(77.3, top + 38.5, sub, "sub", "var(--x-ink-sub)"))
+ o.append(art_at("bolt-chip", 77.0, top + 62.0, 18.0, 13.0))
+ o.append(tx(96.5, top + 65.4, "+25 XP", "chip", "var(--x-xp)"))
+ o.append(art_at("gem-chip", 135.0, top + 60.0, 17.0, 13.0))
+ o.append(tx(155.5, top + 65.4, "+2", "chip", "var(--x-gem)"))
+ o.append(box(341.0, top + 20.5, 28.5, 28.5, 14.25,
+ ";border:1.6px dashed var(--x-ring)"))
+ o.append(tabbar("Home"))
+ return "".join(o)
+
+
+# ---------------------------------------------------------------- path ----
+# Road centreline in content pt. The board at scroll 0 is c4; c3 is the same
+# content 184.3 lower down, which is what SCROLL_B holds.
+ROAD = [(305.5, -200.0), (305.5, 213.3), (98.5, 213.3), (98.5, 363.9),
+ (305.5, 363.9), (305.5, 513.9), (98.5, 513.9), (98.5, 698.1),
+ (305.5, 698.1), (305.5, 883.4), (98.5, 883.4), (98.5, 1120.0)]
+BEND = 39.0
+SCROLL_B = -184.3
+
+CHIPS = [("Fitness", -60.0, 96.7, None, None),
+ ("Skincare", 46.7, 108.3, "chip-skincare", 85.5),
+ ("Hair", 165.0, 76.0, "chip-hair", 199.5),
+ ("Oral Posture", 251.0, 132.0, "chip-oral", 284.0)]
+ACTIVE_CHIP = "Skincare"
+
+# node: (x, y, state, label)
+NODES = [(65.5, 138.1, "done", "Morning Cleanse"),
+ (164.4, 288.4, "done", "Under-Eye Care"),
+ (65.5, 438.4, "play", "Barrier Repair"),
+ (166.8, 588.8, "locked", "Weekly Exfoliation"),
+ (65.5, 808.3, "play", "Face Shape & Cut"),
+ (166.8, 958.4, "locked", "Washing Correctly")]
+
+
+def road_d(dy):
+ """One path string with BEND-radius corners at every turn."""
+ pts = [(x, y + dy) for x, y in ROAD]
+ d = ["M %.1f %.1f" % pts[0]]
+ for i in range(1, len(pts) - 1):
+ (px, py), (cx, cy), (nx, ny) = pts[i - 1], pts[i], pts[i + 1]
+ ax = cx + (0 if px == cx else (BEND if px > cx else -BEND))
+ ay = cy + (0 if py == cy else (BEND if py > cy else -BEND))
+ bx = cx + (0 if nx == cx else (BEND if nx > cx else -BEND))
+ by = cy + (0 if ny == cy else (BEND if ny > cy else -BEND))
+ d.append("L %.1f %.1f Q %.1f %.1f %.1f %.1f" % (ax, ay, cx, cy, bx, by))
+ d.append("L %.1f %.1f" % pts[-1])
+ return " ".join(d)
+
+
+def road(dy):
+ p = road_d(dy)
+ return (' '
+ ' ' % (p, p))
+
+
+LOCK_BOX = CROPS["lock"]
+
+
+def nodes(dy):
+ o = []
+ for x, y, state, label in NODES:
+ cy = y + dy
+ if cy < -60 or cy > 960:
+ continue
+ locked = state == "locked"
+ o.append(box(x - 23.5, cy - 23.5, 47.0, 47.0, 23.5,
+ ";background:var(--x-node-off);z-index:2" if locked
+ else ";background:var(--x-node);z-index:2;"
+ "box-shadow:0 3px 7px rgba(31,74,97,.16)"))
+ if state == "done":
+ o.append((CHECK % (x - 8.5, cy - 8.5, 17, 17,
+ "var(--x-ink-sky-2)", 2.4))
+ .replace("position:absolute;", "position:absolute;z-index:3;"))
+ elif state == "play":
+ o.append(''
+ ' '
+ % (x - 6.5, cy - 10.15))
+ else:
+ o.append(art("lock", x - (LOCK_BOX[1] + LOCK_BOX[3]) / 2.0,
+ cy - (LOCK_BOX[2] + LOCK_BOX[4]) / 2.0, z=3))
+ o.append(tx(x + 35.5, cy - 14.1, label, "node",
+ "var(--x-ink-sky-3)" if locked else "var(--x-ink-sky-2)",
+ extra=";z-index:3"))
+ o.append(box(x + 35.4, cy + 9.35, 59.1, 17.5, 8.75,
+ ";background:rgba(255,255,255,.92);z-index:3"))
+ o.append(art_at("bolt-chip", x + 41.0, cy + 11.6, 11.5, 13.0))
+ o.append(tx(x + 55.0, cy + 13.4, "+25 XP", "cap", "var(--x-xp)",
+ extra=";z-index:4"))
+ return "".join(o)
+
+
+def path(dy, clouds=True):
+ o = ['
']
+ if clouds:
+ o.append(art("cloud-l", 0.0, dy * 0.26))
+ o.append(road(dy))
+ o.append(nodes(dy))
+ o.append('
')
+ o.append(tx(22.7, 80.8, "Skill Path", "title", "var(--x-ink-inv)",
+ extra=";z-index:5"))
+ for name, x, w, cid, tx0 in CHIPS:
+ on = name == ACTIVE_CHIP
+ o.append(box(x, 106.0, w, 40.0, 20.0,
+ ";z-index:5;background:%s"
+ % ("var(--x-chip-on)" if on else "var(--x-chip)")))
+ if cid:
+ o.append(art(cid, 0.0, 0.0, 6))
+ if tx0:
+ o.append(tx(tx0, 118.5, name, "chipname", "#22303A", extra=";z-index:6"))
+ else:
+ o.append(tx(x + 48.0, 118.5, name, "chipname", "#22303A", extra=";z-index:6"))
+ return "".join(o)
+
+
+# -------------------------------------------------------- lesson sheet ----
+# Five circles whose centres all land on y 705: the scalloped top of the sheet,
+# fitted to the white/scrim boundary sampled every 4pt in c5.
+SCALLOP = [(52.0, 56.5), (146.0, 76.2), (230.0, 54.5), (310.0, 65.25), (388.0, 58.0)]
+
+
+def sheet():
+ circles = "".join(' ' % (x, r)
+ for x, r in SCALLOP)
+ o = [box(0, 0, 402, 874, 0, ";background:var(--x-scrim);z-index:10"),
+ ''
+ '%s '
+ ' ' % circles,
+ box(24.5, 680.0, 58.0, 58.0, 29.0,
+ ";background:var(--x-tab-on);z-index:12"),
+ ''
+ ' ',
+ tx(102.8, 694.5, "Barrier Repair", "h2", "var(--x-ink-sky)",
+ extra=";z-index:13"),
+ art_at("bolt-chip", 101.9, 725.0, 12.5, 14.0),
+ tx(119.0, 726.6, "+25 XP", "cap", "var(--x-xp)", extra=";z-index:13"),
+ box(22.0, 767.5, 358.3, 57.0, 20.0,
+ ";z-index:12;background:linear-gradient(180deg,"
+ "var(--x-cta) 0,var(--x-cta) 52.5px,var(--x-cta-lip) 52.5px)"),
+ tx(142.6, 788.1, "START LESSON", "cta", "var(--x-ink-inv)",
+ extra=";letter-spacing:.045em;z-index:13")]
+ return "".join(o)
+
+
+# --------------------------------------------------------------- boards ----
+SCREENS = [
+ ("01-home", "Home", "5:54", lambda: home(0) ),
+ ("02-home-quest-done", "Home, one cleared", "5:55", lambda: home(1) ),
+ ("03-path", "Skill Path", "6:09",
+ lambda: path(0.0) + tabbar("Path")),
+ ("04-path-scrolled", "Skill Path scrolled", "6:09",
+ lambda: path(SCROLL_B) + tabbar("Path")),
+ ("05-lesson-sheet", "Lesson sheet", "6:09",
+ lambda: path(0.0) + tabbar("Path") + sheet()),
+]
+
+REFS = [("01-home", "c1"), ("02-home-quest-done", "c2"), ("03-path", "c4"),
+ ("04-path-scrolled", "c3"), ("05-lesson-sheet", "c5")]
+
+
+def screen(name, title, time, build):
+ body = ('%s%s
'
+ % (build(), statusbar("var(--x-ink-inv)", time)))
+ return page("%s - %s" % (NAME, title), body, EXTRA)
+
+
+def ref_boards():
+ """ref-NN-*.html hold the capture itself, at 1pt = 1px beside the rebuild.
+ Never committed: assets/refs/ is third-party and .gitignore already keeps
+ it out. Regenerating them is what `clone-prototype` phase 5 does."""
+ if not REFS_DIR.exists():
+ return
+ for board, ref in REFS:
+ f = REFS_DIR / (ref + ".png")
+ if not f.exists():
+ continue
+ uri = "data:image/png;base64," + base64.b64encode(f.read_bytes()).decode()
+ body = (''
+ '
' % uri)
+ write("ref-" + board, page("%s - reference %s" % (NAME, ref), body, EXTRA))
+
+
+# --------------------------------------------------------- token boards ----
+# The artboard is 478 x 980 and the evidence is the deliverable, so the tables
+# split across boards rather than being trimmed to fit. Both row counts below
+# are the largest value that still reports `fits` under
+# refkit shoot ./0*.html -o scratch/ovf --scale 2 --check-overflow
+# run WITHOUT --crop-phone: with it a full-bleed board is cropped to the phone
+# rect first and an overflowing table never shows up.
+TOK_ROWS = 14 # 73 tokens -> 6 boards of 13/12/12/12/12/12; 15 rows
+ # overflows by 10px, on the one chunk that carries
+ # --sp-font's 107-character stack
+EV_ROWS = 11 # 73 rows -> 7 boards of 11/11/11/10/10/10/10; 12 overflows
+ # by 5px
+SUF = "bcdefghijklmnopqrstuv"
+
+# 478 - 2 x 24px body padding. The artboard clips horizontally as silently as
+# it clips vertically -- and --check-overflow does not catch that one, because
+# it only flags an element whose own overflow is `hidden`, never the page
+# running wider than the board. So the table is pinned to the width rather than
+# left to size itself. Left to itself it sized to --sp-font's stack: the old
+# board's row dividers measure 1033px wide, starting at the 24px padding and
+# ending 579px past the right edge of the 478px artboard, so the whole value
+# column was off the board and --check-overflow still said `fits`.
+TW = 430
+
+TABLE_CSS = """.w{flex:none;width:%dpx}
+h1{font:600 15px/1.4 var(--x-font);color:var(--x-ink);margin:0 0 4px 2px}
+p.cap{font:12px/1.5 var(--x-font);color:#8B959E;margin:0 0 12px 2px}
+table{border-collapse:collapse;background:var(--x-sheet);border-radius:14px;
+ overflow:hidden;box-shadow:0 8px 30px rgba(40,64,80,.12);
+ width:%dpx;table-layout:fixed}
+td{padding:7px 12px;border-top:1px solid #EEF1F4;overflow-wrap:anywhere}
+tr:first-child td{border-top:0}
+code{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}""" % (TW, TW)
+
+
+def _split(seq, cap):
+ """The fewest boards of at most `cap` rows, balanced. Slicing at the cap
+ would leave the last board a three-row stub; this spreads the remainder,
+ and every chunk still comes in under the count that was measured to fit."""
+ n = -(-len(seq) // cap)
+ out, i = [], 0
+ for k in range(n):
+ take = -(-(len(seq) - i) // (n - k))
+ out.append(seq[i:i + take])
+ i += take
+ return out
+
+
+def _of(k, pages):
+ return " %d/%d" % (k + 1, len(pages)) if len(pages) > 1 else ""
+
+
+def _sheet(title, cap, table, css):
+ return page("%s - %s" % (NAME, title),
+ '' % (title, cap, table),
+ TABLE_CSS + "\n" + css)
+
+
+def tokens_board(chunk, of):
+ rows = []
+ for group, name, value, _ in chunk:
+ swatch = ""
+ if value.startswith("#") or value.startswith("rgba"):
+ swatch = (' ' % value)
+ rows.append('%s --%s-%s '
+ '%s%s '
+ % (group, P, name, swatch, value.replace("<", "<")))
+ css = """table{font:13px/1.5 var(--x-font)}
+td{vertical-align:middle}
+td:first-child{color:#8B959E;width:76px}
+td:nth-child(2){width:116px}
+i{display:inline-block;width:15px;height:15px;border-radius:4px;
+ margin-right:8px;vertical-align:-3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.10)}"""
+ return _sheet("Design tokens" + of,
+ "%d of the %d tokens. One shared :root, "
+ "byte-identical in every board." % (len(chunk), len(TOKENS)),
+ "".join(rows), css)
+
+
+def evidence_board(chunk, of):
+ rows = ['--%s-%s%s '
+ % (P, name, ev.replace("<", "<"))
+ for _, name, _, ev in chunk]
+ css = """table{font:13px/1.55 var(--x-font)}
+td{vertical-align:top}
+td:first-child{width:126px}"""
+ return _sheet("Evidence" + of,
+ "One row per token, and what it was measured on. "
+ "A token with no evidence is a guess.",
+ "".join(rows), css)
+
+
+def foundation_boards():
+ """Every board of the Foundations row, in canvas order: the token table,
+ then the evidence table, each split across as many boards as the 980px
+ artboard takes. The `00`, `00b`, `00c` ... suffixes run across both sets,
+ so the filenames sort into the order the row lists them."""
+ tok, evd = _split(TOKENS, TOK_ROWS), _split(TOKENS, EV_ROWS)
+ i = 0
+ for k, chunk in enumerate(tok):
+ yield ("00%s-design-tokens" % ("" if i == 0 else SUF[i - 1]),
+ "Design tokens" + _of(k, tok), tokens_board(chunk, _of(k, tok)))
+ i += 1
+ for k, chunk in enumerate(evd):
+ yield ("00%s-evidence" % ("" if i == 0 else SUF[i - 1]),
+ "Evidence" + _of(k, evd), evidence_board(chunk, _of(k, evd)))
+ i += 1
+
+
+FOUNDATIONS = list(foundation_boards())
+
+
+# --------------------------------------------------------------- layout ----
+LAYOUT = {
+ "name": PAGE_NAME,
+ "rows": [
+ {"title": "Foundations",
+ "files": [{"file": n, "label": lbl} for n, lbl, _ in FOUNDATIONS]},
+ {"title": "Screens", "numbered": True,
+ "files": [{"file": n, "label": l} for n, l, _, _ in SCREENS]},
+ # Same order as the row above: the canvas lays every row out from x = 0 at
+ # one pitch, so item N here lands column-for-column under item N up there.
+ {"title": "Source of truth: captures", "numbered": True,
+ "files": [{"file": "ref-" + n, "label": dict((a, b) for a, b, _, _ in SCREENS)[n]}
+ for n, _ in REFS]},
+ ],
+}
+
+
+if __name__ == "__main__":
+ cut()
+ for name, _, html in FOUNDATIONS:
+ write(name, html)
+ for name, title, time, build in SCREENS:
+ write(name, screen(name, title, time, build))
+ ref_boards()
+ (OUT / "layout.json").write_text(json.dumps(LAYOUT, indent=2) + "\n")
+ print("%-24s %6d rows" % ("layout.json", len(LAYOUT["rows"])))
+ print("\nnext: refkit tokens", OUT)
diff --git a/mockups/canvases/skillpath-ios/icon.png b/mockups/canvases/skillpath-ios/icon.png
new file mode 100644
index 00000000..4c33a7ac
Binary files /dev/null and b/mockups/canvases/skillpath-ios/icon.png differ
diff --git a/mockups/canvases/skillpath-ios/layout.json b/mockups/canvases/skillpath-ios/layout.json
new file mode 100644
index 00000000..5dec4e81
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/layout.json
@@ -0,0 +1,114 @@
+{
+ "name": "(example) Skill Path",
+ "rows": [
+ {
+ "title": "Foundations",
+ "files": [
+ {
+ "file": "00-design-tokens",
+ "label": "Design tokens 1/6"
+ },
+ {
+ "file": "00b-design-tokens",
+ "label": "Design tokens 2/6"
+ },
+ {
+ "file": "00c-design-tokens",
+ "label": "Design tokens 3/6"
+ },
+ {
+ "file": "00d-design-tokens",
+ "label": "Design tokens 4/6"
+ },
+ {
+ "file": "00e-design-tokens",
+ "label": "Design tokens 5/6"
+ },
+ {
+ "file": "00f-design-tokens",
+ "label": "Design tokens 6/6"
+ },
+ {
+ "file": "00g-evidence",
+ "label": "Evidence 1/7"
+ },
+ {
+ "file": "00h-evidence",
+ "label": "Evidence 2/7"
+ },
+ {
+ "file": "00i-evidence",
+ "label": "Evidence 3/7"
+ },
+ {
+ "file": "00j-evidence",
+ "label": "Evidence 4/7"
+ },
+ {
+ "file": "00k-evidence",
+ "label": "Evidence 5/7"
+ },
+ {
+ "file": "00l-evidence",
+ "label": "Evidence 6/7"
+ },
+ {
+ "file": "00m-evidence",
+ "label": "Evidence 7/7"
+ }
+ ]
+ },
+ {
+ "title": "Screens",
+ "numbered": true,
+ "files": [
+ {
+ "file": "01-home",
+ "label": "Home"
+ },
+ {
+ "file": "02-home-quest-done",
+ "label": "Home, one cleared"
+ },
+ {
+ "file": "03-path",
+ "label": "Skill Path"
+ },
+ {
+ "file": "04-path-scrolled",
+ "label": "Skill Path scrolled"
+ },
+ {
+ "file": "05-lesson-sheet",
+ "label": "Lesson sheet"
+ }
+ ]
+ },
+ {
+ "title": "Source of truth: captures",
+ "numbered": true,
+ "files": [
+ {
+ "file": "ref-01-home",
+ "label": "Home"
+ },
+ {
+ "file": "ref-02-home-quest-done",
+ "label": "Home, one cleared"
+ },
+ {
+ "file": "ref-03-path",
+ "label": "Skill Path"
+ },
+ {
+ "file": "ref-04-path-scrolled",
+ "label": "Skill Path scrolled"
+ },
+ {
+ "file": "ref-05-lesson-sheet",
+ "label": "Lesson sheet"
+ }
+ ]
+ }
+ ]
+}
diff --git a/mockups/canvases/skillpath-ios/probes.json b/mockups/canvases/skillpath-ios/probes.json
new file mode 100644
index 00000000..b083f351
--- /dev/null
+++ b/mockups/canvases/skillpath-ios/probes.json
@@ -0,0 +1,817 @@
+[
+ {
+ "id": "sheet",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 150,
+ 440,
+ 250,
+ 460
+ ],
+ "pt": 1.0745,
+ "only": "flat",
+ "_": "c2 quest sheet between the section head (ends 510) is above and row 1 (starts 517): an empty band of the sheet, inside both gutters. Reads the H.264 white plateau, not 255",
+ "_head": "Phase 1's measurements, replayable. The recording zooms, so every row carries its own \"pt\": c1 1.01194, c2 1.07450, c3 1.01490, c4 1.01490, c5 1.07985. Boxes are design pt on a 402x874 frame. Run with --against scratch/mine after refkit shoot + scratch/rs.py. The status bar has no probe: it is the repo's standard chrome, not the source's."
+ },
+ {
+ "id": "strip",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 222,
+ 242,
+ 252,
+ 292
+ ],
+ "pt": 1.0745,
+ "only": "flat",
+ "_": "c2 weekday strip between the Thu and Fri columns: no glyph, no check disc"
+ },
+ {
+ "id": "level",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 200,
+ 340,
+ 260,
+ 360
+ ],
+ "pt": 1.0745,
+ "only": "flat",
+ "_": "c2 level card interior, right of Level 4 (ends 119) and above the track (starts 386)"
+ },
+ {
+ "id": "sky-1",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 20,
+ 66,
+ 120,
+ 74
+ ],
+ "pt": 1.0149,
+ "only": "flat",
+ "_": "c4 sky left of the island (starts 138.5) and above the Skill Path cap (68.5)"
+ },
+ {
+ "id": "sky-2",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 2,
+ 300,
+ 19,
+ 310
+ ],
+ "pt": 1.0745,
+ "only": "flat",
+ "_": "c2 home sky in the 8pt gap below the streak card (ends 296) and above the level card (334), left of both card edges (20.5). The home sky is a wash, so this is the mode of its flats, not a 100% census"
+ },
+ {
+ "id": "sky-3",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 20,
+ 300,
+ 60,
+ 320
+ ],
+ "pt": 1.0149,
+ "only": "flat",
+ "_": "c4 path sky left of the road (verticals start x 80)"
+ },
+ {
+ "id": "day-on",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 47,
+ 263,
+ 59,
+ 271
+ ],
+ "pt": 1.0745,
+ "only": "all",
+ "_": "c2 Mon check disc core. Mode of all pixels: a 20pt disc in a 1.07x frame has no flat interior"
+ },
+ {
+ "id": "chip-on",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 144,
+ 112,
+ 153,
+ 140
+ ],
+ "pt": 1.0149,
+ "only": "flat",
+ "_": "c4 active Skincare chip, right of its label (ends 141.5) and inside the pill's right edge"
+ },
+ {
+ "id": "node",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 50,
+ 420,
+ 60,
+ 450
+ ],
+ "pt": 1.0149,
+ "only": "all",
+ "_": "c4 Barrier Repair node body, left of the play triangle (starts 55 by ink) -- mode, not census"
+ },
+ {
+ "id": "node-off",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 158,
+ 565,
+ 178,
+ 575
+ ],
+ "pt": 1.0149,
+ "only": "flat",
+ "_": "c4 locked Weekly Exfoliation node, left of the keyhole (163-171)"
+ },
+ {
+ "id": "cta",
+ "img": "assets/refs/c5.png",
+ "cmd": "sample",
+ "box": [
+ 150,
+ 770,
+ 250,
+ 790
+ ],
+ "pt": 1.07985,
+ "only": "flat",
+ "_": "c5 START LESSON button, above the cap top (788) is wrong -- this band is the button fill left of the label's first glyph at x 142.6"
+ },
+ {
+ "id": "dash",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 97,
+ 566,
+ 100,
+ 572
+ ],
+ "pt": 1.0149,
+ "only": "all",
+ "_": "c4 road centre dash, one dash core between the x 80-117 verticals"
+ },
+ {
+ "id": "ink",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 47,
+ 181,
+ 143,
+ 209
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "ink": 15,
+ "_": "c2 3 Days. Darkest 15%: the token is #0E1114 against a measured #060606 core, because a 31.5px cap in a 1.07x frame overshoots dark on its stems"
+ },
+ {
+ "id": "ink-2",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 270,
+ 469,
+ 358,
+ 482
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "_": "c2 1/5 completed, the quest counter"
+ },
+ {
+ "id": "ink-3",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 46,
+ 157,
+ 153,
+ 168
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "_": "c2 CURRENT STREAK, the all-caps label"
+ },
+ {
+ "id": "ink-4",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 38,
+ 496,
+ 195,
+ 510
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "_": "c2 Quests resets at midnight, the section subhead"
+ },
+ {
+ "id": "ink-sub",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 78,
+ 549,
+ 380,
+ 563
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "_": "c2 quest row 1 subtitle"
+ },
+ {
+ "id": "ink-sky",
+ "img": "assets/refs/c5.png",
+ "cmd": "sample",
+ "box": [
+ 101,
+ 690,
+ 256,
+ 712
+ ],
+ "pt": 1.07985,
+ "only": "ink",
+ "_": "c5 Barrier Repair, the sheet title"
+ },
+ {
+ "id": "ink-sky-2",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 200,
+ 277,
+ 310,
+ 292
+ ],
+ "pt": 1.0149,
+ "only": "ink",
+ "_": "c4 Under-Eye Care, an unlocked node label on sky"
+ },
+ {
+ "id": "ink-hdr",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 22,
+ 68,
+ 90,
+ 84
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "bright": true,
+ "_": "c2 Hi, James: light type on the sky, so the ink core is the BRIGHTEST percentile. #CFF0FF over the #9BCBE6 ground solves to white at 72%"
+ },
+ {
+ "id": "green",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 38,
+ 386,
+ 42,
+ 397
+ ],
+ "pt": 1.0745,
+ "only": "all",
+ "_": "c2 level-node core, the filled node under Level 4"
+ },
+ {
+ "id": "green-2",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 82,
+ 388,
+ 98,
+ 394
+ ],
+ "pt": 1.0745,
+ "only": "all",
+ "_": "c2 level track, a filled span between two nodes"
+ },
+ {
+ "id": "green-pale",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 269,
+ 385,
+ 275,
+ 397
+ ],
+ "pt": 1.0745,
+ "only": "all",
+ "_": "c2 locked level node"
+ },
+ {
+ "id": "track-off",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 312,
+ 388,
+ 333,
+ 394
+ ],
+ "pt": 1.0745,
+ "only": "all",
+ "_": "c2 unfilled level track, right of the last filled node"
+ },
+ {
+ "id": "gem",
+ "img": "assets/refs/c2.png",
+ "cmd": "sample",
+ "box": [
+ 153,
+ 574,
+ 170,
+ 585
+ ],
+ "pt": 1.0745,
+ "only": "ink",
+ "_": "c2 +2 gem chip label on quest row 1. This probe is the worst colour miss in the file and it is expected: a 10px magenta glyph is two pixels wide in a 1.07x video frame, so the capture never reaches the ink and its darkest 2% comes back desaturated (#8D6898) while the board reaches the token exactly"
+ },
+ {
+ "id": "lock",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 163,
+ 586,
+ 171,
+ 598
+ ],
+ "pt": 1.0149,
+ "only": "all",
+ "_": "c4 keyhole glyph on the locked node"
+ },
+ {
+ "id": "tab-on-ink",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 140,
+ 832,
+ 178,
+ 843
+ ],
+ "pt": 1.0149,
+ "only": "ink",
+ "_": "c4 active Path tab label"
+ },
+ {
+ "id": "tab-off-ink",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 56,
+ 807,
+ 80,
+ 826
+ ],
+ "pt": 1.0149,
+ "only": "ink",
+ "_": "c4 inactive Home tab icon stroke"
+ },
+ {
+ "id": "play",
+ "img": "assets/refs/c4.png",
+ "cmd": "sample",
+ "box": [
+ 55,
+ 428,
+ 78,
+ 450
+ ],
+ "pt": 1.0149,
+ "only": "ink",
+ "_": "c4 Barrier Repair play triangle"
+ },
+ {
+ "id": "t-hero",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 44,
+ 176,
+ 160,
+ 214
+ ],
+ "pt": 1.0745,
+ "_": "c2 \"3 Days\" -- 31.5px. Box clears the card edge at x 20.5 and the flame art above"
+ },
+ {
+ "id": "t-title",
+ "img": "assets/refs/c4.png",
+ "cmd": "bbox",
+ "box": [
+ 18,
+ 66,
+ 170,
+ 100
+ ],
+ "pt": 1.0149,
+ "bright": 225,
+ "_": "c4 \"Skill Path\" -- 25px, white on sky, so a bright cut"
+ },
+ {
+ "id": "t-h1",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 60,
+ 338,
+ 124,
+ 362
+ ],
+ "pt": 1.0745,
+ "_": "c2 \"Level 4\" -- 17.5px. Left edge clears the crown art that ends at 58"
+ },
+ {
+ "id": "t-sect",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 35,
+ 458,
+ 158,
+ 482
+ ],
+ "pt": 1.0745,
+ "_": "c2 \"Today\u2019s Quest\" -- 17px, the one run that does not take h1"
+ },
+ {
+ "id": "t-h2",
+ "img": "assets/refs/c5.png",
+ "cmd": "bbox",
+ "box": [
+ 98,
+ 684,
+ 264,
+ 716
+ ],
+ "pt": 1.07985,
+ "_": "c5 \"Barrier Repair\" -- 24.5px on the lesson sheet"
+ },
+ {
+ "id": "t-greet",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 20,
+ 86,
+ 172,
+ 112
+ ],
+ "pt": 1.0745,
+ "bright": 225,
+ "_": "c2 \"Welcome Back\" -- 19.5px, white on sky"
+ },
+ {
+ "id": "t-count",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 284,
+ 82,
+ 318,
+ 100
+ ],
+ "pt": 1.0745,
+ "bright": 225,
+ "_": "c2 \"235\" inside the gem pill. The pill border contaminates a wider box, so this one is cut to the numerals and the size was confirmed by eye on scratch/p_gem.png"
+ },
+ {
+ "id": "t-node",
+ "img": "assets/refs/c4.png",
+ "cmd": "bbox",
+ "box": [
+ 180,
+ 270,
+ 380,
+ 290
+ ],
+ "pt": 1.0149,
+ "_": "c4 \"Under-Eye Care\" -- 17px, the node label the size was fitted on"
+ },
+ {
+ "id": "t-row",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 75,
+ 518,
+ 300,
+ 540
+ ],
+ "pt": 1.0745,
+ "_": "c2 quest row 1 title -- 15.5px"
+ },
+ {
+ "id": "t-chipname",
+ "img": "assets/refs/c4.png",
+ "cmd": "bbox",
+ "box": [
+ 278,
+ 106,
+ 380,
+ 134
+ ],
+ "pt": 1.0149,
+ "_": "c4 \"Oral Posture\" -- 15px, the widest chip label"
+ },
+ {
+ "id": "t-cta",
+ "img": "assets/refs/c5.png",
+ "cmd": "bbox",
+ "box": [
+ 136,
+ 780,
+ 272,
+ 802
+ ],
+ "pt": 1.07985,
+ "bright": 225,
+ "_": "c5 \"START LESSON\" -- 15px at .045em; the width is what set the tracking"
+ },
+ {
+ "id": "t-body",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 20,
+ 66,
+ 96,
+ 86
+ ],
+ "pt": 1.0745,
+ "bright": 225,
+ "_": "c2 \"Hi, James\" -- 15px, white on sky"
+ },
+ {
+ "id": "t-meta",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 300,
+ 342,
+ 382,
+ 364
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 \"95/120 XP\" -- 13.5px"
+ },
+ {
+ "id": "t-date",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 192,
+ 260,
+ 216,
+ 278
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 the Thu numeral \"12\" -- 15.5px, the bold selected day"
+ },
+ {
+ "id": "t-sub",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 75,
+ 542,
+ 380,
+ 566
+ ],
+ "pt": 1.0745,
+ "dark": 210,
+ "_": "c2 quest row 1 subtitle -- 13px, kept at the size it was measured at"
+ },
+ {
+ "id": "t-cap",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 43,
+ 152,
+ 160,
+ 172
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 \"CURRENT STREAK\" -- 12px. A cap-height read said 14px and set it 18% too wide"
+ },
+ {
+ "id": "t-tab",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 45,
+ 830,
+ 86,
+ 846
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 the \"Home\" tab label -- 12px. The four labels band at 49.3-81.9, 144.3-168.5, 232.7-261.5 and 313.6-363.0; Home is the one no neighbour can leak into"
+ },
+ {
+ "id": "t-day",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 42,
+ 242,
+ 66,
+ 258
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 \"Mon\" -- 11px; exact at the size chosen"
+ },
+ {
+ "id": "t-count2",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 282,
+ 464,
+ 364,
+ 486
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 \"1/5 completed\" -- 11px. 14px was 26% too wide"
+ },
+ {
+ "id": "t-chip",
+ "img": "assets/refs/c2.png",
+ "cmd": "bbox",
+ "box": [
+ 88,
+ 573,
+ 134,
+ 587
+ ],
+ "pt": 1.0745,
+ "dark": 200,
+ "_": "c2 the \"+25 XP\" chip on quest row 1 -- 10px"
+ },
+ {
+ "id": "c1-sheet",
+ "img": "assets/refs/c1.png",
+ "cmd": "sample",
+ "box": [
+ 150,
+ 440,
+ 250,
+ 460
+ ],
+ "pt": 1.01194,
+ "only": "flat",
+ "_": "c1 quest sheet, the same empty band as the `sheet` probe reads on c2"
+ },
+ {
+ "id": "t-count2-c1",
+ "img": "assets/refs/c1.png",
+ "cmd": "bbox",
+ "box": [
+ 274,
+ 464,
+ 364,
+ 486
+ ],
+ "pt": 1.01194,
+ "dark": 200,
+ "_": "c1 \"0/5 completed\" -- the same run as t-count2, one quest earlier"
+ },
+ {
+ "id": "c3-sky",
+ "img": "assets/refs/c3.png",
+ "cmd": "sample",
+ "box": [
+ 20,
+ 300,
+ 60,
+ 320
+ ],
+ "pt": 1.0149,
+ "only": "flat",
+ "_": "c3 path sky, the same band the `sky-3` probe reads on c4. The scroll moves the road, not the sky"
+ },
+ {
+ "id": "c3-road-l",
+ "img": "assets/refs/c3.png",
+ "cmd": "scan",
+ "pt": 1.0149,
+ "axis": "row",
+ "at": 400,
+ "range": [
+ 68,
+ 96
+ ],
+ "_": "c3 row at y 400: sky, then the road left vertical. c3 is c4 scrolled by -184.3, so this is c4's y 584.3 and the road is in the same x band"
+ },
+ {
+ "id": "gutter-l",
+ "img": "assets/refs/c2.png",
+ "cmd": "scan",
+ "pt": 1.0745,
+ "axis": "row",
+ "at": 200,
+ "range": [
+ 10,
+ 40
+ ],
+ "_": "c2 row through the streak card, left gutter: sky then card at 20.5"
+ },
+ {
+ "id": "gutter-r",
+ "img": "assets/refs/c2.png",
+ "cmd": "scan",
+ "pt": 1.0745,
+ "axis": "row",
+ "at": 200,
+ "range": [
+ 360,
+ 402
+ ],
+ "_": "c2 same row, right edge of the streak card at 382.5 on a 402 pt frame"
+ },
+ {
+ "id": "road-l",
+ "img": "assets/refs/c4.png",
+ "cmd": "scan",
+ "pt": 1.0149,
+ "axis": "row",
+ "at": 320,
+ "range": [
+ 70,
+ 96
+ ],
+ "_": "c4 row between bends at y 320: sky, then the road left vertical at x 79.7. The evidence row says 80-117, and this is the 80"
+ },
+ {
+ "id": "cta-lip",
+ "img": "assets/refs/c5.png",
+ "cmd": "scan",
+ "pt": 1.07985,
+ "axis": "col",
+ "at": 200,
+ "range": [
+ 810,
+ 835
+ ],
+ "_": "c5 column through the button: fill, then the darker bottom lip, then the sheet"
+ },
+ {
+ "id": "sheet-top",
+ "img": "assets/refs/c5.png",
+ "cmd": "scan",
+ "pt": 1.07985,
+ "axis": "col",
+ "at": 30,
+ "range": [
+ 600,
+ 680
+ ],
+ "_": "c5 column left of the play triangle: scrim, then the sheet's top edge"
+ },
+ {
+ "id": "tabbar-top",
+ "img": "assets/refs/c2.png",
+ "cmd": "scan",
+ "pt": 1.0745,
+ "axis": "col",
+ "at": 110,
+ "range": [
+ 780,
+ 830
+ ],
+ "_": "c2 column between the Home and Progress tabs: page, then the tab bar"
+ }
+]
\ No newline at end of file