diff --git a/README.md b/README.md
index f64ef56c..ff6fb9be 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ path and the two modal sheets.*
have: the source composites it out, the iOS frame spec draws it, and this run
keeps the frame and excludes the top 56pt from its numbers.*
-### `notion-ios`, six screens
+### `notion-ios`, eighteen screens
[](mockups/canvases/notion-ios/README.md)
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 9f439e4b..16d17982 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -29,6 +29,13 @@ Everything below is on `main` and reaches no install until a version is cut.
sixteen. Two tokens moved with the re-measurement: the sheet inset is 68
rather than 71, and the type stack names SF Pro's Text cut outright, since
`-apple-system` resolves to the Display cut and renders about 4% narrow.
+- **And three more: the Plus & Notion AI purchase sheet.** Monthly selected,
+ yearly selected, and the StoreKit "You're all set" alert over the dimmed
+ sheet with the subscribe button spinning. The sheet is Apple's paywall
+ rather than one of the app's own, so its metrics sit in a board-local block
+ after the shared tokens. The cat and the sparkle strokes on the feature card
+ are the folder's first `artgen` assets: `gpt-image-2` redraws of the
+ capture's crops, keyed and scored in `art-gen.json`.
## v1.1.1
diff --git a/mockups/canvases/notion-ios/16-plan-plus-ai-monthly.html b/mockups/canvases/notion-ios/16-plan-plus-ai-monthly.html
new file mode 100644
index 00000000..66397a05
--- /dev/null
+++ b/mockups/canvases/notion-ios/16-plan-plus-ai-monthly.html
@@ -0,0 +1,193 @@
+
+
+
+
+Notion iOS — Plan sheet, Plus & AI, monthly
+
+
+
+
+
+
9:41
+
+
+
+
+
+
+
+
+
+
+
+
Plus
Plus & AI
+
Plus & Notion AI
+ 1 member limit
+
+
+
+ Notion AI
+
Including Agent, Enterprise search, and AI meeting notes
+ Unlimited file uploads
+ View data with charts
+ Site customizations
+ and more...
+
+
+
+
+
diff --git a/mockups/canvases/notion-ios/README.md b/mockups/canvases/notion-ios/README.md
index 0fbbd8e4..803c4603 100644
--- a/mockups/canvases/notion-ios/README.md
+++ b/mockups/canvases/notion-ios/README.md
@@ -1,7 +1,7 @@
# Notion iOS, a worked example
A real run of the `clone-prototype` skill, kept as the reference for what a
-finished board looks like. One measured token block plus fifteen replica
+finished board looks like. One measured token block plus eighteen replica
screens, every colour and metric traced to a sample off the source capture.
Open it with `?canvas=notion-ios`, or a single board with
@@ -10,10 +10,11 @@ Open it with `?canvas=notion-ios`, or a single board with
| file | what it is |
|---|---|
| `gen.py` | The source of truth. Every `NN-*.html` here is its output; edit the generator and re-run, never the HTML. |
-| `00-design-tokens.html` | The contract. Swatches, type ramp, radii, metrics, and the evidence for each value. Inlined byte-identically into all fifteen screens. |
+| `00-design-tokens.html` | The contract. Swatches, type ramp, radii, metrics, and the evidence for each value. Inlined byte-identically into all eighteen screens. |
| `01-splash` … `06-share-settings-sheet` | The screens. 393 × 852 pt frames on 478 × 980 artboards, fully self-contained. |
| `07-manage-data-sources` … `10-to-do-list-table` | The *adding a new data source* flow, added later against four more captures. |
| `11-add-an-account` … `15-add-an-account-code-filled` | The *adding an account* flow, five states of one sheet, against five more captures. |
+| `16-plan-plus-ai-monthly` … `18-purchase-success` | The *Plus & Notion AI purchase sheet*, Apple's paywall over the dimmed app, against three more captures. |
| `probes.json` | The measurements behind the tokens, replayable with `refkit batch probes.json --against --pt 3`. |
Two things in `00-design-tokens.html` are worth reading. The capture scale
@@ -125,11 +126,58 @@ alpha runs 0 to 1 over 17 to 52pt from the sheet's top edge, which puts the
faded subtitle remnant within five grey levels of the capture the whole way
down.
+## The purchase sheet, 16 to 18
+
+Three captures of the in-app Plus & AI paywall: the monthly price selected,
+the yearly, and the monthly sheet dimmed under the StoreKit "You're all set"
+alert with the subscribe button spinning. This is Apple's sheet rather than
+one of the app's own, and almost nothing on it is a token:
+
+- **The sheet sits at 58.9, not `--n-sheet-top`'s 68**, with a 38pt corner
+ and a 36 × 4.5 `#C4C4C4` grabber 5.3 below the edge. The app dims to
+ `#C6C6C6` behind it, and to `#9E9E9E` once the alert's scrim is on top.
+- **The status bar is the stock 17pt one.** The shared block sets a 16px
+ clock 12pt lower, so the three boards carry their own bar: clock at 54,
+ cap top 22.8, and the signal, wifi and battery drawn to the capture's
+ 19.7, 18.8 and 27.2 widths. The battery's outline and nub are black at
+ .5 and .55 over the grey, which is where `pw-status-icons` lands.
+- **Four blues, none of them `--n-blue`.** The feature card's checks and
+ `Notion AI` are `#4E7AB0`; the selected price card is a 2pt `#487ED0`
+ border on `#E7F3FF`, its price `#467AB9`; the subscribe button is
+ `#4380D7`; the alert's OK pill is `#367CEF`.
+- The segmented control's white pill has **no shadow**, and its label goes
+ from 400 to 600 when selected; the track is `#EBEBEB`, 31.7 tall, 1.8 inset.
+- The feature card is **taller than its window**. It is 249.8 × 300 with a
+ three-layer shadow, drawn inside a 289.7 tall hero and clipped by a 40pt
+ linear fade to white. `refkit shoot --clip-ok div.hero` is what keeps the
+ overflow check quiet about it.
+- While it spins, the subscribe button is **54.4 tall rather than 50.4**, and
+ the three links under it move 4pt down with it. The ring is 24pt, a 2.3
+ stroke, `#CFDCE8` with a `#2E3A36` arc.
+- The alert is 318.7 × 152.2 at (37.3, 362.3) with a 30pt corner: white at
+ .7 over a 30px blur, an inset 1px white line at .6, and the title, body
+ and OK pill 29.5 in from its edge.
+
+Text is placed by cap top throughout. `cap(size, lh)` in `gen.py` turns a
+measured cap top into a CSS `top` for SF Pro, so a value in the generator is
+the number read off the capture, not a number tuned until it looked right.
+
+**The cat and the sparkles are generated, not cropped.** They are the
+folder's first `artgen` assets: `gpt-image-2` redraws of the capture's crops
+in `assets/art/`, keyed and shipped at 3× the measured box as `assets/cat.png`
+and `assets/spark.png`. `art-gen.json` records the scores against the crop:
+cat 13.97, sparkles 10.9, with a second independent return at 14.56 and 11.12
+that did not beat the first. The one visible cost is the cat's hind leg, which
+ends about 4pt higher than the capture's; `pw-cat` records it. A crop would
+have scored 0, and the redraw was the brief.
+
## How close it lands
Screens 7–15, each against its own capture cropped to `(0, 0, 1179, 2556)` and
rendered with `refkit shoot --scale 3 --crop-phone`, so both sides are the same
-393 × 852 pt screen at 3 px/pt. Mean absolute delta in levels of 255:
+393 × 852 pt screen at 3 px/pt. Screens 16–18 the same way, except that their
+captures are 2.2417 px/pt, so the render is resampled down to the capture
+rather than the capture up. Mean absolute delta in levels of 255:
| # | screen | whole frame | below the status bar |
|---|---|---|---|
@@ -142,23 +190,30 @@ rendered with `refkit shoot --scale 3 --crop-phone`, so both sides are the same
| 13 | Email typed | 5.28 | 1.99 |
| 14 | Verification code | 5.08 | 1.77 |
| 15 | Code typed | 5.07 | 1.77 |
+| 16 | Plan sheet, monthly | 5.02 | 2.61 |
+| 17 | Plan sheet, yearly | 5.23 | 2.83 |
+| 18 | Purchase success | 4.27 | 2.26 |
-**Whole-frame mean 5.37, worst 6.55. Below the status bar, mean 1.94, worst
+**Whole-frame mean 5.17, worst 6.55. Below the status bar, mean 2.10, worst
2.84.** The gap between the two columns is one thing, and it is this repo's
-framing rather than a miss: the top 60pt band scores 51.9 on eight of the nine
-boards and 59.2 on board 10, near enough to a constant, because every board
-here draws a Dynamic Island and none of the captures has one. The left column
+framing rather than a miss: the top 60pt band scores 51.9 on eight of the first
+nine boards and 59.2 on board 10, near enough to a constant, because every
+board here draws a Dynamic Island and none of the captures has one. On 16–18
+the same band reads 37.0, 37.0 and 30.8, lower only because the island sits on
+a dimmed grey rather than white. The left column
is `refkit diff `; the right is the same measure over
`y >= 180px`, which is where the app's own content starts.
What is left below that band is glyph antialiasing and the two deltas named
below. Board 10 is worst of the nine because it carries the most type and the
only clipped table; board 11 is best because it is a sheet of six outlined
-buttons on a flat ground.
+buttons on a flat ground. The purchase sheet's three sit higher below the bar
+than the account flow's because their captures are softer, 2.24 px/pt against
+3, so every glyph edge disagrees by a little more.
-`refkit batch probes.json --against --pt 3` replays all 43 probes
-against the renders: **33 box probes at a mean |dw| of 0.41pt and |dh| of
-0.18**, and 4 colour probes at a mean Δmax of 1.8. Per screen:
+`refkit batch probes.json --against --pt 3` replays all 62 probes
+against the renders: **50 box probes at a mean |dw| of 0.38pt and |dh| of
+0.32**, and 4 colour probes at a mean Δmax of 1.8. Per screen:
| # | screen | box probes | mean \|dw\| | mean \|dh\| | worst |
|---|---|---|---|---|---|
@@ -171,6 +226,9 @@ against the renders: **33 box probes at a mean |dw| of 0.41pt and |dh| of
| 13 | Email typed | 1 | 0.00 | 0.00 | 0.0 |
| 14 | Verification code | 4 | 0.07 | 0.00 | 0.3 |
| 15 | Code typed | 1 | 0.00 | 0.00 | 0.0 |
+| 16 | Plan sheet, monthly | 13 | 0.35 | 0.65 | 3.6 |
+| 17 | Plan sheet, yearly | 16's probes, the other card selected | | | |
+| 18 | Purchase success | 4 | 0.22 | 0.42 | 1.0 |
Screen 7 carries the spread on its own, and all of it is `nav-title`: 4.7pt,
the standing `--n-t-nav` delta described above. Drop that one probe and 7 reads
@@ -182,6 +240,13 @@ scan col 60 55 90 --pt 3` reads the same #D4D4D4 band on every one of
them, which is also why `--n-sheet-top` moved to 68 with five captures behind
it.
+Screen 16 carries two of its own. `pw-cat` is 3.6pt short in height, the
+generated cat's hind leg, described above. `pw-price-selected` reads the
+selected card 1.7 taller on the capture than on the render, and about 1.3 of
+that is the resample: the capture's 2pt border, blown up from 2.24 to 3 px/pt,
+spreads a third of a point past each edge, and the probe's threshold of 235 is
+loose enough to count it. Everything else on the sheet is within half a point.
+
Two colour deltas are worth naming. `acc-cta`, the Continue button, reads
`#2280DE` on capture a2 against the board's `--n-blue` of `#2784E0`, a Δmax of
5 and the worst colour delta in the folder — the token was measured off the
@@ -218,7 +283,10 @@ locally, then add the row back:
{ "file": "ref-12-add-an-account-email", "label": "Work email" },
{ "file": "ref-13-add-an-account-email-filled", "label": "Email typed" },
{ "file": "ref-14-add-an-account-code", "label": "Verification code" },
- { "file": "ref-15-add-an-account-code-filled", "label": "Code typed" }
+ { "file": "ref-15-add-an-account-code-filled", "label": "Code typed" },
+ { "file": "ref-16-plan-plus-ai-monthly", "label": "Plan sheet, monthly" },
+ { "file": "ref-17-plan-plus-ai-yearly", "label": "Plan sheet, yearly" },
+ { "file": "ref-18-purchase-success", "label": "Purchase success" }
] }
```
@@ -261,6 +329,20 @@ have file names instead of ids. All nine are exact matches:
Those exports are 1179 × 2676 and carry a 120px Mobbin banner at the **bottom**.
Crop to `(0, 0, 1179, 2556)` before measuring anything off them.
+Screens 16–18 came in as three Mobbin captures supplied with the request, so
+they have neither an id nor an export file name. They are 881 × 2000 with a
+90px banner at the bottom: crop to `(0, 0, 881, 1910)`, which is 393 × 852 pt
+at **2.2417 px/pt**, and pass that as `--pt` to every `refkit` call. For
+`probes.json` they are resampled to 1179 × 2556 (`assets/refs/s1.png` to
+`s3.png`) so the whole file replays at `--pt 3`; the resample blurs an edge by
+about a third of a point, which is most of `pw-price-selected`'s height delta.
+
+| # | screen | source |
+|---|---|---|
+| 16 | Plan sheet, monthly | supplied capture, `Plus & AI` tab, monthly selected |
+| 17 | Plan sheet, yearly | supplied capture, yearly selected |
+| 18 | Purchase success | supplied capture, the alert over 16 |
+
None of the captures shows a Dynamic Island; Mobbin shoots on a device without
one. Every board in this repo draws one anyway, so the frame keeps it. That is
this repo's framing, not a property of the app.
diff --git a/mockups/canvases/notion-ios/art-gen.json b/mockups/canvases/notion-ios/art-gen.json
new file mode 100644
index 00000000..a3c2a7a9
--- /dev/null
+++ b/mockups/canvases/notion-ios/art-gen.json
@@ -0,0 +1,30 @@
+{
+ "_": "The two crops in assets/art/ redrawn by gpt-image-2 from a two-cell geometry-anchored grid on a white ground, keyed, fitted back to the same pt box and scored against the crop they came from. `runs` is that asset's mean delta in each of two independent returns, in `_runs` order; `delta` is the one that shipped, at 3x the measured box. Both are over artgen's 8.0 default: thin black line art on white scores worse than the filled characters duolingo-ios was tuned on, and the second return did not beat the first on either asset. Rebuild with tools/artgen.py.",
+ "_runs": [
+ "white/high",
+ "white/high again"
+ ],
+ "_sup": 3,
+ "cat": {
+ "delta": 13.97,
+ "from": "white/high",
+ "scale": 0.91,
+ "dx": -1,
+ "dy": 8,
+ "runs": [
+ 13.97,
+ 14.56
+ ]
+ },
+ "spark": {
+ "delta": 10.9,
+ "from": "white/high",
+ "scale": 0.9,
+ "dx": 3,
+ "dy": 2,
+ "runs": [
+ 10.9,
+ 11.12
+ ]
+ }
+}
diff --git a/mockups/canvases/notion-ios/assets/art/cat.png b/mockups/canvases/notion-ios/assets/art/cat.png
new file mode 100644
index 00000000..edf3671b
Binary files /dev/null and b/mockups/canvases/notion-ios/assets/art/cat.png differ
diff --git a/mockups/canvases/notion-ios/assets/art/spark.png b/mockups/canvases/notion-ios/assets/art/spark.png
new file mode 100644
index 00000000..a16687a6
Binary files /dev/null and b/mockups/canvases/notion-ios/assets/art/spark.png differ
diff --git a/mockups/canvases/notion-ios/assets/cat.png b/mockups/canvases/notion-ios/assets/cat.png
new file mode 100644
index 00000000..435b873c
Binary files /dev/null and b/mockups/canvases/notion-ios/assets/cat.png differ
diff --git a/mockups/canvases/notion-ios/assets/spark.png b/mockups/canvases/notion-ios/assets/spark.png
new file mode 100644
index 00000000..b6ae8d4c
Binary files /dev/null and b/mockups/canvases/notion-ios/assets/spark.png differ
diff --git a/mockups/canvases/notion-ios/gen.py b/mockups/canvases/notion-ios/gen.py
index 90b9c5a4..452c8614 100644
--- a/mockups/canvases/notion-ios/gen.py
+++ b/mockups/canvases/notion-ios/gen.py
@@ -1,9 +1,10 @@
-"""Emit mockups/canvases/notion-ios/ from measurements of fifteen Mobbin captures.
+"""Emit mockups/canvases/notion-ios/ from measurements of eighteen Mobbin captures.
-Fifteen screens of Notion iOS: the splash, search and the AI chat, a meeting
+Eighteen screens of Notion iOS: the splash, search and the AI chat, a meeting
page, the date and share sheets, the four-screen flow that adds a data source
-to a database, and the five-screen flow that adds an account. The measurements
-behind the tokens are in probes.json.
+to a database, the five-screen flow that adds an account, and the Plus & AI
+purchase sheet in three states. The measurements behind the tokens are in
+probes.json.
python3 mockups/canvases/notion-ios/gen.py
@@ -1011,6 +1012,185 @@ def account(form, up=""):
+ '
Resend verification code
\n', " up")
+# ------------------------------------------------- 16-18 the purchase sheet ---
+# Three Mobbin captures of the Plus & AI paywall at 881 x 1910, so 2.2417 px
+# per pt; every number below is a pt read off them with refkit (README: "The
+# purchase sheet, 16 to 18"). Text is placed by its cap top: top = cap_top -
+# cap(size, line_height), with SF's ascent 0.952em and cap height 0.705em.
+# The cat and the sparkles are gpt-image-2 redraws of the capture's crops
+# (assets/art/), scored in art-gen.json.
+import base64
+
+
+def cap(size, lh):
+ return (lh - 1.165 * size) / 2 + 0.247 * size - 0.5
+
+
+def png(name):
+ b = (OUT / "assets" / (name + ".png")).read_bytes()
+ return "data:image/png;base64," + base64.b64encode(b).decode()
+
+
+SHEET = 58.9
+CARD_TOP = 285.5
+
+CSS_PW = """
+
+/* Board-local values, all read off the paywall captures (README: "The purchase
+ sheet, 16 to 18"). The paywall is a StoreKit-style page sheet: it sits at 59pt with a
+ 38pt corner over a dimmed #C6C6C6 app, and its blues are not --n-blue. */
+.phone{background:#C6C6C6}
+/* The capture's status bar is the stock iOS one, 17pt semibold with its cap
+ top at 23.2pt; the shared block sits it lower, so place it by hand here. */
+.statusbar{display:block;padding:0;color:#000}
+.statusbar .time{position:absolute;left:54px;top:%(time_top)spx;width:auto;font:600 17px/22px var(--n-font);letter-spacing:0}
+.sicons{position:absolute;left:282.3px;top:22.8px;width:78.5px;height:12.9px;display:block}
+.sicons svg{position:absolute}
+.sheet{position:absolute;left:0;right:0;top:%(sheet)spx;bottom:0;background:#fff;
+ border-radius:38px 38px 0 0;overflow:hidden}
+.sheet>*{position:absolute}
+.handle{left:178.5px;top:5.3px;width:36px;height:4.5px;border-radius:2.5px;background:#C4C4C4}
+.close{left:351.3px;top:%(close_top)spx;width:12px;height:12px;color:#7C7C7C}
+.close svg{display:block;width:12px;height:12px}
+.seg{left:20px;top:%(seg_top)spx;width:353px;height:31.7px;box-sizing:border-box;padding:1.8px;
+ display:flex;border-radius:var(--n-r-pill);background:#EBEBEB}
+.seg div{flex:1;display:grid;place-items:center;position:relative;padding-bottom:.4px;border-radius:14.05px;
+ font:400 13px/16px var(--n-font);color:#000}
+/* The labels sit 0.9pt off their cells' centres, away from the divide. */
+.seg div:first-child{left:-.85px}.seg div:last-child{left:.9px}
+/* The capture shows no shadow under the selected pill: the grey is the same
+ #EBEBEB right up to its edge on every side. */
+.seg .on{background:#fff;font-weight:600;letter-spacing:-.1px}
+.ttl{left:0;right:0;top:%(ttl_top)spx;text-align:center;font:700 27px/34px var(--n-font);
+ letter-spacing:.1px;color:var(--n-text)}
+.limit{left:50%%;transform:translateX(-50%%);top:%(limit_top)spx;height:24px;padding:0 8.7px;
+ border-radius:var(--n-r-pill);background:#E8E8E8;font:500 13px/22px var(--n-font);
+ color:#282828;white-space:nowrap}
+/* The feature card is taller than the window that shows it: the window fades
+ to white over its last 40pt (a black stroke in the capture ramps linearly
+ from 500 to 540), taking the card, its shadow and the cat's legs with it. */
+.hero{left:0;right:0;top:%(hero_top)spx;height:%(hero_h)spx;overflow:hidden}
+.fcard{position:absolute;left:71.8px;top:%(card_top)spx;width:249.8px;height:300px;border-radius:22px;
+ background:#fff;box-shadow:0 0 4px rgba(0,0,0,.14),0 6px 24px rgba(0,0,0,.08),0 16px 80px rgba(0,0,0,.06)}
+.fcard>*{position:absolute;left:44.6px;margin:0;white-space:nowrap}
+.fcard svg{left:21.6px;width:12.8px;height:12.8px;color:#4E7AB0}
+.fcard .ai{left:43.7px;top:%(ai_top)spx;font:400 15px/20px var(--n-font);color:#4E7AB0;letter-spacing:-.1px}
+.fcard .sub{left:43.3px;top:%(sub_top)spx;width:190px;white-space:normal;font:400 13px/15.6px var(--n-font);
+ color:var(--n-text-2)}
+.fcard .row{font:400 15px/20px var(--n-font);color:var(--n-text)}
+.fcard .more{top:%(more_top)spx;font:400 15px/20px var(--n-font);color:var(--n-text-3)}
+.art{position:absolute;display:block}
+.spark{left:%(spark_l)spx;top:%(spark_t)spx;width:%(spark_w)spx;height:%(spark_h)spx}
+.cat{left:%(cat_l)spx;top:%(cat_t)spx;width:%(cat_w)spx;height:%(cat_h)spx}
+.fade{position:absolute;left:0;right:0;bottom:0;height:40px;
+ background:linear-gradient(rgba(255,255,255,0),#fff)}
+.price{top:%(price_top)spx;width:178px;height:75px;box-sizing:border-box;border:1px solid #E4E4E4;
+ border-radius:var(--n-r-field);background:#fff}
+.price.l{left:15.5px}.price.r{left:199.5px}
+.price b,.price i{position:absolute;left:15.6px;white-space:nowrap;font-style:normal}
+.price b{top:%(pb_top)spx;font:700 21.5px/26px var(--n-font);letter-spacing:0;color:var(--n-text)}
+.price i{top:%(pi_top)spx;font:400 13px/16px var(--n-font);color:var(--n-text-2)}
+.price.on{border:2px solid #487ED0;background:#E7F3FF}
+.price.on b,.price.on i{left:15.5px;margin-top:-1.4px}
+.price.on i{margin-top:-1px}
+.price.on b{color:#467AB9}.price.on i{color:#4E7BB8}
+.cta{left:16px;top:%(cta_top)spx;width:361.5px;height:50.4px;border-radius:8px;background:#4380D7;
+ color:#fff;text-align:center;font:600 15px/50px var(--n-font);letter-spacing:-.03px}
+.cta svg{position:absolute;left:166px;top:12.5px;width:30px;height:30px;display:block}
+.links{left:0;right:0;top:%(links_top)spx;text-align:center}
+.links a{display:block;font:400 13px/23.7px var(--n-font);color:#9A9A98;
+ text-decoration:underline;text-underline-offset:1.2px;text-decoration-thickness:1px}
+""" % dict(
+ sheet=SHEET, time_top=round(22.5 - cap(17, 22), 2),
+ close_top=round(92.8 - SHEET, 1),
+ seg_top=round(122.1 - SHEET, 1),
+ ttl_top=round(191.9 - SHEET - cap(27, 34), 1),
+ limit_top=round(227.0 - SHEET, 1),
+ hero_top=round(250.3 - SHEET, 1),
+ hero_h=round(540 - 250.3, 1),
+ card_top=round(CARD_TOP - 250.3, 1),
+ ai_top=round(309.6 - CARD_TOP - cap(15, 20), 2),
+ sub_top=round(327.0 - CARD_TOP - cap(13, 15.6), 2),
+ more_top=round(492.9 - CARD_TOP - cap(15, 20), 2),
+ # crop boxes of assets/art/*.png in capture px, over 2.2417 px/pt
+ spark_l=52.65, spark_t=round(266.8 - 250.3, 2), spark_w=30.4, spark_h=36.5,
+ cat_l=round(628 / 2.2417 - 0.1, 2), cat_t=round(976 / 2.2417 - 250.3 + 1.7, 2),
+ cat_w=round(198 / 2.2417 + 0.6, 2), cat_h=round(250 / 2.2417 + 2.5, 2),
+ price_top=round(567.5 - SHEET, 1),
+ pb_top=round(587.9 - 567.5 - 1 - cap(21.5, 26) + 1.0, 2),
+ pi_top=round(613.4 - 567.5 - 1 - cap(13, 16) - 0.5, 2),
+ cta_top=round(665.5 - SHEET, 1),
+ links_top=round(754.3 - SHEET - cap(13, 23.7), 2),
+)
+
+CSS_PW_OK = """
+/* Success: the button grows to 54.4pt while it spins and the links move down
+ with it; then a 20%% black scrim over the whole screen and a frosted alert. */
+.cta{height:54.4px}.links{top:%(links_top)spx}
+.scrim{position:absolute;inset:0;background:rgba(0,0,0,.2);border-radius:52px}
+.alert{position:absolute;left:37.3px;top:362.3px;width:318.7px;height:152.2px;border-radius:30px;
+ background:rgba(255,255,255,.7);-webkit-backdrop-filter:blur(30px);backdrop-filter:blur(30px);
+ box-shadow:inset 0 0 0 1px rgba(255,255,255,.6),0 10px 30px rgba(0,0,0,.12)}
+.alert h3,.alert p{position:absolute;left:29.5px;margin:0;white-space:nowrap}
+.alert h3{top:%(h_top)spx;font:600 17px/22px var(--n-font);color:#000;letter-spacing:0}
+.alert p{top:%(p_top)spx;font:400 15px/20px var(--n-font);color:#505050;letter-spacing:0}
+.ok{position:absolute;left:16.2px;top:88.3px;width:286.9px;height:48.5px;border-radius:24.25px;
+ background:#367CEF;color:#fff;text-align:center;font:600 17px/48.5px var(--n-font)}
+""" % dict(h_top=round(388.1 - 362.3 - cap(17, 22), 2), p_top=round(415.4 - 362.3 - cap(15, 20), 2),
+ links_top=round(758.4 - SHEET - cap(13, 23.7), 2))
+
+STATUS = """
\n'
+ body += ' %sNotion AI\n' % (CHECK % round(310.0 - CARD_TOP - 0.6, 2))
+ body += '
Including Agent, Enterprise search, and AI meeting notes
\n'
+ body += row(379.2, "Unlimited file uploads")
+ body += row(417.1, "View data with charts")
+ body += row(454.8, "Site customizations")
+ body += ' and more...\n'
+ body += '
\n'
+ body += ' \n' % png("spark")
+ body += ' \n' % png("cat")
+ body += ' \n