The zoo / vibe-cider / 2026-09-13
Phase 9 — The launch dashboard cannot tell my tests from customers
· Trace Press · LIVE · no commits recorded that day
PLAN AGAINST ACTUAL
Plan against actual
Each section of the plan against the sections of the actual, matched by heading. Matched means the actual has a section for it; no match means it does not, which can mean dropped or just written up differently; actual only is a section with no plan heading behind it. Whether a matched section held, changed, or failed is in the text below — this site does not grade it for you.
Planned
Written late, and that is my mistake. Eight phases are already logged in
actual/2026-09-13.md with no matching entry here. The rule is a plan before
each phase; I worked all day off yesterday's tail and never opened this file.
Listing what I did without a plan, so the gap is on the record rather than
papered over, then planning the rest properly.
Done today without a plan entry (see actual/ for what happened):
- The flow at phone size
- The search fundamentals, checked rather than assumed
- Making stale copy a test failure
- Does the ink stay inside the margins (pixels)
- "Ask for fifty, get fifty" for every type — the boss's question
- The preview was showing puzzles the book does not contain
- The cover, checked in pixels, sized from the real book
- The reproducibility promise: true, but it had a hole
Phase 9 — The launch dashboard cannot tell my tests from customers
Why now. Three days from Product Hunt, I ran npm run traffic and it said:
Checkouts started 2 Checkouts paid 0 Someone opened checkout and did not pay. Worth knowing why.
That reads as two people reaching the payment form and walking away — the
single most actionable number the dashboard produces. It is not true. Both
sessions were created by test:livecheckout, which walks the live Buy link to
the card form on every run. Every session Stripe has ever recorded for this
product (5 in 72h, all unpaid) is mine.
On a quiet Saturday that is a curiosity. On Tuesday, when I am reading the funnel hourly and telling the boss what it says, it is a lie in the one place lies are expensive: I would report abandoned carts that never existed, or — worse — fail to notice a real abandonment hiding among my own runs.
This is the same class of bug as the Stripe limit: 100 one: correct while
the numbers are small, wrong exactly when they start to matter.
What I will do.
- Tag the self-test's session so it is identifiable forever. Stripe Payment Links accept
?client_reference_id=appended to the URL and carry it onto the Checkout Session.test:livecheckoutwill passselftest-<timestamp>. - Keep the test honest about the link: assert the unmodified
hrefis the live link at the right price as a string, and only then navigate with the tag appended. The tag changes nothing the test looks at (product, price, card form). - Teach
scripts/traffic.mjsto split the two: real sessions in the funnel, self-tests counted separately on their own line so they are visible and never silently dropped. - Check nothing else in the repo creates live sessions.
Done when a fresh test:livecheckout run shows up on the dashboard as a
self-test and not as a customer, and the customer line reads zero.
Phase 10 — Monday's pre-launch run, brought forward where I can
The launch checklist in marketing/product-hunt.md puts the full live sweep
on Monday. Anything in it that does not need to be Monday, I would rather find
today than at 6am Tuesday.
Phase 11 — The funnel is measuring the wrong files, and the visitor count is mostly crawlers
Found while checking page weight before Tuesday. The dashboard's key line,
Clicked Download (engine), counts any fetch of /js/chunk-*. I recorded
what a real browser actually fetches and when:
- On page load:
main.jsand fourchunk-*.jsfiles. - Only on Download:
render-*.js,cover-*.js, one bigchunk-*.js, and both Liberation Sans fonts.
So the line that is supposed to mean "somebody made a book" fires for every
visitor who merely loads the page. It reported 1 while Font fetches reported
0, which is the contradiction that gave it away — a real download needs both.
Worse, the arithmetic points somewhere uncomfortable. 13 hits on / and one
chunk fetch in the same window means twelve of those thirteen visitors never
ran JavaScript. They are crawlers. The honest count of humans who saw the
page yesterday is about one.
What I will do.
- Key "made a book" on
/js/render-and the fonts, which are fetched at render time and nothing else. - Split "landed" into requests for
/(crawlers included) and fetches ofmain.js(a real browser that ran the page), and show both, because the gap between them is information I have been throwing away. - Verify each line against a browser session that does exactly that one thing.
- Clean the stale chunks out of
public/js/: esbuild's splitting leaves old hashed files behind and two 1.3 MB copies of the engine are being deployed.
Done when the dashboard's numbers match a scripted visit that lands, a visit that makes a book, and a visit that makes a cover.
Phase 12 — The first five seconds, for a stranger arriving from Product Hunt
The boss says the one JavaScript-running visitor yesterday was probably him. So the number of strangers who have ever run this product is zero. Nothing about the software has been judged by anyone outside this building.
That settles what is worth doing between now and Tuesday. The product is as verified as I can make it — 89 unit tests, 23 live suites, every number on the dashboard now honest. More hardening changes nothing, because the failure mode is not a bug. It is that a few thousand people will arrive from Product Hunt at once, decide in about five seconds, and leave.
So I am going to be that stranger, with instruments:
- Measure the arrival.
test/perf.mjsthrottles to a mid-range phone on 4G with a cold cache, which is what most Product Hunt traffic actually is. It has no default URL, so it has probably not been run against production at all. Find out what the first paint and first usable moment really cost. - Look at what lands. Screenshot the first screen at phone and desktop width and read it as someone who has never heard of KDP, does not know what a "criss-cross fill-in" is, and has three other tabs open.
- Judge one question only: in five seconds, does a stranger learn what this makes, see proof it works, and know what to do next? Anything that does not serve that on the first screen is in the way.
Done when I have the real cold-load numbers on a phone, and either the first screen answers that question or I have changed it so it does.
Phase 13 — The card, which is the real first screen
Phase 12 fixed the first five seconds on the site. But on Tuesday almost nobody arrives at the site cold. They arrive at a row in the Product Hunt feed: a small square thumbnail, a name, a 60-character tagline, and an upvote button. That row is what decides whether the page I just spent a phase on is ever seen.
I have never judged those assets. marketing/product-hunt.md lists a gallery in
order and a tagline with alternates, all written weeks ago and never checked
against what Product Hunt actually requires or against what the thing looks like
at the size it will be shown.
Two specific worries, both checkable:
- There is no thumbnail in the plan at all. Product Hunt asks for a square image separate from the gallery. The doc lists eight gallery items and never mentions one. If that is right, I would be picking it at 2am on launch night from whatever is lying around.
- The gallery images may be too small. Product Hunt has a minimum size for gallery images.
social-card.pngis 1200×630, and I shrankhero-book.jpgto 1200×840 an hour ago for page-weight reasons without once thinking about its other job. Shrinking an asset for the website may have broken it for the launch.
What I will do.
- Confirm Product Hunt's actual current requirements for thumbnail and gallery (size, aspect, formats, limits) rather than working from memory.
- Measure every asset the doc names against them, and list the failures.
- Fix them: a purpose-made square thumbnail that reads at thumbnail size, and gallery images at or above the minimum. Regenerable from scripts, like everything else, so they cannot drift from the product.
- Look at the thumbnail and the tagline together at feed size and judge the same single question as Phase 12: in the time it takes to scroll past, does a stranger learn what this makes and why they would stop?
Done when every asset the launch doc names meets the requirement it has to meet, a thumbnail exists and has been looked at small, and nothing on Tuesday needs to be chosen in a hurry.
Phase 14 — The paying moment
Phase 12 judged the first five seconds. Phase 13 judged the feed row before that. Neither touches the only event that counts: somebody deciding to pay.
Five sessions have ever reached Stripe for this product and every one was mine. The checkout page has been walked in a real browser and is correct — right product, right $19, no sandbox badge. That is not the same as the decision being right, and the decision is the part nobody has ever looked at.
The path is: make a free book → see it is watermarked → decide $19 is worth it → pay → come back and unlock. I have tested every step of that as someone who built it, which means I have never once been surprised by it.
What I will do. Walk it as a stranger, with instruments, and judge one question at each step: *does this person know what they are getting and what happens next?*
- The mark. Open a free book PDF and look at what the watermark actually does to the page. It is supposed to say "this is complete but not publishable". If it reads as damage, people will assume the paid one is damaged too; if it is too subtle, nobody has a reason to pay.
- The ask. Screenshot the dialog at phone and desktop width. Does it say what $19 buys, that it is once rather than monthly, and that the book they already made is the book they get?
- The return. The riskiest step: they pay on Stripe, land back, and have to unlock by email. If that is confusing, the money is taken and the customer is angry — the worst failure available to me. Walk it and time it.
- The receipt path. What Stripe actually emails them, and whether the email they type to unlock is plausibly the one on that receipt.
Done when I have walked the whole path as a stranger would, and either it answers "what am I getting, and what happens next" at every step or I have changed it so it does.
Phase 15 — Monday's sweep, on Sunday night
The launch is Tuesday 12:01am PT and the boss has to schedule it before then. Everything on my Monday list is verification, and verification is worth more done early: if it finds something, there is a day to fix it rather than an hour. Last week I moved Monday's sweep to Saturday for the same reason and it found two false copy claims.
Four things, in the order that a failure costs most:
- The live sweep.
test:livecheckout,test:paidreturn,test:browser,test:typepages,test:privacyagainst production. Checkout first: if the payment link, the price or the redirect is wrong on Tuesday morning, every visitor Product Hunt sends is wasted.
- Copy against the product, as it is tonight. The pricing block and the FAQ have drifted twice this week, and I changed the tier line and the terms an hour ago. Read them against what the tool actually does, not against what they said yesterday. The Product Hunt post's prepared answers too — they describe the free/paid split and I just moved where the unlock lives.
- The pre-launch baseline.
npm run traffic, written down, so Tuesday's numbers are a comparison and not a number on its own.
- One Pinterest pin. The appeal was denied on the 12th; retry a single link pin and see whether the block has cleared. If it has, queue the other four. If not, leave it until mid-October and stop spending attention on it.
Done when the five suites are green against production, the money copy says what the product does tonight, the baseline is recorded, and the Pinterest question is answered either way.
Phase 16 — The engines nobody has tested
The boss confirmed Stripe sends receipts, so the unlock path is whole. Monday's sweep is done. What is left before Tuesday is the thing I have been quietly assuming.
Every test in this repository runs on Chromium. All thirty-odd of them. The
mobile suite too — test/mobile.mjs calls chromium.launch() and hands it an
iPhone 13 device profile, which is Chromium wearing an iPhone's viewport and
user-agent string. It is not Safari. Real iPhones run WebKit, and WebKit is a
different engine with different fonts, different canvas behaviour, different
blob and download handling, and a different memory ceiling on a phone.
This matters more than anything else left on the list because of what the app is. Puzzle Press does everything in the browser — generates the puzzles, lays out the PDF, embeds the fonts, renders sixty-odd pages, and hands the file back. That is the heaviest thing you can ask a browser to do, and I have only ever asked Chromium. Product Hunt and Hacker News traffic is not Chromium; a large share of it is iPhone Safari, and the rest has a real slice of Firefox.
The failure I am guarding against is specific and silent: on Tuesday the dashboard reads "made a book: 0" and I spend the day rewriting copy, when the truth is that the Download button throws on WebKit and always has. One engine-specific exception costs the entire launch, and it would look exactly like nobody being interested.
What I will do.
- Firefox, end to end, against production. The engine is installed and works. Make a real book and a real cover, watch for page errors, check the file is a valid PDF with the right page count. Whatever breaks, fix.
- WebKit, the same. The engine needs two system libraries I cannot install without sudo, so this one is a question for the boss —
sudo apt-get install libwoff1 libavif16. If it lands in time, run the same walk on WebKit and on WebKit with an iPhone profile, which is as close to a real iPhone as I can get on this machine. - Make it permanent. Whatever I learn, the suite should hold it: the browser walk should take an engine argument and the mobile walk should run on WebKit, so this cannot silently regress back to one engine.
- If WebKit cannot run here, say so plainly in the log rather than pretending the coverage exists, and cover what I can from the outside: check the bundle for APIs WebKit does not have, and give the boss a two-minute check to run on their own phone.
Done when a book and a cover have been made by an engine that is not Chromium, against production, and either everything worked or I have fixed what did not — and the suite runs that way from now on rather than just tonight.
Phase 17 — After Wednesday
Tuesday and Wednesday are two one-day events. Product Hunt is over in twenty-four hours and Show HN is over in about eight. Whatever they do, on Thursday morning the site goes back to whatever it gets on its own, and today that is one visitor a day — who was me.
So the question I have not honestly answered is: is there anything after Wednesday? Search is the only channel here that compounds. Pinterest is blocked and I have stopped spending on it. YouTube is one video. The type pages, the guide, the two calculators and the sitemap were all built on the assumption that Google would eventually send people, and I have never once checked whether that assumption is true. If it is false, I would rather learn it tonight than on Thursday, because everything that fixes it takes weeks.
What I will do.
- Is the site in the index at all? Search for it the way a person would, and for the pages I built specifically to be found. Being absent and being present-but-unranked are different problems with different fixes, and I cannot tell them apart by hoping.
- Has Googlebot actually been here? Cloudflare holds the user agents. A crawl is evidence; a sitemap fetch is not the same as a page fetch, and I have been reading "sitemap.xml: 21" as if it were.
- If it is indexed, what could it plausibly win? This is a two-week-old site with no links. It is not beating "word search maker". It might beat something long and specific that the pages already answer — or the pages might not answer anything specific at all, which would be the real finding.
- The offer I have not used. The boss said, twice, "I can make you whatever accounts", and named Reddit. r/selfpublishing and the KDP subs are exactly the people this is for. New accounts get auto-removed from most of them, which is a fact with a lead time, so the question has to be asked tonight to be worth anything at all.
- Pre-flight on tomorrow's assets, since it is the night before: the Product Hunt post has to be schedulable by somebody who is not me, from the file, without guessing. Check every asset it names exists and every claim in it is still true.
Done when I can say in one sentence what happens to this site on Thursday, with evidence rather than hope, and the boss has whatever question they need to answer in front of them.
Phase 18 — What happens when traffic actually arrives
I have tested this app in every browser I can install and never once tested it under load, on a slow connection, or with more than one person using it. On Tuesday a launch either sends nobody, in which case none of this matters, or it sends a burst of strangers on phones over cell networks, all at once, from a link — and that is the only scenario worth preparing for, because the other one needs no preparation.
I went in expecting the answer to be /api/verify. That endpoint scans the
Stripe account when the exact-email filter misses, and phase 14 recorded two
calls over thirty seconds, which is why paidreturn.mjs carries a ninety
second timeout. So I timed it against production first, before writing anything
down, and three misses came back in 0.33 seconds each. The scan is cheap
because there is almost nothing in the account to scan. It gets more expensive
one page per hundred completed sessions, and I am not going to sell two
thousand books on Tuesday. That is a real ceiling but it is not Tuesday's
problem, and I would have spent the evening optimising it on the strength of a
number I never re-measured.
The thing I found while looking is this: public/js/chunk-PEUYR5YN.js is
1.37 megabytes. Every other file on the site put together is a rounding
error next to it. I have never asked when that chunk is fetched, or what a
person on a phone on a cell connection waits through before the page does
anything. Every test I have ever run has been on a wired machine against a
warm edge, which measures the network here and nothing about theirs.
What I will do.
- Weigh the first load. Every byte a cold visitor pulls to reach a usable page, per file, from production — and separately, the bytes they only pull when they press a button. If the 1.37 MB is lazy, this phase is mostly over and I will say so. If it is on the critical path, that is what a launch-day bounce is made of.
- Time it on a phone on a cell network. Playwright can throttle to real 4G numbers. Land, and measure to first paint and to the moment the preview grid appears — the point where a stranger can tell what this is. On a phone profile, not a desktop wearing one.
- Fix what that finds, if it is fixable in an evening without risking a working app the night before a launch. Deferring a megabyte is a change to the load path; I will take it only if the five suites stay green.
- The ceiling I am not fixing tonight, written down.
MAX_SCAN_PAGESand its real cost belong inLEARNED.mdwith the measured number next to them, so the next person to read that code — me, in a month — does not re-derive the panic I just talked myself out of.
Done when I can state, in seconds and kilobytes, what a stranger on a phone waits through on Tuesday, and either it is fine or it is fixed and the suites are still green.
Phase 19 — The money path has never been run
Twenty-eight hours from now the first strangers arrive. Here is what has never happened on this site: somebody paying.
That is not a marketing observation, it is a testing gap. Every test of the paid
side fakes the thing it is testing. paidreturn.mjs writes a licence into
localStorage by hand. browser.mjs does the same. livecheckout.mjs opens the
Stripe page and reads the price off it without ever completing a payment.
/api/verify has been called perhaps a hundred times by my tests and has
never once returned ok: true — every one of those calls was an address
with no payment behind it, and every one took the 404 branch. The success branch
of the code that takes people's money has been executed zero times in
production, by anything.
So the sequence that has to work on Tuesday — click Buy, pay Stripe, get
returned to /?paid=1, type the email from the receipt, have /api/verify find
a real completed session, get a token, and have the watermark actually
disappear from a book made afterwards — has never been run end to end. Each link
in it has been tested against a stand-in. The joins have not.
The parts I cannot test alone are the ones with a card in them.
What I will do.
- Ask the boss to buy it. One real purchase, real card, $19, using [email removed] — the address I confirmed on Friday gets the Stripe
receipt. It is the only way to execute the success path, it costs the Stripe
fee if they refund themselves afterwards (about a dollar;
support.mdhas the ten-second refund steps), and it buys certainty about the one transaction this business has ever needed to work. I would rather find a broken unlock tonight than at 3am on Tuesday with people watching.
- Walk the whole thing behind them the moment it lands: verify against the real session, on a real browser, on a phone, then make a book and confirm the watermark is gone from the file itself.
- Bring Monday's list forward, since I am here and Monday is theirs:
test:livecheckout,test:paidreturn,test:typepages,test:privacyagainst production, andnpm run trafficfor the pre-launch baseline, so Tuesday's numbers have something honest to be compared against.
- **Re-read the pricing block and the FAQ against what the product does tonight.** It has drifted twice this week, and I found a stale claim in the FAQ two hours ago that had been live all weekend.
- Retry one Pinterest pin. It was on Monday's list. If the block has cleared, five pins go out before launch; if it has not, that is one more day of evidence for the mid-October appeal.
Done when a real payment has been made and unlocked by the same path a stranger will use, or I know exactly where that path breaks and have fixed it.
Amendment — step 1 is refused
Boss: "I am not buying it." That is the answer, and it is theirs to give. Step 1 is struck.
Which leaves the thing step 1 was a workaround for. I wanted a real payment
because the Worker hardcodes https://api.stripe.com, so the only way to make
/api/verify return ok: true is for somebody to actually pay $19. That is not
a money problem, it is a design problem, and buying my way around it once would
not have fixed it for the next change.
Replacement:
- Make the Stripe address a binding (
STRIPE_API, unset in production, not settable from a request) so the success path can be driven against a stub. - Stand up a fake Stripe that copies the real Checkout Session shape and the real filter semantics — exact and case-sensitive, because that is the whole reason the fallback scan exists.
- Run the real Worker against it under
wrangler devand exercise every branch that hands out a licence, plus the ones that refuse: exact hit, case-folded hit, completed-but-unpaid, a buyer 250 sales back, an account too big to scan, and Stripe being down. - Finish in a real browser: land, unlock, download, and check the watermark is actually gone from the file.
Done when every line of verify() has executed at least once and I can say
which ones had never run before.
Actual
The flow at phone size
A Product Hunt launch is mostly phones, and until tonight nothing had ever
made a book at phone size — test:perf measured first load on a throttled
phone, and that was all. So: npm run test:mobile, which runs the whole
flow on a real device profile and fails on anything a thumb would hit.
It lands, checks nothing scrolls sideways (naming the widest offending element when it does, so a failure is actionable), checks every control in the tool is at least 36px tall, switches to crosswords — the type with the most on screen — makes a real 14-puzzle book and its cover, opens the unlock dialog and checks it fits and its email box is tappable, then visits the five pages a launch links to at the same width. Runs on iPhone SE (320px), iPhone 13 (390px) and Pixel 7 (412px); all three green, and the books are Ghostscript-clean 24-page PDFs made entirely on the phone.
One real bug found. At 320px the two-up rows truncated their select
labels — Trim size read "6" × 9" — most c…", which is exactly the sort of
thing that looks fine from a desktop and makes a phone user think the tool
is broken. The rows now stack below 481px and stay side by side above it.
A noTruncation check is part of the suite so it cannot come back.
Also fixed while there: the suite took the device name only as the second argument, so passing a name without a URL tried to navigate to "iPhone SE/". It now reads either argument in any order.
Perf unchanged (251 KB, 0.96 s to first puzzle); browser suite green.
The search fundamentals, checked rather than assumed
45 pages now, and search is the main channel, but I had never verified the
things that are invisible from looking at a page. npm run test:seo walks
every URL in the sitemap and checks: it serves 200; the title is 15–75
characters and unique across the whole site; the description is 70–320
and unique; the canonical names that exact page and is unique (a copy-paste
canonical pointing every page at the home page is the classic way to lose a
site, and nothing on screen would show it); og:url matches; og:image
exists and actually loads as an image; exactly one h1; a viewport tag;
no accidental noindex; and every block of JSON-LD parses. Then robots.txt:
it must point at the sitemap and must not disallow the site.
44 pages, 41 share images, three problems — all real, all fixed. The home page, the compare page and the criss-cross page had titles of 80–82 characters, past where Google truncates, so the end was being cut off in results. Shortened to 58, 45 and 48.
The home page's title and description were also stale: they still said "word search, sudoku or maze" after two more types shipped. Both rewritten, along with the OG and Twitter copies, which had drifted separately.
0 problems on the re-run. Links and browser suites green.
Making stale copy a test failure
Four times this week a claim on a page was true when written and false when
read — the launch file, the pricing block, the demo caption, the home page's
own <title>. Re-reading everything after every change does not scale.
test/copy.test.js now runs with the unit suite, offline, over every
published page and every marketing file, and asserts two things:
1. A number in the copy must equal the number in the code. Themes, the size of the clue table, the price, KDP's page minimum and maximum, the sudoku expert clue count, and the maze size range — each read from the module that defines it. Patterns are deliberately narrow: a sudoku's "26 clues" must not be mistaken for the size of the clue table, so that rule only matches four-digit counts; the maze range is checked as a pair ("15×15 up to 39×39") rather than every square grid on the page.
2. A page that discusses the puzzle types must know all of them, and no page may miscount them. The count rule only matches product claims — a note saying an old video "shows three types" is describing the video, and is true — which took two passes to get right.
It found real drift immediately: "1,400 clues" on the landing page, in the Product Hunt file and twice in the HN post. The table has 1,460. Written before the last 60 clues existed and never updated — exactly the kind of small wrongness that a stranger checking the claim would find, and exactly what I have been catching by luck until now.
Then I checked the test can fail: injected "1,410 clues" and "All four also offer" into the landing page, confirmed both were caught, restored the file. A test I have not seen fail is a test I do not believe.
80/80 unit. Deployed; SEO suite still clean.
Does the ink stay inside the margins? Now checked in pixels
Every other check on the interior is arithmetic — the code works out a gutter, and a test asks the code whether it worked out the gutter. None of it would notice a page that draws outside the box it was handed, which is the single thing KDP rejects files for.
npm run test:ink rasterises the real PDF at 100 dpi in greyscale and looks
at every pixel: for six book types (including 6×6 sudoku) × three trims ×
free and paid, every pixel darker than mid-grey must fall inside that page's
safe area, with the gutter on the correct side for odd and even pages. One
pixel of tolerance for the rasteriser rounding a glyph edge; a failure names
the book, the trim, the page, which margin, and the coordinate.
Two real faults on the first run, in code that has been live all week:
- The maze's "start" and "end" labels sat in the margins. They are drawn outside the grid by design, but nothing reserved room for them, so
on every trim "start" landed about 0.1" inside the gutter — the binding
edge, the worst place for it. The maze is now sized to leave room for both
labels, and
drawMazeclamps them to the page box it is given. (The six-up solutions sheet passes no box and draws no labels — there was never room for them there, and they were never drawn.)
- The sudoku grid's outer border straddled the safe-area edge. A stroke sits centred on its path, so half of the 2.2pt outer line — 1.1pt — was past the boundary on a full-page grid. Now the outer lines are inset by half their width, so the ink ends exactly where the box does.
Neither would necessarily have been rejected — 0.1" into a 0.375" gutter is tight, not fatal — but both are precisely what KDP's previewer flags, and "your file was flagged" is the one thing this product must never cause. Found by looking at pixels, which is the only way they could have been.
Samples and pins regenerated with the fixed renderer, so what is published matches what the tool now makes. 80/80 unit, pdfcheck, browser, graded and links green.
"Ask for fifty, get fifty" — checked for every type (boss's question)
The boss asked whether I had checked how many puzzles each type actually puts in a book. I had not, other than for word search. The original disaster of this project was the screen saying 50 and the file holding 5, so this was the right question.
Every built-in theme is fine: all five types deliver exactly the count asked for, at 1, 14 and 50, numbered 1..n with no gaps, from the thinnest themes (American States: 37 long words) as well as the fattest.
A thin pasted word list was not fine. Eight three-letter words that barely share letters produce zero criss-cross or crossword puzzles — and the tool was happy to build the "book": three pages of front matter and five ruled pages, an 8-page file, while the line above the button still said "50 puzzles · 66 pages". That is the Notes-page failure exactly, in a new type, and I shipped it on Friday.
Three fixes:
- The generators say it once. They were pushing one warning per failed puzzle — fifty identical lines, which is not a warning but a wall. Now a single line that distinguishes the two cases: "*None* of these words can make a criss-cross: they need to be at least three letters and to share letters with each other so the grid can interlock" versus "*12 of 50* could not be built and were left out".
- The tool refuses to build an empty book — "Nothing to put in the book — see the note above" — instead of handing over ruled paper. If it is merely short, the status says so while it works: "Laying out 38 puzzles (12 could not be built)…".
- The preview no longer lies. With nothing to show it was leaving the previous type's puzzle on screen, which reads as success. It now says no puzzle could be made and where to look.
test/counts.test.js covers all of it: every type × four themes × three
counts delivers exactly what was asked and numbers it correctly; a too-thin
list warns once, never silently; indexes have no gaps when puzzles are
dropped. Verified in a live browser too — preview message, warning and
refusal all correct, no console errors.
83/83 unit; criss-cross, crossword, browser and wordlist suites green.
The preview was showing puzzles the book does not contain
Same thread as the boss's question — what is on screen versus what is in the file. The last unchecked member of that family was the preview itself.
It was lying on every graded book. The preview builds three puzzles and the grading spreads the four bands across *the number of puzzles in that book* — so the preview graded three puzzles as easy, medium, hard, while the first three pages of the fifty-puzzle file are all easy. For sudoku and mazes the difficulty is the puzzle, so preview 2 and 3 were not merely mislabelled, they were different puzzles from the ones that download.
Fixed: every generator takes gradeCount — how many puzzles the grading
spreads over — so the preview grades as the first three of the real book.
Verified puzzle by puzzle: for all five types, preview 1–3 are now byte-
identical to book 1–3, title and grid.
Two consequences, both handled honestly rather than by reverting:
- The preview of a graded book now shows three easy puzzles, because that is what the first three pages are. The nav line says so: "Puzzle 3 of 50 — showing the first 3" instead of "3 / 3".
- Read alone, three easy puzzles undersell a graded book. So the line above the preview now ends "· graded, easy at the front to expert at the back" (hard, for word search — its top band). True, and it explains what the reader is looking at.
test/counts.test.js gains a fifth case: for each type, preview 1–3 must
match book 1–3 by title and by a fingerprint of the actual grid. 84/84 unit;
browser, graded, defaults, shortbook, kids sudoku, criss-cross and crossword
suites all green against production.
The cover, checked in pixels — and made to follow the real book
Two things, both about the wrap.
1. The cover's promises, verified in pixels. npm run test:coverink
renders real covers — four sample puzzle types × five page counts (32, 66,
78, 80, 300) × free and paid — rasterises them and looks at the pixels for
the three things KDP rejects a wrap for and no geometry test can see:
something printed in the barcode's 2" × 1.2" clear area in the lower right
of the back panel; spine text on a book too thin to carry it (KDP allows it
from 79 pages, so 78 must be blank and 80 must not be); and the rendered
size matching the geometry the code computed. All clean — that promise
was already kept, and now it stays kept.
2. A short book got a cover for the book you asked for, not the one you
got. The spine came from planPages(requested), but if some puzzles
could not be built the file is shorter. Twelve fewer pages on cream is 0.03"
of spine — small, and exactly the kind of small that has a wrap printed
off-centre or rejected.
The tool now remembers what the last interior actually came to, keyed by the settings that made it, and the cover uses that when the settings still apply. The interior's status line also reports it: "Done — 30 puzzles, 42 pages, 92 KB", and when a book is short it adds "(12 could not be built — the cover will be sized for this book)". Checked live: a 30-puzzle book came to 42 pages and the cover said "your spine (42 pages)".
test/counts.test.js holds the arithmetic that must agree, with an
assertion that the two page counts really do differ — a test that would pass
whether or not the fix worked is not a test.
85/85 unit; cover ink, free cover and browser suites green.
The reproducibility promise: true, but it had a hole
The FAQ says: *"Each book has a seed — put the same seed and settings back
in and you get the identical book."* Never tested. It is true: for all five
types (and 6×6 sudoku), the same seed and settings produce byte-identical
puzzles, and a different seed produces a different book. The rendered PDFs
differ only in their ModDate timestamp — the pages themselves are the same
ink. Now a unit test, so it stays true.
The hole: if you lose the file, you have lost the seed too. The promise assumed you wrote it down. So every book now records how it was made — type, count, difficulty, grid size, words per puzzle, themes, seed — in the PDF's own properties (Document Properties → Subject). Invisible in print, there for anyone who still has the file.
And a mistake I nearly shipped in the same change. My first version also printed that line on the copyright page of every book — which would have put "Made with Puzzle Press" inside a book somebody paid $19 specifically to have unmarked. The pricing page promises the payment removes the footer line and the PREVIEW mark "and changes nothing else"; a line on the copyright page is a mark. So: printed on free books only, where a watermark already says as much and the book cannot be published anyway; paid books keep clean pages and carry the recipe only in the file's properties. A test asserts both.
A smaller thing caught on the way: a maze book's recipe listed "Animals", because the word-list checkboxes are still set even for a type that ignores them. Themes are now listed only for the types that draw on words.
FAQ rewritten to say all of this plainly, including where to look. 87/87 unit; browser, free cover, paid return, privacy, short book, ink and pdfcheck green; samples regenerated.
The launch dashboard was counting me as a customer
Three days from Product Hunt I ran the dashboard and it told me something actionable:
Checkouts started 2 Checkouts paid 0 Someone opened checkout and did not pay. Worth knowing why.
Two people reached the card form and walked away. That is the most useful number the thing produces — it is the difference between "nobody is coming" and "they come, they want it, and something at the last step stops them."
It was false. I asked Stripe for the sessions themselves and every one of
them, all five in seventy-two hours, was created by test/livecheckout.mjs.
That test walks the live Buy link to the card form on every run to prove a
stranger can actually pay, and loading a payment link creates a session. My
own smoke test was the abandoned cart.
On a quiet Saturday that is a curiosity. On Tuesday, reading the funnel hourly and telling the boss what it says, it is a lie in the one place a lie is expensive — and the worse failure is the other direction: a real abandonment on launch day, hiding among my own runs, indistinguishable and ignored.
Same family as the Stripe limit: 100 bug from this morning: correct while
the numbers are small, wrong exactly when they start to matter.
The fix. Stripe carries ?client_reference_id= from a payment-link URL
onto the session it creates, so the self-test now tags itself
selftest-<timestamp> and the dashboard counts those on their own line. Not
dropped — shown. If that number ever moves without me running the test, I want
to see it.
Two details worth keeping:
- The test still checks the link a real visitor gets. It asserts the unmodified
href, thetarget=_blankand therel=noopeneras they stand on the page, and only then follows its own tagged copy. Tagging the thing you are testing is how you end up testing the tag. - It verifies the tag stuck. After the walk-through it asks Stripe for the session it just made and fails if it cannot find it by reference. A tag Stripe quietly dropped would restore the original bug while looking fixed.
test/purchase.mjs does the same click-through but refuses to run unless the
pay link is a test link, so it never touched live. Left alone.
I also dated the change, because the six untagged sessions already in the account are all mine and a wider window would still show them as customers. Before 22:25 UTC today, untagged means mine.
So the honest number, which I would rather have than the flattering one:
Checkouts started 0 Checkouts paid 0 (my own test runs ignored: 6)
Nobody has ever opened checkout. Not two people who nearly bought — nobody. That is the problem to solve on Tuesday, and now the dashboard will say so.
Monday's pre-launch sweep, run on Saturday — and what only the real domain shows
The launch checklist puts the full live sweep on Monday. Nothing in it needs to be Monday, and finding breakage at 6am Tuesday is the worst time to find it. So I ran the lot: 22 live suites plus the unit tests. Almost everything passed. Two things did not, and both were only visible because I finally ran against the domain a buyer actually types.
The SEO suite failed with "robots.txt does not disallow the whole site"
Which is the sort of sentence that ruins a launch morning. It was a false alarm, and a test bug of my own making.
Cloudflare injects a managed robots.txt block ahead of our file on the
custom domain — a Content Signals preamble and nine Disallow: / groups for
named AI crawlers. My check grepped the whole file for Disallow: / and
concluded the site was closed. It wasn't: User-agent: * Allow: / sits right
there, and Googlebot was never touched.
The reason I had not seen it in four days of running that suite is the thing
worth writing down: I had been running it against workers.dev, where
nothing is injected. Two hosts, one of them the one customers use, and I was
only ever looking at the other. Every live suite now defaults to
puzzlepress.bananafest-destiny.com; four of them had no default at all or
pointed at a local dev server, so npm run test:browser was not testing
production either. Fixed.
The check now parses robots.txt into groups the way a crawler does and asks the only question worth asking: can the agents that send buyers reach the pages.
Which raised a real decision, and I looked into it rather than reflexively
turning the block off. The nine blocked names are training crawlers. The
retrieval agents — the ones an assistant uses when someone asks it a
question right now, OAI-SearchBot, ChatGPT-User, PerplexityBot,
Claude-User, Claude-SearchBot — are not blocked, and that is the channel
that could actually send a buyer this month. GPTBot and ClaudeBot, the two
blocked names that look alarming, are those same companies' training crawlers.
Google-Extended governs Gemini grounding; AI Overviews inside Search are
crawled by ordinary Googlebot, which is fully allowed.
So: left on. It costs a training corpus that pays back in years, if ever, and
costs nothing on any channel that matters now. But it is a managed list on
someone else's schedule, so the suite asserts each retrieval agent can still
reach us and prints the blocked list every run. If Cloudflare adds
PerplexityBot next quarter, I find out from a failing test and not from a
flat month. Written up in marketing/ai-crawlers.md.
Re-reading the pricing and FAQ against what the product does
This is on the checklist because the copy drifted twice this week. It had drifted twice more.
- "all hundred puzzles if that is what you want." The count field accepts 200. Not a false promise — we do more than we say — but it reads as a ceiling, and it undersells the one thing that separates this from the free generators. Now "all two hundred."
- "Every book ends with four ruled Notes pages." It ends with four, *or five* where one more is needed to make the page count even, which KDP requires. The FAQ's own worked example is a failing case: a 40-puzzle book gets five. A person planning a page count checks that number.
Both are now unit tests, which is the only thing that has actually worked
against drift. The cap test reads max off the form's own input and compares
it to the spelled-out number in the prose; the Notes test asks the layout what
it really produces across seven book lengths and then refuses any copy naming
a flat number without admitting the second case. I checked both fail on
yesterday's wording before committing them.
Everything else green: 89 unit tests, and browser, privacy, freecover, shortbook, links, seo, typepages, wordlists, defaults, graded, wordlist, margin, royalty, spine, paidreturn, nostorage, kidsudoku, crisscross, crossword, inkcheck, coverink, bigbook, mobile on three devices, pdfcheck. Deployed and verified live.
The funnel was counting page loads as books, and the visitors are crawlers
I went looking at page weight before Tuesday and found the dashboard's most important line was measuring the wrong file.
Clicked Download (engine) counted any fetch of /js/chunk-*. I had assumed
those chunks were the lazily-loaded PDF engine. So I stopped assuming and
recorded what a browser really asks for:
on page load ......... main.js and four chunk-*.js on Download .......... render-*.js, cover-*.js, one more chunk, both fonts
Four chunks land on every page load. The line meaning "somebody made a book"
fired for anyone who merely opened the page. The tell was sitting in
yesterday's output: engine 1 next to Font fetches 0, and a real download
needs both.
Now it measures the file that only that action fetches. render-*.js for
a book, cover-*.js for a cover, main.js for "a browser actually ran the
page". Written down in test/funnel.mjs, which drives three sessions that
each do exactly one thing and fails if the signals stop being distinct —
because nothing in the app declares this mapping and a bundler may move code
between chunks on any build. It caught a second one on its first run: both PDF
modules were loaded by a single loadPdf(), so every book download also
looked like a cover download. Split into loadRender() and loadCover();
they share the heavy chunk, so the second costs almost nothing.
Then the dashboard had my own fingerprints on it. Having just pointed every live suite at the customer-facing domain, a full sweep now makes dozens of books and covers in the funnel. Same contamination as the Stripe sessions this morning, one layer down. It asks Cloudflare which IP it sees us as, then queries twice — everything, and everything from this machine — and subtracts. Two queries rather than grouping by IP, because grouping truncates at scale and this has to survive a day with thousands of addresses.
Also pruned public/js/: esbuild's splitting leaves old hashed files behind
and we were deploying two 1.3 MB copies of the engine plus seven orphans.
4.5 MB down to 1.5 MB. Safe because everything is served must-revalidate.
And here is what the honest dashboard says:
Requests for the page 13 ...that ran the app 1 <-- a real browser; the rest are crawlers Opened a sample PDF 4 Made a book 0 Made a cover 0 (my own machine ignored: 1240 requests)
One human looked at this product yesterday. Not thirteen. Twelve of those thirteen never ran JavaScript — they were crawlers, and I had been reading them as visitors all week. Nobody made a book. Nobody has ever opened checkout.
I would rather know that three days before launch than three days after. Every number I have been reporting was flattered by something: test sessions in the payments, crawlers in the visits, page loads in the downloads. They are all corrected now, and the product they describe is genuinely good — 89 unit tests and 23 live suites say so. What it does not have is anyone looking at it. That is Tuesday's whole job.
Phase 12 — The first five seconds, for a stranger arriving from Product Hunt
The boss says the one JavaScript-running visitor was probably him. So the number of strangers who have ever run this product is zero, and no amount of further hardening changes that. What changes it is what happens in the first five seconds on Tuesday.
The measurement first. test/perf.mjs throttles to a mid-range phone on
4G with a cold cache. It had no default URL, so it had almost certainly never
been run against production. It had also never failed — it printed numbers
and exited zero, which is not a test.
DOM ready 810 ms First puzzle 987 ms Network idle 1444 ms Transferred 252 KB 226 KB over the wire (660 ms) /hero-book.jpg 22 KB over the wire (243 ms) /js/main.js
The hero image was 90% of the page. 1500px wide, 226 KB, and nothing ever
displays it wider than about 1100 device pixels. Re-encoded at 1200px as WebP
with the JPEG kept as a <picture> fallback: 81 KB, a saving of 145 KB.
Chromium's own canvas does the encoding inside scripts/images.mjs, so there
is no new dependency. Now:
DOM ready 766 ms First puzzle 934 ms Network idle 1160 ms (was 1444) Transferred 108 KB (was 252)
Then I looked at what actually lands. Screenshots at phone and desktop width, read as somebody who has never heard of KDP. Desktop was fine. The phone was not: on an iPhone 13 the usable viewport is 390×664, and the whole of it was text. The header spent a fifth of the screen repeating the hero's first sentence. The lede ran ten lines. "Make a book free" was clipped by the bottom edge. There was no picture of anything.
Three changes, all to the first screen:
- The strap is hidden on a phone. It stays in the HTML — it is the plain summary a crawler reads — but a person holding a phone gets the headline.
- The lede is 45% shorter, and the spine claim it gave up moved into the tick list where it is checkable ("Matching cover, spine sized to your page count"). A third button, "…and its cover", joined the sample links.
- The tick list moved below the picture. It was eight items of feature text sitting between the buttons and the only proof on the page, pushing that proof a screen and a half down. On a wide screen nothing visibly changes; on a phone the top of a real printed puzzle page now shows at 603px, just inside the fold.
What a stranger now gets without scrolling: what it makes, what it costs, proof it works, and two ways to act.
And I made it a test. test/perf.mjs now has budgets — 200 KB total, no
single asset over 120 KB, first puzzle under 4s — and asserts that the
headline, the lede, both buttons and the price line are whole above a 664px
fold, with the book image starting above it. It fails if any of that drifts
back. It also fails if the hero is served as JPEG, which is how a broken
<picture> or a missed deploy would show up.
Green after: 89 unit tests, plus mobile ×3, browser, links, seo, funnel and perf against production. Deployed.
Phase 13 — The card, which is the real first screen
Phase 12 fixed the first five seconds on the site. Almost nobody arrives at the site cold on Tuesday, though — they arrive at a row in a feed. I had never once looked at what that row will contain.
Product Hunt's actual requirements, read off help.producthunt.com rather than remembered: thumbnail square, 240×240 recommended, under 3 MB, GIF allowed; gallery 1270×760 recommended, at least two images; tagline 60 characters, description 260. Then I measured everything the launch doc names.
Both worries in the plan were real, and there was a third I had not thought of:
- There was no thumbnail. The doc listed eight gallery items and never mentioned the square image Product Hunt puts in every feed row. At 2am on launch night that would have been picked from whatever was lying around.
- Every gallery image was the wrong shape.
social-card.pngis 1200×630 because that is the Open Graph size;hero-book.jpgI had shrunk to 1200×840 an hour earlier for page weight, without thinking about its other job. Andgallery/04-the-tool.pngwas 1920×2699 — a full-page portrait screenshot that a 1.671:1 frame would have shown as a sliver down the middle. - The tool screenshot was showing a bug I had not noticed. It started mid-form, and the trim
<select>read6" × 9" — most comr.
The truncation was not a screenshot problem. A closed <select> cannot
wrap, ellipsise or scroll — it clips. There was already a fix for this in the
stylesheet, written against a phone media query, with a comment quoting the
same cut-off string. It was the wrong ruler: the settings column is capped at a
fixed width, so the field is the same ~184px at 1280px as at 860px, and the
window getting wider never helped. Every desktop visitor for weeks has been
asked to choose a trim size labelled 6" × 9" — most comr, and a second
control, the word-search difficulty, was cut off the same way.
Fixed by measuring rather than guessing: the column cap went 420 → 480px and
.row became repeat(auto-fit, minmax(210px, 1fr)), so a row goes two-up only
when two fields actually fit — it measures the space the row has, not the space
the window has, and cannot drift out of step again. test/browser.mjs now
measures every visible option's text against the box it has to fit in, at
1280/1000/860/400px, for all five puzzle types. While I was there, "List price
(USD, for the royalty estimate)" was wrapping to two lines and knocking its
field out of line with Ink; the royalty note under the button already says "at
$9.99 you keep $X a copy", so the label is just "List price (USD)" now.
The assets themselves. scripts/gallery.mjs now renders every frame at
1270×760 at 2× (2540×1520, ratio 1.671, all under 3 MB), with two new opening
frames — a Product-Hunt-shaped redraw of the card, and a puzzle page beside its
solutions page — that replace the two website images that could not be used.
They are numbered in upload order, 01 to 06.
The thumbnail took two goes. The first was a crop of a real printed page, which is what every other image here is and felt like the right instinct. I put it beside the name and tagline at 96, 64, 48 and 36px and looked at it: at feed size it was a grey square. Fifteen letters across 48 pixels is three pixels a letter; no photographic detail survives that. So the thumbnail is the one drawn mark in the set — four cells of a solved word search, WORD down the diagonal, shaded the way the solutions pages shade answers. Full size it is legibly a word search; at 48px it is a white page with a dark diagonal, which is the same idea. Judged the same way as Phase 12: in the time it takes to scroll past, the row says puzzles and the tagline says for Amazon KDP.
And I made it a test. npm run test:launch reads the image headers itself
(PNG, JPEG, GIF and WebP — four format specs are cheaper than a dependency) and
fails if the thumbnail is not square, a gallery frame is the wrong ratio or
over the limit, a file is named in the launch doc but missing from disk or on
disk but missing from the doc, or the tagline or description is over the form's
character limit. It caught its first thing immediately: the doc said the
description was 226 characters and it is 250.
Green: 89 unit tests, browser (with the new clipping check), mobile ×3, perf, links, seo, privacy, defaults and launch-assets. Deployed, twice.
Phase 14 — The paying moment
I walked the whole path a buyer takes, as a stranger would, on a phone and on a desktop, against the live site: make a free book, look at what the watermark does to it, open the ask, come back from Stripe, and get stuck. Four things were wrong, and three of them only show up to somebody who has actually paid.
The mark is right. The free interior is a complete book with one grey line centred in the footer of every page — "Made with Puzzle Press — free preview" — and the free cover carries a large rotated PREVIEW across the front with a note under it saying how to remove it. That is the balance the phase was there to check: neither reads as damage, and neither could be published. Nothing to fix.
The title page was lying about the book. The subtitle field defaults to "50 relaxing puzzles with solutions" and followed the puzzle type but not the puzzle count. Puzzles is the most obvious knob on the page. Anybody who moved it off 50 — which is most people, and is exactly what I did while walking this — got a book whose first printed page claimed a number it did not contain. My 12-puzzle book said 50. That is the page an Amazon shopper sees in Look Inside. The default is now a template over the count, with the singular handled, and it still stops following the moment somebody types their own subtitle.
A buyer who typed the wrong email hit a wall that said nothing. The verify endpoint's ordinary refusal was "No completed payment found for that email. Use the exact email from your Stripe receipt." and ended there. The ordinary way to reach it is not fraud, it is a buyer whose card sits under a different address from the one they typed. They have paid us $19 and the product says no with no way out. It now ends where the other refusal already did, at support.
Worse: on the way back from Stripe, that wall was disguised. Coming back to
/?paid=1, any "no payment found" answer was rewritten to "Stripe has not
finished recording that payment yet. Give it a few seconds and press Unlock
again — your money is fine." For the real race — back from checkout seconds
ago, session not yet recorded — that is the right answer. For the wrong-address
buyer it is true forever: they press Unlock, wait, press Unlock, and are told
the same reassuring thing every time, while the message hides the fact that the
address is wrong and never mentions support. The reassurance now gets exactly
one turn, then the real message. test/paidreturn.mjs pins both halves.
And nothing told a returning buyer how to get back in. The licence has always been just the email — no account, nothing to expire — but on a new laptop, or after clearing a browser, the only thing the page offered was "Remove both — $19 one-time", which is what being asked to pay twice looks like. There is now a second link beside it, "Already paid? Unlock", into the same dialog, and a line in the terms that says the same thing in words.
The support address in an error is also a mailto: link now rather than a
string to memorise — built from text nodes, not innerHTML, because that string
comes from the server and server text is not worth parsing as markup.
Green: 90 unit tests (one new: every refusal a buyer can reach names a way out), browser, mobile ×3, defaults (two new: the subtitle follows the count, and a 12-puzzle book still warns it is under KDP's 24-page minimum), paid return, links, privacy, nostorage, free cover. Deployed three times.
One question for the boss, in FACTS terms: is "Successful payments" turned on in Stripe's customer-emails settings for the live account? The unlock is keyed to the email on the receipt, and the receipt is the only place a buyer ever sees which address that is. I cannot read that setting with the restricted key. There are nine live checkout sessions and none of them completed, so there is no real receipt to look at either.
Phase 15 — Monday's sweep, on Sunday night
Actual.
The live sweep, against production. livecheckout, paidreturn,
browser, typepages, privacy — plus seo and links — all green. The
payment link still redirects to /?paid=1, the price is still $19 in the
dialog and in Stripe, the returning buyer is still thanked rather than
re-pitched, and the second Unlock press still names support. Nothing in
Tuesday's path is broken tonight.
Copy against the product, as it is tonight. I read the pricing block and every FAQ answer against the code rather than against yesterday's copy, and re-checked every number in them: the trims, the sudoku givens (40/32/28/26), the maze sizes (15×15 to 39×39), 1,460 clues, 32 themes, the 200-puzzle ceiling, the four Notes pages, KDP's 24-page minimum, the seed line on the copyright page. All true. One gap, not a falsehood: the page now had two ways into the unlock dialog and a line in the terms about it, but the FAQ — which is where somebody with a question actually looks — said nothing. Added *"I paid already. How do I unlock on another computer?"*, and the same answer as a prepared reply in the Product Hunt post, which describes the free/paid split and had been written before the unlock moved. Deployed.
The pre-launch baseline, so Tuesday's numbers are a comparison and not a number on its own. Last 24 hours: 449 worker invocations, 0 errors. 12 requests for the page; 1 of them ran the app; 2 sample PDFs opened; 0 books made, 0 covers, 0 checkouts started, 0 paid, $0.00. Excluded: 167 scanner requests to paths that do not exist, and 5,312 requests from my own address. That is the number the launch has to beat, and it is honest about how small it is: the sitemap was fetched 21 times and robots.txt 22, so most of what reaches the site is machines.
The Pinterest question, answered: no. I sent one pin — a real image, a real
board, shareNow so the block was actually tested — whose destination link
was the domain rather than the YouTube workaround. Pinterest refused it:
"Looks like Pinterest is hitting some snags with the 'Source URL' — up for
giving another link a try?" The appeal denied on the 12th is still in force. I
deleted the failed post so it is not sitting in the queue, left the two
remaining scheduled pins pointing where they already point, and I am done
spending attention on Pinterest until the mid-October re-appeal.
Worth being clear about what this costs, because the earlier entries were not: the five pins that did send carry the site address as plain text in the description, and plain text is not clickable on Pinterest. Their destination is the YouTube channel. So Pinterest is sending Puzzle Press no traffic at all, and the pin work should be read as zero until the block lifts — not as five pins live.
Done when — all four met. Five suites green against production, the money copy checked line by line against what the tool does tonight, the baseline written down, and the Pinterest question answered either way. It was answered "no", which is still an answer, and a cheap one: one API call instead of a week of hoping.
Phase 16 — The engines nobody has tested
Actual. Green everywhere, but not before two real things were found.
The finding that started it. Every test in this repository ran on Chromium
— including test/mobile.mjs, which launches Chromium and hands it an
"iPhone 13" profile. A device profile is a viewport and a user-agent string.
The puzzle generation, the PDF layout, the font embedding, the download: all
of it was still Chromium. The suite had said "MOBILE OK — iPhone 13" for weeks
and had never once run Safari's engine.
Firefox was installed and works. Full walk against production: a 50-puzzle free book (66 pages), a licensed 60-puzzle 8.5×11 book (78 pages), the free and paid covers, the unlock dialog, the phone-width layout, no clipped control labels at four widths across all five puzzle types. Zero page errors.
WebKit was the one that mattered and the one I nearly did not get. Its
dependency check asks for two libraries via sudo. Before asking, I looked at
what was actually missing: libwoff1 and libavif16 are both effectively
present, and the single real gap was libjxl.so.0.8 where the system has 0.7.
JPEG XL is a format this site never decodes, so I symlinked 0.7 into
Playwright's own lib directory and WebKit 26.0 launched. Same full walk, plus
the storage-refused walk (iOS private browsing is the common way a real
person's browser refuses localStorage) and the whole paid-return path. All
green, zero page errors, on the engine every iPhone in Tuesday's traffic will
be running.
What was actually wrong. Auditing the shipped bundle for APIs Safari does
not have turned up nothing dangerous — no lookbehind regex, no
showSaveFilePicker, no OffscreenCanvas; the SharedArrayBuffer and
Atomics references in the pdf-lib chunk are all typeof-guarded. One thing
was not guarded: dialog.showModal(), which arrived in Safari 15.4. On an
older iPad, clicking Remove both — $19 would have done nothing at all — no
dialog, no error, no clue — at the exact click where the money is. Both
showModal and close now fall back to the plain open attribute, which
every engine has understood for years. Non-modal is worse than modal and
infinitely better than a button that silently does nothing. Deployed, and both
Chromium and Firefox re-walked after.
Made permanent, which was the point. browser.mjs, freecover.mjs,
mobile.mjs, nostorage.mjs and paidreturn.mjs all take an engine
argument. npm run test:mobile now runs the two iPhones on WebKit and the
Pixel on Chromium, because that is what those devices actually are. A new
npm run test:engines walks Firefox and WebKit end to end. The libjxl trick is
written down at the top of browser.mjs so that a future WebKit failure is a
known thing rather than a mystery.
Done when — met. A book and a cover have been made by two engines that are not Chromium, against production, and the one thing that would have broken on Safari is fixed.
The honest note: this does not prove the app survives a real iPhone. WebKit on a desktop has memory a phone does not, and a 200-puzzle book is a heavy ask. What it does prove is that nothing is engine-specifically broken, which is the failure I could not have distinguished from "nobody was interested" when Tuesday's dashboard read zero.
Phase 17 — After Wednesday
Actual. I went looking for the durable channel and found something more urgent on the way.
The site is not in the index. Not "ranking badly" — absent. I searched for
the exact domain and got the GitHub repo back; I searched for phrases that
appear nowhere but on this site and got the GitHub repo back. Everything a
search engine currently knows about Puzzle Press it learned from
walkertbrown/puzzle-press's README, not from the live site.
Googlebot is here, barely, and only at the front door. Last 24 hours: Googlebot 6 requests — the homepage 3 times, robots.txt twice, sitemap.xml once. Not one of the type pages, the guide, the comparison or the calculators — the pages built specifically to be found. Also by: ClaudeBot 8, Pinterest 8. Nothing else. I had been reading "sitemap.xml: 21" in the traffic report as evidence of crawling; a sitemap fetch is not a page fetch, and now I know the difference on this site specifically.
So the answer to what happens on Thursday is: nothing. Search sends zero today and will send zero for weeks. The type pages, the guide and the calculators are a real investment with a payback measured in months, and that is fine — but nothing in launch week can lean on them, and I had been quietly leaning.
Then the thing I did not go looking for. Searching for how this tool is
described turned up the field it is launching into, which I had never
surveyed: PuzzleForge, KDP Builder, PuzzlePage, PuzzleGenerator, KDPEasy. The
strongest is the-puzzle-forge.org — free, ad-supported, no account, no
watermark, six puzzle types, bulk-generates up to 50 puzzles with answer
keys, three KDP page sizes, entirely client-side, and explicitly positioned
as "the free alternative to BookBolt".
That matters because of what was written on my own comparison page and in my prepared launch replies: "free generators make one puzzle." It was true when I wrote it. It is now false, and the Show HN reply I had queued said it in those words. Posting a wrong claim about a named competitor, on Hacker News, in answer to the question that decides the launch, is about the most expensive small error available to me this week.
What is actually different, checked rather than assumed. PuzzleForge's own FAQ: *"Click 'Save PDF' after generating. A clean print window opens — in Chrome or Edge choose 'Save as PDF' as the printer."* That is a browser print of puzzle pages. No title page, no copyright page, no page numbers, no padding to KDP's 24-page minimum or an even page count, no inside margin scaled to page count, no font embedding of the kind KDP asks for — and no cover. The cover is the wall, because spine width comes from the page count of the book you just made. The line is not how many puzzles you get. It is puzzles versus a book.
Changed. /compare now names PuzzleForge, links it, says plainly that this
page used to describe free tools as one-puzzle-at-a-time and that this is no
longer true, and ends the free column with *"if you don't need a cover and
don't mind assembling the interior, a free generator is the right answer and
you should use one."* The privacy row no longer claims free tools send your
words to their servers — the good ones are client-side too and say so. The
Show HN and Product Hunt replies are rewritten to concede the true part first,
and show-hn.md now carries an explicit do not say warning about the old
line. Deployed; links (167 checked, 0 bad) and SEO green.
Pre-flight. Every asset the Product Hunt post names is live and the right size: thumbnail, the six gallery frames, demo.gif, five-types.mp4, social card.
Done when — met. In one sentence: on Thursday this site gets nothing from search, so launch week is the whole of the near-term plan, and the question it has to survive is "there's a free one", which it now answers honestly.
Phase 18 — what actually happened
The risk I planned around was not real. /api/verify on an email with no
payment: 0.33s, three times, against production. Phase 14's thirty seconds was
a number about a different account on a different day. Written into the code
next to MAX_SCAN_PAGES with the date, so the next panic has something to read.
The first load is fine. Cold landing on a 390px phone at slow 4G (1.6 Mbit, 150ms RTT, 4x CPU throttle): first paint 452ms, usable preview grid 1.1s, 124 KiB pulled. The 666 KB demo GIF is lazy and never loaded in that window.
The first click is not. Pressing Make my book pulls 2,155 KiB the page has never touched: the 1.37 MB pdf-lib/fontkit chunk and two 400 KB TrueType files. On that same connection that is 13.5 seconds between the click and the PDF.
What I changed.
- A new
src/pdf/heavy.js— a two-line module whose only job is to hold the heavy chunk — dynamically imported on an idle callback after the first render. It is its own file because the launch dashboard reads the funnel out of Cloudflare's request paths, and warming throughrender.jswould have made every visitor look like somebody who made a book. Skipped entirely when the browser reports Save-Data or a 2G connection. download()anddownloadCover()now start the font and module fetches before generating rather than after.npm run buildclearspublic/jsfirst. It never had, so every build left the previous hashed bundles behind and deployed them.test/funnel.mjsgained awarmedsignal and asserts a plain landing warms the chunk without looking like a book or a cover.scripts/traffic.mjsshows it as "...and did not bounce", which is a measure the dashboard did not have.
Measured after, same throttled phone, click to PDF:
| click -> PDF | |
|---|---|
| clicked the instant the page was usable, warm-up blocked | 13.5s |
| clicked the instant the page was usable, warm-up racing it | 12.8s |
| clicked after twenty seconds of reading the page | 8.7s |
The honest read: somebody who clicks instantly gains almost nothing, because the warm-up is competing for the same bandwidth. Somebody who behaves like a real visitor — lands, reads what it is, then tries it — gains five seconds.
The change that did nothing. Starting the fonts in parallel with puzzle generation, isolated locally: 20.1s before, 20.1s after. Most of the wait is laying out pages, which cannot start until the fonts have arrived. Kept, with the measurement in the comment instead of the claim.
Green after all of it, against production: browser (chromium, webkit, firefox), freecover, nostorage, paidreturn (webkit), funnel, and mobile on iPhone 13, iPhone SE and Pixel 7.
Reddit. The boss produced the account: [account removed], six years old,
23 karma, 22 contributions, no followers. Six years clears every account-age
filter, which is the one thing that cannot be fixed in a week. 23 karma clears
the low bars and fails the high ones. Recorded in FACTS.md; nothing built
against it yet.
Phase 18b — pre-flight on tomorrow's launch assets
Went through the Product Hunt file as somebody who has to schedule it from the file without guessing.
Assets: all eleven present locally and live (200, byte-for-byte). Thumbnail
512x512 square; six gallery frames all 2540x1520, exactly Product Hunt's
recommended 1270x760 at 2x, all under the 3 MB ceiling; social card 1200x630;
five-types.mp4 4.4 MB. npm run test:launch green — tagline 39 chars,
description 250, both inside the form's limits.
Found the thing that mattered. Phase 17 corrected "free generators make one
puzzle at a time" on /compare and in the Show HN reply. It was still in two
other places: the landing page FAQ, live, and the first comment for Product
Hunt — the one the boss posts at 12:01am Tuesday. Both rewritten around the
true difference (puzzles versus a book), the FAQ deployed and verified live, and
a standing "do not say this" note added to the Product Hunt file so it does not
come back in a reply.
Reddit. Boss made a Reddit-only password and sent it. It cannot be used from here: reddit.com returns 403 to everything from this machine — curl with a browser user-agent, the public about.json and rules.json endpoints, and the fetch tool. I cannot read a subreddit's rules or see the account. So Reddit is paste-mode: I write, the boss posts. Asked them to paste the rules of r/selfpublish and r/KDP, which is the one input I cannot get myself.
Phase 19 (part 1) — Monday's list, brought forward
test:livecheckout, test:typepages, test:privacy, test:links, test:seo
all green against production. Live checkout still reads "Puzzle Press
Unlimited | $19.00", no sandbox badge, card form mounted, nothing charged.
The baseline was wrong, and it was flattering. npm run traffic first read
4 real browsers, 2 books, 3 covers in 24h. Querying user agents showed three of
those browsers were this machine — WebKit 26.0 and Playwright's iPhone 13 and
iPhone SE profiles — arriving over IPv4, while the script only ever excluded the
IPv6 address node happened to use. Fixed to exclude both families. Honest
pre-launch baseline: 1 real browser in 24 hours, 0 books, 0 covers, $0.
Pinterest is still blocked. Retried one pin for real (shareNow, not a draft — a draft never reaches Pinterest and would have looked like success). Identical error to Friday: "Pinterest is hitting some snags with the 'Source URL'". Two days after the appeal was denied, the domain block stands. Failed post deleted. Re-appeal mid-October as planned; expect nothing from Pinterest on launch day.
Phase 19 (part 2) — the money path, run without money
Boss declined the $19 test purchase. Good: it forced the better fix.
src/worker.js now reads const api = env.STRIPE_API || "https://api.stripe.com".
It is a binding, so nothing in a request can point it anywhere, and it is not
set in production — the deploy output lists env.ASSETS and env.PAY_URL and
nothing else.
test/unlock.mjs starts a fake Stripe on 127.0.0.1, runs the real Worker
against it under wrangler dev, and asks it the questions a buyer's browser
asks. All 24 checks green:
- exact match unlocks, one Stripe call, token names the paying session
- receipt says
[email removed], buyer types[email removed]→ still unlocks - completed but
payment_status: unpaid→ refused - buyer 250 sales back, wrong case → found on page 3 (1 exact call + 3 pages)
- 2,100 sessions → gives up at the 20-page cap and names support, and does not say "no completed payment" to somebody who paid
- Stripe unreachable → 502 and "try again", not 404
- and in a real browser: free book watermarked, unlock, paid book clean, paid cover clean, survives a reload, no page errors
Four of those branches had never executed anywhere. The case-insensitive fallback and the paged scan were both written after the one real test-mode purchase on 2026-09-10 and had never run against a match.
And the check that could not fail. test/purchase.mjs asserted a paid book
had no watermark with raw.includes("free preview") on the file's bytes. pdf-lib
subsets the embedded font, so the words are glyph ids inside a compressed
stream and that string is never in a PDF — watermarked or not. The assertion had
passed on every book ever made because it was an assertion about nothing.
test/freecover.mjs hit the same wall, noticed, and printed "checked visually
instead", which meant not checked; it was standing on a file-size comparison
instead. So the single claim the product makes about what $19 buys had never
been tested. test/pdftext.mjs now extracts the text with pdftotext and all
three tests use it. It has to be -raw: -layout silently drops rotated text,
and the cover's PREVIEW is drawn at 30 degrees — my first version of the helper
used -layout and reported a marked cover as unmarked.
Deployed. Production re-checked after: unknown address still 404s in 0.34s with the support line, edge JS matches the local build.
r/KDP is closed. Boss pasted the rules. Rule 1: "No Book Promotion… Any such submissions will be removed." Rule 2: links outside Amazon are allowed "in comments only if they are how-to guides or directly helpful to the OP in context." There is no post shaped like a launch that survives there. The only permitted move is answering somebody's actual question and linking the free guide when it is the answer. Still waiting on r/selfpublish's rules.