# Changelog

## v6.2.3 — 18 August 2026

**The full-sweep review pass.** Ten module-by-module reviewers over the
shipped v6.2.2, then every finding fixed: 5 crash/data-loss items (a
prototype-named custom-plant id could kill Seed & supply or the whole Catalog
permanently; deleting a journal entry in a read-only-latched tab destroyed its
photos irreversibly; ~30 write paths still bypassed the refuseWrite guard,
several overwriting the refusal with a success toast; stored content packs
were never re-sanitized after import), 19 wrong-behaviour items (zones 9–10
lost their spring stage tasks to a calendar-year test; section-2/3 annuals got
perennial windows and care; "Plan a putting-up day" died across New Year;
Diagnose's sticky-film key counted "plants" as "ants"; the Kitchen invented a
false batch ceiling; containers were still charged field row spacing in the
overlap and qty checks; the Jars & lids card — lid-safety line included —
never reached either supply export; an unknown pantry method was relabelled
"Frozen" with freezer safety copy; and more), and 16 polish/a11y items
(including a v6.2.2 regression: the empty savebar left a permanent 8 px band
on phones). Full accounting in `CODE-REVIEW-v6.2.3.md` and
`FIXES-APPLIED-v6.2.3.md`; two design calls deliberately deferred and
written up there. Suites 851/851.

## v6.2 — 15 August 2026

**The diagnosis release. Fourteen views could plan a garden, and none of them
could answer the question a gardener actually asks out loud:** *what is wrong
with this plant?* The catalog carried a "Common troubles" strip on fifty crops,
and the Pests & Disease pack deepened it — but both start from the culprit and
work toward the symptom, which is exactly backwards from how the question
arrives. Nobody walks in from the garden knowing they have a diamondback moth.
They walk in holding a yellow leaf.

**Diagnose is the fifteenth view, and it starts where the gardener starts.**
Which plant, where it shows, what you can actually see, what has changed
lately — then every cause in the key ranked against those answers, each one
carrying how to be sure, what to do least-drastic-first, and what it is most
often mistaken for. A symptom key sits behind the second tab for anyone who
would rather scan a list than be led through one.

**The half that was missing is the abiotic half.** "Why is my plant failing"
is answered *the hose* far more often than *a beetle*, and an app that only
knows pests will confidently name one. So the key was written the other way
round: 37 causes, of which 14 are watering, nutrition, weather and culture —
over- and under-watering and the wilt that tells them apart, seven nutrient
deficiencies with their look-alike traps, heat and frost and light, herbicide
drift, transplant shock, root-bound pots, pH lockout, bolting and pollination
failure — sitting as equals beside 23 pests and diseases. Blossom end rot is
filed under nutrition and its entry says plainly that it is a watering
problem wearing a calcium costume, because the eggshells advice has killed
more tomatoes than the rot has.

**Where the answer is uncertain, it says so.** When the top two candidates
land within a point of each other the view says *several causes fit* before it
says anything else, and points at the checks that split them apart. The
threshold for that admission is deliberately low: one tick of "lower leaves
yellowing" scores nitrogen hunger three against drowning two, and feeding a
plant whose roots are drowning is the classic way to finish it off. A wrong
certainty kills more plants than an honest maybe.

**Nothing here is behind the paywall, and nothing here is automatic.** Every
tier gets the whole key — diagnosis is correctness, and the rule has always
been that correctness ships in every tier. Suite 9 pins that as a source-level
guarantee: the module may not call `requirePro`, and may not draw a ✦.
Likewise, a diagnosis writes nothing to the garden until asked: the treatment
plan and the links to the catalog and the guide are always on screen, and
"Log to journal" and "Add follow-up to Today" are buttons.

Verified by nine suites — 792 checks, all passing (82, 60, 53, 87, 159, 49,
121, 76, and suite 9's new 105). The badge reads v6.2; the hosted and download
builds ship byte-identical.

### The view

- **A fifteenth view, `diagnose`**, in Reference beside the catalog and the
  guide rather than in My garden: it is consulted *about a plant* when
  something has gone wrong, not walked through as part of the daily round. The
  sidebar is fifteen and the phone's More sheet is ten; the five-item bottom
  bar is unchanged, on purpose. suite5's and suite6's pins move with it, as
  they did for the Pantry.
- **The guided read** asks in the order a house call would: the plant (chips
  for what is in your beds, a dropdown for anything in the catalog, and
  "not sure" is a valid answer), the part affected, the symptoms — tick as
  many as fit — and finally what has changed lately. Conditions season the
  ranking; they never invent a candidate, so ticking "a heat wave" cannot
  conjure a cause that no symptom supports.
- **The symptom key** is the same engine held the other way up: 42 symptoms
  grouped by plant part, each one opening the causes that produce it. Both
  doors share one scoring function, so they can never disagree.
- **Ticking a symptom repaints the answer in place.** `go()` re-renders the
  view, scrolls to the top and moves focus to the `h1` — correct on
  navigation, and violence mid-checklist. Only the choices at the top of the
  page (the plant, the door, Start over) go through it. The symptom key's list
  is built once and only re-lit for the same reason the guide's contents list
  has to hunt down its equivalent anchor after every repaint: the node the
  reader just pressed should still be the node they pressed.
- **A crop gate.** A cause flagged `onlyCrops` — blossom end rot, the squash
  vine borer — is dropped outright when the named plant is not a host, however
  well the symptoms match. A diagnosis that cannot apply must not appear. With
  no plant named it stays in, because generic mode is allowed to say "if this
  is a squash".
- **The crop's own troubles ride along.** Whatever `DATA.troubles` holds for
  the named plant — the fifty baked strips, and whatever the Pests & Disease
  pack has filled in — is cross-matched against the ticked symptoms and shown
  beneath the ranking, matches first. The pack fills that table before this
  view reads it, so pack depth arrives here without the view knowing packs
  exist.
- **A way in from the plant itself.** The drawer grows a "Something wrong with
  this one?" button that opens Diagnose pre-filled. Offered on every plant,
  not only the fifty with a troubles strip — those are exactly the crops
  nobody wrote a strip for, and the watering half of the key applies to all of
  them.
- **Addressable**, like every other view: `#diagnose/tomato` restores the
  reading. A plant id naming nothing clears rather than sticks, the same
  discipline the Kitchen's crop and the bed editor's id already follow.

### Following through

- **`RW.diagTasks()` returns tending rows** shaped exactly like
  `puttingUpTasks()`'s, so a "recheck the soil in two days" flows into Today's
  Tending card and the calendar's six-week list, and Done / 7d / Not mine work
  on it through `careDone` and `taskState` with no special handling anywhere.
  Done writes the journal `task` entry; the journal is the record, as it has
  been since v5.
- **The rows are not bed-filtered**, deliberately, where the care engine's
  are. A diagnosis is made about a *plant* — often one in a pot on the step, or
  one the gardener could not name a bed for — so there is no bed to filter by,
  and dropping the row for want of one would silently lose the recheck the app
  had just promised to remember.
- **`state.diag`** is the new list, hardened in `normalizeState` exactly as the
  pantry and the seed drawer are: plain objects only, every field clamped, an
  entry with no label or no valid date dropped, guard-if-present on the
  optional fields so a normalize pass stays a no-op on clean state and export
  still round-trips byte-identically. Rows older than roughly a season are
  pruned — a recheck nobody did last year is not a job, it is a reproach.
- **Confirming the same diagnosis twice does not stack the same job**, and a
  follow-up whose plant has since been deleted still surfaces, filed against
  the garden rather than dropped.

### The suites

- **Suite 9 is new: 105 checks** over the baked key's shape (every cause cites
  only symptom and condition ids that exist; every cause carries both a way to
  be sure and a plan), the scoring engine, the honesty gate, the crop gate in
  both directions, ten named pictures that must each land on their own answer,
  both doors, the troubles cross-match, all three follow-through paths, the new
  state and its normalize hardening, the routing, the drawer's way in, the
  phone, and the every-tier promise.
- **Suite 5's sidebar and More-sheet pins move to fifteen and ten**, suite 6's
  sidebar pin to fifteen, and the version-badge assertions in suites 5 and 8
  to v6.2. Those are the pins that are *supposed* to move with a new view;
  that they had to be edited is the suites working.
- **The service worker is `bloomsteader-v6.2.0`**, so an installed copy fetches
  the new build rather than serving v6.1 out of the cache.

### Known, and not introduced here

- **Suite 6's cart section is flaky**, failing eight checks in roughly one run
  in three on a loaded machine. It is a race in the suite's own
  `reload()`-then-`sleep(1600)` step, not a defect in the Shop: the untouched
  v6.1 build fails at the same rate under the same conditions. Re-run before
  believing it, exactly as suite 1's check #30 has always been re-run. Worth
  fixing properly — by waiting on a readiness signal rather than a stopwatch —
  in a release that is about the suites.

## v6.1.6 — 10 August 2026

**A fifth sweep over the putting-up release, and the theme is the number that
took the long way round.** v6.1.4 gave `putUpCrops` a default bed list so that
a season surface which forgets to ask still describes the garden the global
setup filter is showing, and v6.1.5 pushed the area filter through the four
surfaces that default could not reach. Both fixes live in `putUpCrops`. Two
screens quote pounds without going through it — the decision helper's own
pre-fill, and the Kitchen's scaled-batch line — and both called `projectedLb`
with no bed list at all, which has always meant every bed you own.

**So the helper opens on a garden the card that sent you there is hiding.**
Filter to containers with two hundred onions in a raised bed and two hundred
in pots, and the Seed & supply list costs 90 lb of onion at 36 quart jars, the
pantry-door sheet says 90 lb, the season share card says 90 lb — and "Put it
up" opens with **180** in the box, under a caption reading "Pre-filled from
this garden's projection — 120–240 lb across the season", and then works that
out to "roughly 72 quarts". Four screens, one crop, one filter, two answers,
and the wrong one is on the screen whose whole job is to be the place the
gardener decides what to do with the pile.

The fix belongs in `projectedLb`, not at the two call sites: it is the
function that does the counting, so it is the function that should know which
garden it is counting. `RW.setupBeds()` is that rule, written once, and
`putUpCrops` reads it too rather than keeping a second copy of it.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a probe driving the real build in Chromium,
which reproduced each defect below before the fix and asserted against it
after. The badge still reads v6.1; the hosted and download builds ship
byte-identical.

### The filter that never reached the pounds

- **`projectedLb` defaults to the setup-filtered garden**, the same rule
  `putUpCrops` has defaulted to since v6.1.4. Today's glut warning and
  `putUpCrops` pass their own lists and are unchanged; the decision helper and
  the Kitchen's ✦ "your garden projects…" line passed nothing, and now get the
  garden the rest of the app is describing.
- **`RW.setupBeds()`** is the one answer to "which beds is the filter
  showing". `putUpCrops` had it inline; two copies of a rule is two places for
  it to drift, which is the whole reason the last two releases exist.

### The count that was right and useless

- **The cook-through-the-glut week sheet caps its batch counts at
  `BATCH_CAP`.** That constant's own comment says it is "shared by the
  helper's phrase and the pantry-door calendar's column so the two cannot
  describe the same pile differently" — and the third surface doing the
  identical division never got it. Twenty-four pounds of onion a week against
  a half-pound recipe printed **"roughly 24–48 batches at 0.5 lb each"** in a
  column headed Scale, on a sheet planning seven days, while the pantry-door
  sheet for the same crop correctly said "12+". It now names the size of the
  job — "more than 12 batches … split this one across methods" — exactly as
  the other two do.

### The Pantry

- **The shelf caches its photos for the life of the view.** v6.1.1 replaced
  `RW.go('pantry')` with an in-place `repaint()` and listed "re-reads every
  shelf photo out of IndexedDB" among the costs it was written to avoid —
  then `repaint()` called `paintShelf()`, which rebuilt every row and fetched
  every image again. Ticking three jars off a twelve-jar shelf read
  thirty-six base64 images back out of the store to redraw pictures that had
  not changed. Half the fix had been shipped; this is the other half.
- **The container latch starts closed.** `unitTouched` exists so a hand-picked
  container is never overwritten by a later change of method, and it was
  seeded from the *prefilled* container — which is set to quarts on exactly
  the path that prefills it. So arriving from "Into the pantry →" on a canning
  method latched the box on the way in, and switching the method to Frozen
  left the container reading "quarts". That is the one field the supply list's
  "N quart jars are currently full" counts, so it miscounted the shelf in the
  opposite direction from the defect v6.1.5 added the latch to fix.
- **The focus restore reads `dataset` instead of building a selector.** The
  shelf was the only place in the build that spliced stored data into a CSS
  selector, and `normalizeState` leaves `id` alone the way it does everywhere
  (`x.id = x.id || uid()`). An imported garden carrying an id with a quote in
  it made `querySelector` throw a `SyntaxError` out of the Ate one / Remove
  handler — after the save, so nothing was lost, but the focus restore never
  ran. Nothing there needed a selector.

### Looked at and left alone

- **Today's "Big weeks ahead" ribbon filters on the raw `gap` without
  consulting `ahead`**, which is the shape of the bug v6.1.3 fixed on the
  season readers. Swept across all 446 crops: no window in the catalog can
  satisfy one test and fail the other, because a `gap` inside the ribbon's
  sixty days can only wrap for a harvest window longer than about 245 days and
  there is none. A second test that can never change an answer is noise.
- The annual-window-into-current-year date resolution, `emit()`'s
  unconditional "saved to your downloads" toast, the share cards not
  reflowing for short content, the 14-row year cap, and `putUpCrops` using the
  first bed's `bedAdj` for a crop planted in several — all as before.

## v6.1.5 — 10 August 2026

**A fourth sweep over the putting-up release, and the theme is the second
filter.** v6.1.4 found four season-long surfaces that took no bed list and so
never heard about the global setup filter, and gave `putUpCrops` a default
that fixed all four at once. That default can only ever know about the setup
filter. Today and the Calendar each carry a *second* one — the area — and
every surface handed the fix in v6.1.4 is still deaf to it.

**So the two buttons sitting on Today's own filtered Tending card describe a
different garden from the card they sit on.** Filter Today to "Back", and the
"Big weeks ahead" ribbon correctly names one crop, the Tending list correctly
carries one putting-up row, and then *Share the season* renders a card headed
"2 crops coming in quantity" and *Putting-up calendar* prints a sheet headed
"2 crops projected in glut quantities", both of them listing the front bed the
same screen is hiding. The Calendar has the identical defect in its own head:
a chart filtered to one area, one row on it, and a **Share as image** button
beside it that draws every crop in the garden.

That is the same sentence v6.1.4 was written to stop, at the one filter it did
not reach — and it is worse on a share card than anywhere else, because the
card leaves the device.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a probe driving the real build in Chromium: 31
assertions, each defect below reproduced before the fix and asserted against
after it. The badge still reads v6.1; the hosted and download builds ship
byte-identical.

### The filter the fix could not reach

- **`sharePuttingUp` and `puttingUpCalendar` take a bed list.** Today passes
  the one it has already built from the setup filter *and* the area filter —
  the same list the ribbon and the tending rows are drawn from. The default
  is unchanged, so every other caller keeps v6.1.4's behaviour.
- **`shareYear` takes a bed list and the name of the area it came from.** The
  Calendar resolves the area once, above its own page head, and both the care
  card and the Share button read that one answer instead of computing it
  twice. The card prints "Year at a glance · zone 7 · Back" when it is
  filtered, because a filtered card that does not say so is the other half of
  the same lie, and "Nothing is planted in Back yet" rather than "Plant
  something in a bed first" when that area is empty.

### The bloom bar nobody could see

- **The year share card put `bloom` in the wrong lane, and on 41 of the 446
  crops its gold bar was painted straight over the green transplant bar.**
  The Calendar's chart splits each row into two lanes — sowing above, harvest
  and bloom below — with a comment saying it exists so "a cool-season crop's
  four windows pile up illegibly" cannot happen. The card had the same split
  hardcoded by kind and disagreed about exactly one entry: bloom went in with
  the transplants. Every flowering perennial in the catalog is affected —
  yarrow, echinacea, scarlet bee balm, wild bergamot, catnip, catmint,
  marshmallow, valerian, mullein, rugosa rose, anise hyssop, mountain mint —
  and on all of them one of the two bars simply vanished, on the card whose
  legend v6.1.2 rewrote so that every colour on it could be read. The card
  shares the chart's `LANE` map now, and draws one centred lane where a crop
  only needs one. Collisions across the catalog: 45 → 4, and the four left
  are indoor-against-sow, which collide on the chart too. One layout, one set
  of compromises.

### The pantry

- **The container the Pantry's own add form writes was invisible to the only
  join between the pantry and the rest of the app.** The form defaults to
  `jars`; the Seed & supply list's "N quart jars are currently full in your
  pantry — those come back into circulation as you eat them" counts `quarts`
  alone, and rightly, because a "jar" of unstated size is not a quart and
  costing it as one would be a guess. So a gardener who canned twenty quarts
  through the main way in got credit for none of them on the card that exists
  to stop them buying jars they already own. The container now follows the
  method — quarts for water-bath and pressure, jars otherwise — which is the
  rule `pantryOpen` has always applied coming in from "Into the pantry →" and
  this form did not. A hand-picked container is never overwritten.
- **"13 put up and not yet eaten, across 2 crops", on a shelf holding three.**
  The subtitle's crop count keyed a plain `{}` on the crop NAME, which is free
  text a gardener types: `a['__proto__'] = 1` adds no own key at all, so the
  entry vanished from the count while its jars stayed in the total. v6.1.4
  gave `pantryDiff`'s `byCrop` a null prototype for this exact reason and left
  the identical reducer three hundred lines below it alone.
- **Cold storage contradicted itself once it aged out.** Every other method in
  the shelf-life table has a real quality window; `coldstore`'s own line says
  the figure is not the thing to go by — so a five-month-old crate printed
  "Past its best window — Depends on the crop and the room more than on any
  figure." It reads "Long enough in storage to be worth going through" now.
  The months stay: they are a prompt to go and look, not a verdict.

### Two that fire twice

- **The decision helper rebuilt itself on every keystroke.** `paintPaths` tears
  down and rebuilds five cards, re-filters the recipe list, walks the installed
  packs and builds up to six `<details>` equipment blocks — and it was wired
  straight to `input`, so typing "12.5" ran all of that four times. Debounced
  at 120 ms, the way the Kitchen's search box has been, and the timer is
  cleared on close so a rebuild cannot land in a sheet that has gone.
- **Every share button ran its card again on a double-tap.** All five cards
  await — the canvas, and three of them one or more photos out of IndexedDB —
  so a render takes the better part of a second, and two taps put two identical
  PNGs in the downloads folder. `shareBtn` latches and disables while it works,
  which is the guard the Pantry's Add button got in v6.1.2.

### Housekeeping

- **Today walked the season twice per render.** The "Big weeks ahead" ribbon
  asked `putUpCrops` for the next 60 days and the Tending card asked
  `puttingUpTasks` for the same season a second time, each walking every bed,
  every planting and `projectedLb` per crop. It is computed once now and
  handed to both; `within` was only ever a filter on that list, so the ribbon
  applies it rather than buying a second walk. `puttingUpTasks` takes
  `opts.crops` for this and keeps its own default for every other caller.
- **The "Replace everything?" dialog counts the pantry.** It itemised beds,
  journal entries and photos so the gardener could tell the right file from
  the wrong one, and replaced the shelf in silence — the one collection in the
  app that cannot be re-derived from anything else.
- **Five exports with no reader are gone:** `RW.EQUIP`, `RW.SHELF`,
  `RW.PANTRY_INSPECT`, `RW.equipBlock` and `RW.TESTED_LINE`, none of them read
  by the app or by any of the eight suites. `RW.LID_LINE` stays, because the
  Seed & supply list genuinely prints it.

### Known, and deliberately unfixed

- **The year card still shows the garden in all three Calendar tabs.** In
  "Plantable in the next 6 weeks" and "Everything in my zone" the chart is
  zone-derived and is not this garden at all; the card is always the garden,
  because a shareable image of 446 crops nobody planted is not a thing anyone
  posts. The area mismatch was a card and a chart disagreeing about the same
  subject; this is the card declining a subject that has no card in it.
- **The Jars & lids count still reads `quarts` only.** Counting a `jar` as a
  quart would be the guess the whole card is written not to make. The fix
  belongs at the point of entry, and that is where it went.
- Everything recorded under this heading in v6.1.2, v6.1.3 and v6.1.4 stands:
  the annual-window-into-current-year date resolution, `emit()`'s unconditional
  "saved to your downloads" toast, share-card reflow for short content, the
  14-row year cap, `putUpCrops` taking the first bed's adjustment, and no "Put
  it up" button in the plant drawer.

## v6.1.4 — 10 August 2026

**A third sweep over the putting-up release, and the theme is a list with two
things in it that the code read as one.** v6.1.2 was a filter half a card
obeyed; v6.1.3 was a flag the app computed and never read. This one is the
same shape one step earlier: `windows()` hands back a *list* of harvest
windows, and `putUpCrops` took the first one it found and stopped looking.

**63 of the 446 crops carry two harvest windows.** Every cool-season crop in
the catalog gets "Harvest — spring sowing" and then "Harvest — fall sowing",
in that order — and the whole putting-up layer only ever saw the spring one.
Plant cabbage, broccoli and kale, ask the app on 10 August, and 240 lb of
brassicas arriving 25 October – 8 December produced **no "Jars & lids" card at
all**, a pantry-door calendar headed *every window has closed for this year*
with three rows marked "· closed", a season share card that toasted *nothing
in this garden has a glut window still ahead of it*, no "Big weeks ahead"
ribbon, and no "Plan a putting-up day" row — on the same screen that was
telling the gardener to sow the succession row that feeds it.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a probe driving the real build in Chromium:
each defect below was reproduced before the fix and asserted against after it.
The badge still reads v6.1; the hosted and download builds ship byte-identical.

### The season the app could not see

- **`putUpCrops` picked the first `harv` window rather than the nearest one.**
  It now ranks every harvest window a crop has by the same test `ahead` and
  `rank()` already use — open now, else soonest opening, else most recently
  closed — and takes the best. The window chosen, the tense written over it
  and the order it sorts in are now one decision instead of three. Cabbage on
  10 August moves from "closed, 11 weeks, ended 22 July" to "ahead, 6 weeks,
  25 Oct – 8 Dec", and all five season surfaces follow it.

### The four smaller ones behind it

- **The season-long readers ignored the global setup filter.** Today and the
  Seed & supply list have always handed `putUpCrops` their own filtered bed
  list; the printable calendar, the season share card, the glut week plan and
  the Kitchen's week-plan test handed it nothing and got the whole garden. So
  the calendar offered from Today's filtered Tending card listed the raised
  beds that same card was hiding. `putUpCrops` now defaults to the beds the
  setup filter is showing.
- **The share card printed "0 lb" as its hero number.** The journal form
  always writes an `amount` for a harvest — a blank box stores a hard `0` —
  and the timeline correctly hides its weight pill on `if (e.amount)`. The
  card tested `!= null`, so an entry showing no weight on the screen the Share
  button sits on came out as a 96px clay **0 lb** under a subtitle reading
  "0 lb · 10 August 2026". Same test as the timeline now.
- **The "Can it" card advertised the Preserving pack to people who own it.**
  Ten of the 41 crops the pack covers carry no jar route in it — cabbage,
  onion, garlic, leek, basil, parsley, dill, cilantro, melon and horseradish —
  and on every one of them the card closed with "The Preserving pack carries
  the prep and the safety notes crop by crop", which is the sentence for
  somebody who has not bought it. Three sentences now: the pack covers this
  crop and has no jar route for it, the pack does not cover this crop, or the
  pack is not installed.
- **A `nocan` crop lost its whole equipment list, not just the canning half.**
  The guard dropped every pack method the crop had, so broccoli — which this
  screen has just told you in red to freeze, and which the pack carries a full
  freezing method for — got no "What the day needs" card at all, while cabbage
  beside it got two. Only `waterbath` and `pressure` are dropped now.

### Housekeeping

- **The put-up-versus-eaten review walked `Object.prototype`.** `byCrop` was
  keyed on the crop NAME, which for a free-typed pantry entry is the only
  lookup table in this build fed straight from the keyboard. A jar labelled
  "constructor" kept Object's own constructor through `|| {}`, wrote `NaN`
  onto it, and printed a row reading *constructor · NaN · NaN · Plan for less,
  or eat more of it*. Null-prototype, like `tierOf` and `sanitizePack`.
- **A rejected photo write leaked its id into `pendingPhotos` forever**, where
  it counts against `PHOTO_CAP` for the rest of the session. `photos.put`
  resolves `false` for the failures it expects but can still reject —
  `db.transaction()` throws synchronously inside its promise executor once
  IndexedDB has closed — and both the pantry and the journal deleted the id on
  the happy path only.
- **The "N quart jars are currently full in your pantry" line lived inside
  `if (needQt)`.** A card whose every row read "not canned" or "no figure"
  printed no total and took that line down with it — the one number on the
  card that is not an estimate at all.
- **Batch counts are capped at a dozen.** A 0.5 lb recipe against 50–72 lb of
  kale printed "99–144 × Kale chips" in a column headed *Batches* on the sheet
  that goes on the pantry door, and "about 144 batches" in the helper. The
  arithmetic is right and the instruction is absurd; both now say "12+" and
  the helper names the size of the job instead of counting it out.
- `wrap()` on a share card checks its floor before the first line as well as
  between them, and `shareYear` builds its legend from the bars as it draws
  them rather than walking every row's windows a second time to ask what it
  had just painted.

### Known and deliberately unfixed

- The annual-window-into-current-year date resolution, `emit()`'s
  unconditional "saved to your downloads" toast, share-card reflow for short
  content, and the 14-row year cap — all as recorded in v6.1.2 and v6.1.3.
- `putUpCrops` still takes the bed adjustment of the first bed a crop appears
  in. A crop under glass in one bed and in the open in another is projected
  across both and dated from one; the alternative is a per-bed row for one
  crop, which is a worse answer on every screen that reads this.
- The plant drawer still has no "Put it up" button. Five other surfaces open
  the helper; the drawer is a reference screen and the harvest card directly
  above it in Today is the moment that question gets asked.

## v6.1.3 — 10 August 2026

**A second sweep over the putting-up release, and the theme is a flag the app
computed and never read.** v6.1.2 was about a filter half a card obeyed and a
season described in the wrong tense. This one is the same shape one layer
down: three of the four findings are a value that already existed, already
correct, sitting in the object every reader was handed — `nocan` on the jar
arithmetic, the bed list on the calendar's tending card, the window's own
closing date — while the surface above it went on stating something else. The
app was not missing the information. It was not asking.

The one that matters most is a safety contradiction. **The Seed & supply
list's "Jars & lids" card and the printable pantry-door calendar were both
handing out quart-jar counts for the five crops the app elsewhere says in red
must never be canned on their own.** Nothing throws, all of it passed 700
checks, and a gardener reading the shopping list would have bought 34 quart
jars for elderberries.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a probe driving the real build in Chromium:
each defect below was reproduced before the fix and asserted against after it.
The badge still reads v6.1; the hosted and download builds ship byte-identical.

### The shopping list costing jars for a crop that must not be canned

- **With elderberries coming in, the Seed & supply list printed "Elderberry ·
  30–90 lb · 34" under a red box about lids, and totalled "About 34 quart
  jars' worth across the season".** The printable putting-up calendar said
  "about 34 quarts" in a column headed *In jars*, and "about 23 quarts" for
  brussels sprouts. Open the same crop in the decision helper and it prints,
  in the warning band: *No safe home-canning process for this crop on its own
  — do not improvise one, and do not adapt a recipe from another vegetable.*
  The catalog says it. The Preserving pack says it. Two surfaces were selling
  jars for it. `jarsFor()` has returned `nocan` since the day it was written
  and **no caller had ever read it** — the pounds-per-quart figure exists for
  the harvest planner's "I want twenty quarts, how much do I plant?", and run
  backwards onto a shopping list it stops being a conversion and becomes an
  instruction. Both surfaces now print `not canned` where the flag is set,
  keep those crops out of the jar total, and carry the same sentence the
  helper does. The supply list's explanatory lines were split apart while I
  was there: they used to hang off the end of the total, so a table of nothing
  but refusals said "None of these crops has a published figure" — a sentence
  that would have been false the moment one of them was counted.

### A tending list that had not been told which garden

- **On the calendar, with the setup filter on "container", "Tending — the next
  six weeks" opened with "Broccoli — cover direct-sown brassicas" for a raised
  bed the app was hiding.** This is exactly the defect v6.1.2 fixed on Today,
  at the second site nobody looked at: Today filters its beds and hands the
  same list to both engines, and the calendar's copy of the card called
  `careTasks({horizon: 42})` and `puttingUpTasks({horizon: 42})` with no bed
  list at all, above a chart drawn from a bed list that *had* honoured the
  filter. It takes the filtered list now. The area filter rides along too, but
  only in the mode that draws an area control — a remembered area quietly
  narrowing a list on a screen with no visible way to widen it would be the
  same bug pointing the other way.

### A season that had been over for five months, still "coming"

- **On 10 August, a crop whose harvest window ran 10 February – 7 March was
  headlined "coming in quantity" on the share card, printed its February dates
  with no `· closed` beside them, counted itself in "still ahead" on the
  pantry-door sheet, and had jars bought for it on the supply list.** `ahead`
  was `gap >= 0`, and `gap` comes from `daysToWindow`, which wraps at ±180
  because it answers a different question — how far to the nearest edge of the
  next occurrence. A window that shut 156 days ago comes back as **+184**, and
  every one of the four sentences v6.1.2 was written to stop came back with
  it, at a boundary nobody had looked at. The flag is measured from the
  window's own close now: the crop is ahead if it is open today, or if its
  next opening is nearer than its last closing is far behind. The sort learned
  the same thing — the closed ones rank on how long ago they closed rather
  than on a negated gap, so a wrapped one can no longer sort in among the
  crops genuinely arriving.

### Two counters walking one list

- **The seven-day glut sheet could serve the same recipe twice and never reach
  the third.** v6.1.1 gave the rotation a counter per crop and per job, which
  was right; it keyed the counter on the job the day *asked* for. A crop whose
  recipes are all one category falls back to the other pool — broccoli has
  three fresh recipes and no preserve one — so that single pool got two
  independent counters walking it. The key is the pool actually used now,
  which is the same test the Job column prints, so the counter and the column
  cannot disagree about which list was read.

### Smaller

- The decision helper called `preserveWays()` twice per render, and it repaints
  on every keystroke in the amount box: typing "12" walked every installed pack
  and every method in it four times to answer the same question about the same
  crop. Once, at the top, like the projection above it.

### Known and deliberately unfixed

- **The season surfaces still resolve an annual window into the current
  calendar year** (`fmt` drops the year, so the month and day are right). With
  `ahead` now measured from the close, the only rows this can reach are ones
  genuinely arriving in the next few months, which is the case the shortened
  date reads correctly for.
- **The helper hides the Preserving pack's pickled route for a `nocan` crop.**
  The pack carries an honest "pickled per a tested recipe is the only safe jar"
  entry for broccoli and cauliflower, with its own red safety block, and the
  catalog drawer shows it; the helper's refusal branch fires first and shows
  only the red line. Conservative on purpose, and the catalog is one tap away.
- **Share cards still do not reflow for short content**, `emit()` still toasts
  unconditionally, and the year card's 14-row cap is still a cap — all measured
  again this pass, all still deliberate.

## v6.1.2 — 10 August 2026

**A review sweep over the putting-up release, and the theme is a filter that
only half the card obeyed.** v6.1.1 fixed a sort that ran the wrong way; this
one is about what got *written over the top* of that sort, and about the
second engine on a screen where only the first one had been told which garden
it was describing. A bed list passed to one function and not the one beside
it. A season list sorted correctly and then counted as though every crop in it
were still coming. A box holding one number under a caption quoting another.
None of it throws, all of it passed 700 checks, and every one of them is the
app stating something it has no basis for.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a probe driving the real build in Chromium that
rendered every share card to disk: each defect below was reproduced before the
fix and asserted against after it. The badge still reads v6.1; the hosted and
download builds ship byte-identical.

### The card said "coming" about a season that was over

- **On 10 August the share card read "Putting up 2026 · 5 crops coming in
  quantity" and three of them had finished in May, June and July.**
  `putUpCrops()` returns the whole year when no `within` is passed, which is
  exactly what the three season-long readers want and exactly what none of
  them then said out loud. v6.1.1 sorted the closed windows to the bottom and
  stopped there; the headline above them still counted all five as arriving,
  and **Jars & lids on the Seed & supply list asked a gardener to buy 58 quart
  jars for a tomato window that had closed on 21 July** — a shopping list
  costing a crop that was already eaten. Each row now carries `ahead`,
  computed beside the `gap` it reads, so the three surfaces cannot drift
  apart again: the share card takes only what is still coming, the printable
  pantry-door calendar keeps the whole year but marks a finished window
  `· closed` in the cell and counts how many are still ahead in its subtitle,
  and the jar arithmetic buys for the future only.

### Half a card obeying the filter

- **With the setup filter on "container", Today's Tending card listed
  "Cabbage — thin seedlings" for a raised bed the app was hiding.** The
  putting-up rows beside it correctly vanished, because they had been handed
  the filtered bed list since the day they existed — and the comment above
  them said that made the whole card honour the filter. `careTasks` was still
  walking `st.beds`. It takes a `beds` option now, the way `puttingUpTasks`
  always has, and Today passes the same list to both: two halves of one card
  had been describing two different gardens.

### A box and its own caption disagreeing

- **Opened from Today with a filter on, the helper's amount box said 150 lb
  and the line underneath it said "Pre-filled from this garden's projection —
  70–240 lb".** Today passes the pounds *its* filtered beds project; `paint()`
  ignored that and recomputed the projection over the whole garden to write
  the caption. The projection is now computed once, at the door, and the
  caption prints what was actually used — or says the number was carried over
  from the screen you came from, which is the truth when one was. It also
  stops a second whole-garden walk on every open.

### The pantry

- **Eating the last jar of a row threw focus, and the scroll with it, to the
  crop box at the top of the form.** The row disappears when the count hits
  zero — exactly as Remove does — and only Remove knew it: `repaint` hunted
  for a button on a row that no longer existed and fell through to its
  `nameIn` fallback. Ate one hands off to the neighbouring row now, and the
  fallback itself uses `preventScroll`, because it exists to keep focus off
  `<body>` and not to move the page.
- **An emptied shelf rendered the "On the shelf" card with its heading, its
  three buttons and nothing underneath**, over a subtitle reading "0 put up
  and not yet eaten, across 0 crops — oldest first, because that is the order
  to eat them in". The empty state was gated on every entry ever made and the
  list is built from what is left of them; those are the same array until
  somebody eats the last jar. Two empties, and they are not the same thing:
  never filled gets the explanation, emptied gets congratulated.
- **The share card walked storage order** while the shelf, the labels and the
  "eat these first" line are all oldest-first — so the card disagreed with the
  screen it was launched from, and the twelve-row cap kept an arbitrary twelve
  rather than the twelve the app everywhere else says matter. Sorted now, and
  the shelf shot leads with the *newest* photo rather than whichever entry
  happened to sort first, which after the sort would have been the oldest
  thing in the pantry.
- **The count column was laid out by measuring the numeral**, so every row
  past nine pushed its text 20px right of every row under it — a ragged edge
  down the middle of the app's most-postable image. Right-aligned in a column
  of its own now.
- **The add form was the only data-entry form in the app with no visible
  labels** — a box containing "1" beside a box containing a date, and nothing
  on screen to say what either was for. The aria-labels stay; the words are
  above the controls now, as they are in Settings, the journal and the bed
  editor.
- **The photo cap counted what had landed, not what was in flight.** The
  journal adds `pendingPhotos` to its own count for exactly this reason; a
  shot picked here while a journal entry's images were still being written
  measured a store already fuller than it said.
- **Add to the pantry had no double-submit guard.** With a photo attached it
  awaits twice before the entry is pushed, and a second press inside that
  window wrote a duplicate jar. The no-photo path never awaits, so the
  twenty-in-an-afternoon case is untouched.
- **A `plantId` that no longer resolves printed the literal word
  "Something"** on the shelf, the labels and the card. It falls through to the
  id now — not pretty, but the only thing left that says which jar it is.
- **"1 quarts · Tomato."** The shelf row, the "eat these first" line and the
  share card all print the count immediately in front of the container and
  none of them had a singular. This app counts "1 crop", "1 photo" and "1
  quart jar is currently full" correctly everywhere else; the pantry was the
  one collection that did not. The picker keeps the plural, because a picker
  names a container in general rather than one of them.

### Smaller

- The year card **drew bars in two colours it never keyed**: `indoor` in lilac
  and `bloom` in gold were missing from the legend, so every crop started
  indoors — most of them — carried an unexplained purple bar in February. The
  key is built from the kinds actually on the card, and wraps.
- Every Share button on the journal timeline **announced itself as "Share"**,
  so a screen reader offered one identical name per harvest. `shareBtn` takes
  an accessible name where the visible label cannot be unique.
- The four overlay guards each **hand-rolled the `#putup` selector** that
  89-putup exports `RW.putUpOpen()` for. Four copies of one test is four
  places to miss.
- The Jars & lids **"no published figure" sentence lived inside the branch
  that only runs when some other crop had one** — so a table consisting
  entirely of refusals carried no explanation at all, which is the case the
  sentence was written for.
- `glutWeekPlan` **shared two recipe counters across every crop on the sheet**,
  so with three crops in the rotation it skipped recipes they had and repeated
  ones they did not; the counters are per crop and per job now. A crop whose
  recipes are all herb or staple **silently dropped its day**, so a sheet
  headed "Seven days from …" came out with six rows and nothing to say which
  day was missing; it walks on to the next crop that can fill it.

### Known and deliberately unfixed

- **The share cards do not reflow for short content.** A two-crop putting-up
  card now leaves roughly 800px of blank paper above the wordmark, and the
  fix above makes short cards more common. Filling it is a typographic
  decision about a shipped artifact, not a defect, and it is not a review
  sweep's call to make.
- **`emit()` toasts "Card saved to your downloads" whether or not the anchor
  click was honoured.** There is no callback that says it was.
- **The printable calendar resolves an annual window into the current
  calendar year**, so a January window listed in November prints this year's
  dates. `fmt` drops the year, so the month and day are right and only the
  sheet's own heading disagrees.

## v6.1.1 — 7 August 2026

**A review sweep over v6.1's day-old putting-up code, and the theme is the
oldest one there is: a fixed number that used to be right.** A canvas 1350px
tall that nobody measured against. A sort that was ascending on a quantity
which turns negative. A head built once beside a filter that repaints. A
threshold copied into a second file by the comment promising it had not been.
An altitude stored without the unit it was typed in. All of it passed 700
checks, because every one of these produces output that is the right *shape*
— a card, a list, a button, a number — and simply says the wrong thing.

Verified by the eight suites (700 checks, 700 passed — 82, 60, 53, 87, 172,
49, 121, 76, unchanged) and by a twelve-point probe driving the real build in
Chromium: each defect below was reproduced before the fix and is asserted
against after it. The badge still reads v6.1; the hosted and download builds
ship byte-identical.

### The bed card had no bed on it

- **Every bed plan ever shared said "This bed could not be drawn."** `planSvg`
  writes `<svg viewBox=…>` with no `xmlns`, which is correct for what it was
  built for — the print sheet drops it into a document, where the HTML parser
  already knows what an `<svg>` tag means. The share card is the one consumer
  that does something else with it: a `data:image/svg+xml` URL handed to an
  `<img>`, which is parsed as **XML**, where a root element in no namespace is
  a parse error and nothing else. So `img.onerror` fired every single time, the
  catch printed its apology, and the flagship card of the release went out
  carrying a title, a plant list and a blank rectangle. The namespace is forced
  on in `svgImage` beside the width and height it already forces on, because
  that is the one call site that parses the string as XML.

### Sorted by a number that goes negative

- **In the first week of August, the putting-up sheet led with April.**
  `putUpCrops` sorts ascending on `gap`, and `daysToWindow` returns a
  *negative* number for a window that has closed. On today's date a
  sixty-crop garden ordered mizuna at −121, komatsuna at −121, bok choy at
  −111 — and put the one crop actually harvesting **twenty-third**. The three
  readers that ask for the whole season are all of them: the ✦ printable
  pantry-door calendar, the "Putting up 2026" share card, and the Jars & lids
  table on the Seed & supply list. The day-planning callers pass `within`,
  which drops closed windows entirely, which is exactly why nobody saw it —
  the option added to fix this at v6.1 was fixing it only for the callers that
  used it. Open now first, then soonest arriving, then recently-closed ahead
  of long-gone, then no window at all.

### A fixed canvas nobody measured against

- **Three of twelve pantry entries were drawn off the bottom of the image.**
  The shelf shot claimed 460px, the rows stepped 62px from wherever it left
  off, and neither knew the card is 1350px tall: with a photo the twelfth row
  landed at a baseline of **1510**, six draw calls entirely outside the image
  and four more across the wordmark. Same untested assumption, three more
  places: the bed card's eight plant rows ended at 1288 against a footer at
  1298; the harvest card wraps `e.text`, which is the one string in the app
  with **no length cap anywhere**, at 42px a line until it runs out of card;
  and the year card divided the leftover height by a fixed fourteen, which can
  squeeze a row to 10px under 25px type. Every list is now clipped against the
  footer, the photos yield to the text rather than the other way round, and
  what did not fit is counted honestly — *"and 5 more"* — through one shared
  helper, so the four cards cannot drift apart again.

### Built once, beside a control that repaints

- **The Kitchen's "Put up ‹crop›" button never appeared, and once it did it
  pointed at the wrong crop.** It is built into the page head at render time
  and gated on `K.crop`; the crop dropdown runs through `apply()`, which
  repaints the list and — deliberately, correctly, and documented three
  comments below — nothing else. So choosing tomato from the dropdown left the
  head empty, and arriving with a crop already set (from Today's harvest card,
  or a `#kitchen/tomato` bookmark) and then switching to basil left a button
  still reading *"Put up tomato"* and still opening tomato. The head is
  repainted from `markControls()` now, which also puts it after the
  stale-choice guard: a crop that no longer has recipes cannot leave a button
  offering to put it up.

### The one view whose main interaction is a repeated small edit

- **Ticking a jar off the shelf threw you back to the top of the page.**
  "Ate one", "Remove" and Add all ended in `RW.go('pantry')` — a whole-view
  rebuild that re-creates the 446-option crop datalist, re-reads every shelf
  photo out of IndexedDB, scrolls `#main` to 0 and moves focus to the `<h1>`.
  Measured: scroll 600 → 0, a half-typed crop name in the form above **wiped**,
  focus gone. A pantry is filled and emptied one jar at a time, so this is the
  view that could least afford it. The subtitle, the shelf and the all-time
  line repaint in place now; focus returns to the button that was pressed
  (`preventScroll`, for the reason `go()` uses it on the heading — the button
  is already under the reader's finger); and the add form clears crop, count,
  source and picture on a successful save while keeping method, unit and date,
  which are what a canning day repeats twenty times in an afternoon.

### Numbers that had lost the unit they were typed in

- **A garden at 5000 ft announced "You are at 5000 m."** The altitude field's
  placeholder switches between metres and feet with the units setting, and the
  value was stored as a bare number with no record of which one had been
  typed — so flipping the setting reinterpreted it, a threefold misstatement
  of the single variable the whole feature exists to name. The clamp gave it
  away: 20000 is a feet ceiling, and it was being applied to metres too.
  Stored in feet now and displayed in the live unit, exactly as pounds and
  lengths already work, through `RW.altToFt` / `altField` / `altText`. It is
  still never used to compute an adjustment, and never will be.
- **`GLUT_LB` was defined, exported, and ignored by the screen it was defined
  to agree with.** 89-putup's constant carries the comment *"same threshold as
  Today's glut warning, deliberately: a crop that warns there and is missing
  here would read as a bug"*, and Today kept its own literal `20` four
  thousand lines away. Today reads the constant.

### The pantry was an unmetered door into the photo store

- **A free-tier browser could store its 63rd photo through the Pantry.** The
  journal checks `PHOTO_CAP` before every add; the pantry's save path, whose
  own comment claimed *"the same cap-and-resize path the journal uses"*, did
  the resize half and none of the cap. Verified with 62 journal-referenced
  photos already stored: accepted, no warning, and the tier's own feature list
  promises sixty. It refuses now with the journal's wording — and still saves
  the **entry**, because the record is the point and a full photo store is no
  reason to lose what was put up. The picker carries the same slot count the
  journal's does.
- **And the photo-failure messages were never seen anyway.** `toast()` replaces
  the message and restarts its own timer, so both *"the photo could not be
  stored"* and *"that image could not be read"* were overwritten by *"Into the
  pantry."* microseconds later: the entry saved without its picture and the app
  said nothing. One toast at the end now, carrying whichever note applies.
- **A restored backup dropped pantry photos first.** The import's photo
  priority list was built from journal ids and then padded with whatever was
  left, so at the cap the shelf shots went regardless of how few journal photos
  the file held — the entry came back with its id and no image. Pantry ids join
  the list, in the same spirit as the boot prune, which learned about them
  during v6.1 for the same reason.

### Housekeeping

- **`putUpCrops` re-projected the crops it was about to reject.** `projectedLb`
  walks every bed and every planting in the garden, and the `seen` flag was set
  *after* the glut threshold — so the crops that fail it, which is most of
  them, were re-projected once per planting. An eight-bed, eighty-planting
  garden ran it **94 times for one Today render**. The flag moves above the
  test: the answer cannot change between two plantings of the same crop against
  the same bed list.
- `#putup` and `#drawer` shared `z-index:41` and the putting-up sheet won only
  because it happens to come later in the markup — the same tie `#toast` and
  `#locked` were on until v6.0.1. It has an explicit `42` now.
- The helper borrowed the plant drawer's focus-return slot. Nothing opens it
  from the drawer today, but the guard that anticipates one is already written
  two lines below, so the sheet has its own slot rather than a hazard waiting
  for the feature.
- Copy: *"No safe home-canning process for this crop plain"* → *"…for this crop
  on its own"*.
- `emit()` constructed a `File` unguarded, so a browser without the constructor
  lost the plain-download fallback as well as the share — the fallback taken
  away by the failure of the thing it was the fallback for.
- `go()`'s comment said "five of the thirteen views" for the second release
  running. The list is fourteen.

Nothing here touches the no-canning-figure rule, and suite 7's scan over the
helper, the equipment lists, the Today rows and the Pantry is unchanged and
green.

## v6.1 — 7 August 2026

**The putting-up release: the season between the harvest and the seed
catalog, finally on screen.** Bloomsteader walked the gardener from seed to
harvest better than anything at its price, and then went quiet at the exact
moment a food garden works hardest. August and September are the weeks of
jars, lids, blanch pots and freezer Tetris, and until now the app had nothing
to say about any of them: the harvest planner spoke in quart jars going *in*
to a plan, and nothing spoke in quart jars coming *out*.

This is `PUTTING-UP-PLAN.md` implemented in full — all four numbered items and
all four LATER ones — plus the share cards from `UI-RECOMMENDATIONS.md` items
8 and 10, which came along because the pantry photos depended on them.
Verified by eight suites: **700 checks, 700 passed** (82, 60, 53, 87, 172, 49,
121, 76), up from 651. The hosted and download builds ship byte-identical.

**Almost none of this is a new engine.** The yields already projected pounds;
every glut-crop recipe already carried the pounds one batch eats, authored at
v5.1 for exactly this and never read until now; the care engine already dated
checkable rows onto Today; the Preserving pack already held the method text.
The work was wiring between things that already existed, which is why a
release this size added one view and one overlay.

### The line that shapes every screen here

**This app states no canning process figure — no time, no pressure, no
headspace — and v6.1 does not soften that by a word.** It would have been easy
to: the moment you build a canning-day planner, printing "process 35 minutes"
looks like the helpful thing to do. It is not. The moment the app states a
time it owns the altitude table, the jar-size matrix, the errata and the
liability, and its whole differentiator dies. Every hobby-canning forum is a
graveyard of invented process times; the app that *refuses* to state them,
names NCHFP/USDA/Ball on every entry, and still plans the day better than
anyone else is the one an extension agent would recommend.

So every canning surface prints the same sentence — *times, pressures and
headspace come from your tested source, for your jar size and your altitude* —
which is also, not coincidentally, the single most important habit a home
canner can be taught. The `CANNING_FIGURE` scan that has guarded pack prose
since v5 now guards the app's own copy too, and suite 7 checks the helper, the
equipment lists, the Today rows and the Pantry for digit-bearing process text.

### What landed

- **The Kitchen knows about the glut now.** The Harvesting-now card carries a
  Kitchen link beside its storage line, and the glut warning finally got its
  second half: *"roughly 40 lb of tomatoes is on the way"* is a problem
  statement; *"the Kitchen has 9 ways through it"* is the app being on the
  gardener's side. ✦ adds batches scaled to the projection and a printable
  seven-day sheet that alternates eating it now with putting it up — because a
  week of nothing but canning is how you end up with forty quarts of something
  nobody wanted to eat fresh even once.
- **"Put it up" — the decision helper.** The question at the counter is not
  "show me recipes", it is *"I have twelve pounds of green beans; now what?"*
  One screen answers it, crop first, in honest order: eat it fresh (with the
  storage clock attached, so "do nothing yet" has a deadline), cure and store,
  freeze/dry/ferment/fridge with the recipes scaled against the pounds you
  typed, and then the jars. **The triage order is the teaching**, which is why
  the beginner needs no special casing. It is an overlay, not a view — a
  question is not a place — hash-addressable so Back closes it, and every
  overlay guard in the app knows it exists.
- **The canning row tells the truth in all three states.** With the pack: the
  prep prose, the red safety note, the named tested source. Without it: the
  path is still named, because pretending an option does not exist in order to
  sell a pack is a lie. And for the five crops with no tested home-canning
  process at all, a red refusal that says *do not improvise, and do not adapt
  another vegetable's recipe* — that is the branch where people get hurt.
- **The canning day, planned in jars, lids and Saturdays.** The researched
  pounds-per-quart the harvest planner uses, run backwards: this pile is about
  that many jars — and a refusal where no figure is published, because
  inventing one for the 65 crops that lack it would undo the reason to trust
  it for the 278 that have it. Jars and lids join the Seed & supply list, dated
  putting-up rows join Today through the care engine's own contract (Done, 7d
  and Not mine work on them unchanged), and a method-aware equipment checklist
  carries the two lines that matter: **new lids every time**, and **have a dial
  gauge tested — extension services do it free**.
- **The Pantry, the fourteenth view.** The seed drawer for the other end of the
  year. Crop, method, count and container, the date, a photo, and — for the two
  canning methods — the tested source, because a jar's provenance *is* its
  safety record. Honest shelf life, with the distinction almost nobody makes:
  for home-canned goods the clock measures **quality, not safety**. A jar that
  sealed and stayed sealed does not become dangerous on its birthday, and
  saying it does teaches people to trust a date instead of their eyes and nose
  — exactly backwards, because the date cannot detect a failed seal. Free, per
  the house rule that the record is never paywalled; ✦ holds the labels and the
  put-up-versus-eaten review that quietly corrects next year's targets.
- **Share cards.** The bed plan, the year at a glance, a journal harvest, the
  pantry shelf and the putting-up season, rendered to PNG on canvas —
  offline, no font fetch, no pixel. Free at every tier, because paywalling
  your own advertising is a strange way to sell software. The footer is the
  wordmark alone: these images outlive the release, and a URL that changes
  turns every card ever posted into a broken link.
- **The four LATER items:** a putting-up strip in the Kitchen's Preserving
  chapter, an optional altitude on the garden profile, a glut-horizon ribbon
  on Today, and pantry photos. Altitude is shown back on canning rows and
  **never used to compute an adjustment** — teaching the variable without
  touching the figure is this app's posture in miniature. An altitude
  calculator would be the same mistake as printing a process time.

### Three things the tests found

- **The putting-up row expired while the crop was still coming in.** A flat
  three-week duration meant the reminder vanished about a week into the harvest
  window — precisely when the gardener is standing in the pile it was warning
  them about. It now stays due for the whole window, with the three weeks as a
  floor.
- **And it disappeared entirely once the season had passed.** A harvest window
  is annual, but `fromDoy` resolves it into one calendar year, so anchoring on
  the current year produced no row at all for a crop whose season had finished
  — invisible in August, but it silently emptied the calendar's six-week list
  in November. It now falls through to the next occurrence.
- **`putUpCrops` was planning Saturdays around crops that finished in July.**
  The season calendar wants the whole year; anything planning a *day* has to
  drop the closed windows. That is what the `within` option is for, and the
  week plan and the Kitchen button both use it.

### Housekeeping

- Badge, service worker and `package.json` to **v6.1** / `bloomsteader-v6.1.0`.
  suite 5's badge literal and suite 8's brand check move with it, on purpose.
- **Sidebar 13 → 14, More sheet 8 → 9.** Pinned in suites 5 and 6; both updated
  deliberately, which is what those pins are for.
- The Preserving pack gained an `equip` field and went to **version 2**, in all
  three places a pack lives: the unsigned payload, the baked `rw-packs` block
  Full buyers actually read, and `sanitizePack`'s whitelist — a field the
  schema does not name is a field the pack loses silently. The equipment lines
  go through the same canning-figure scan as the prose. **The copy in
  `packs-unsigned/` must be re-signed before it is sold again**; the baked copy
  needs no signature and already carries it.
- Because an imported pack beats the baked copy *whatever its version* — which
  is deliberate, so a buyer sent a corrected file gets the file they were sent
  — a Full buyer holding an old import would silently miss the new content.
  Rather than change a rule that exists for a good reason, the Packs view now
  says so and offers to fall back to the built-in copy.
- The boot photo prune only knew about the journal, so it would have deleted
  every pantry photo 2.5 seconds after boot. It knows about the pantry now.

## v6.0.2 — 5 August 2026

**A second sweep over v6, and this one looks the other way down the seam.**
v6.0.1 was about everything the app failed to tell the gate; the theme here
is the reverse and the older kind: whitelists that do not whitelist, a global
control two views disagree about, and three claims — in a comment, a test
name and a changelog — that were true of one call site and asserted of all of
them. Every one of these was green through all 642 checks. Verified in-browser
over HTTP at 1280 and 375px with the service worker and caches cleared before
every reload, and by eight suites: 651 checks, 651 passed. The hosted and
download builds ship byte-identical.

The two whitelists nobody had got to yet were the two fed by the address bar:

- **`#toString` blanked the app; `#constructor` opened Settings under an
  error.** `parseHash` tested a view id with `if (!renderers[id])` — a bare
  bracket lookup on a plain object literal, so every name on
  `Object.prototype` came back as an inherited function and cleared the guard.
  `SUB[h.id].set(h.sub)` then threw, because `Object.prototype.set` is not a
  function. From a hashchange that was an uncaught TypeError with the URL left
  disagreeing with the screen; from a reload or a bookmark it reached
  `paintFirstView`'s catch, so a mistyped address opened Settings under
  *"Bloomsteader could not draw that view"* — the sentence reserved for a
  renderer that actually failed. This is the eighth and ninth prototype-walking
  lookup found in this build (after `tierOf`, `PACK_TYPES`, `SHOP_VENDORS`, the
  cart and the rest), and the only two that took their key from the user.
  `hasView` and `subOf` are own-key checks now, and `go()` uses the first, so
  no id can talk it into rendering into a host that is null.

A paid app said no to somebody who had paid:

- **A verified key that could not be *written* kept the gate shut.** "The key
  verified but could not be stored — browser storage is unavailable." So a
  browser with localStorage switched off — Safari's *block all cookies*, a
  managed or locked-down profile — took the purchase and then refused to open
  the app, over a setting this app already knows how to run under: `read()`
  latches such a session read-only and the savebar states it plainly
  (*"Nothing will be remembered between launches — export before you close the
  tab"*), and everything works from there. The branch immediately above it
  reaches the opposite conclusion from exactly the same reasoning, about a
  browser with no `crypto.subtle`: a limitation the buyer did not cause and
  cannot fix must not cost them the app. It opens now, for the session, and
  says what will not survive it — that the key has to be pasted again next
  launch, and that nothing else will be remembered either. The gate's warning
  space is unchanged: this is news about the session, not about the licence,
  so it goes to the toast layer, which since v6.0.1 outranks the gate.

One global control, three scopes:

- **Today went on telling you to water a bed the app had just hidden.** The
  setup filter in the top bar sits on every view and toasts *"Filtered to
  container"* when you set it. The Beds view honours it, and 90-list's own
  header comment records this being fixed for the Seed & supply list — *"it
  shopped for beds the app said it was hiding"*. Today's "Your beds" card was
  built from `S().beds` unfiltered, so it kept the hidden beds' watering rows,
  and the harvest rows underneath came off the same list. Area chips still read
  from the whole garden: an area is a garden-level idea, and dropping the chip
  you are standing on is worse than leaving it.
- **And the Beds badge counted findings you could not reach.** It read every
  bed regardless of the filter, so a filtered garden could carry a red count
  with nothing on the page behind it. It counts the beds the Beds view will
  show — which is the view it opens — and the filter buttons rebuild the nav
  now, as the zone select two cards down has done since the badge existed.

Three claims that were true once each:

- **The ✦ had a tooltip, not a name.** v6.0.1 gave every marker a `title` and
  said in this file that it gave them an accessible name too. A `title` is a
  description; the name of a button that has text comes from its text — so all
  five were still announced as the bare glyph. Only the seed drawer's
  germination button carried a real label, and its wording is the one the new
  `RW.liteLabel()` uses everywhere.
- **Three buttons reading "Buy", on the one screen where choosing between them
  is the whole task.** The tier name sat in a sibling `div` that nothing
  associated with the control, so a screen reader's button list was three
  identical entries. The visible word is still *Buy*; the accessible name
  carries the tier and its price, the way every other control in this app is
  labelled.
- **`RW.countProblems` was defined twice**, identically but for the memo, and
  50-beds parses after 20-checks — so the first copy never ran. Dead code, and
  the dangerous kind: the day module order moved, the badge would quietly go
  back to a full `checkBed` per bed on every nav rebuild.

And three smaller things the gate had not been told about either:

- **Export from the gate was fire-and-forget.** `exportData` is async and the
  handler ignored its promise, so a rejection settled into nothing and the
  button appeared to do nothing at all — on the screen whose first rule is
  that it never traps data. It reports now, and says the garden is still safe
  in the browser.
- **Printing the licence screen produced a blank sheet.** `#locked` was in the
  print stylesheet's blanket hide list, and because `boot()` returns before
  `bootShell()` while the gate is up, there is nothing behind it. It is also
  the only screen carrying the three prices and the safety notice, which is
  what someone printing it wants. The gate prints; its controls carry
  `.noprint`. Suite 8's check that asserted the old behaviour has been
  rewritten to assert this one.
- **No script, no page.** The whole app is one `<script>`, so scripting
  switched off, an extension blocking inline script, or a truncated download
  gave a blank white document — no heading, no error, and no safety notice
  either. There is a `<noscript>` now: what happened, what to do, that nothing
  is sent anywhere regardless, and the notice, since a blank page carries none.
  It hides `#app`, whose 100vh grid would otherwise push the explanation under
  the fold, which is still a blank page.

Also fixed: `closeModal` restored focus behind a `document.contains()` test
that `restoreDrawerFocus` twelve lines below documents as insufficient — it is
true of a `display:none` node, so a dialog button whose action navigated left
the guard passing and `.focus()` doing nothing, undoing the heading `go()` had
just focused. The drawer was fixed for this; the modal was not. Latent today,
because every current path detaches the node first.

Deliberately not changed: the gate's ordering — the paste box sits below the
three priced tiers, which puts a returning buyer's only task about 690px down
a 375×812 phone — and the treatment of an activation failure, which prints as
a hint rather than in the warning band. Both are product decisions about what
the first screen says, not defects, and the warning band stays where it is.

sw `bloomsteader-v6.0.2`, package 6.0.2, badge still `v6.0` — a patch release
must not touch it, and suite 5 pins the literal.

## v6.0.1 — 5 August 2026

**A review sweep over the licence gate v6.0 shipped that morning.** The gate
is the newest and highest layer in the app, and the finding that ties almost
all of this together is that nothing else in the build was told it existed.
Every one of these was green through all 625 checks. Verified in-browser over
HTTP at 375 and 1280px with the service worker and caches cleared before every
reload, and by eight suites: 642 checks, 642 passed. The hosted and download
builds ship byte-identical.

The gate could not speak:

- **Every message the gate raised was painted underneath it.** `#toast` and
  `#locked` were both `z-index:95` — a tie, and `#locked` comes later in the
  markup, so `#locked` won. Three real messages died there, and the worst was
  the quietest: `openBuy`'s popup-blocker fallback. A buyer whose blocker ate
  the Buy tab clicked the button and saw *nothing happen at all*, on the one
  screen where the only thing to do is buy. The toast is 97 now, above the
  gate as it was already above the welcome screen.
- **A key that failed verification explained itself into that void, and the
  explanation was wrong anyway.** "Re-enter it in Settings" — from a screen
  whose entire purpose is that Settings is not reachable. The reason now
  prints on the gate itself, in a warning band under the lead, and says to
  paste the key in the box that is right there. The band is empty and hidden
  on an ordinary first run: someone who has simply not entered a key yet is
  not being told off.

The gate was invisible to every overlay guard in the app:

- **Back, a bookmark or a restored session navigated the app behind it.**
  `hashchange` tests `#firstrun` by hand and had never heard of `#locked`, so
  a hash change while locked ran `go()` on the hidden app — rendering the
  view, retitling the document, and, the real damage, landing focus on the
  hidden view's `<h1>`, which is outside `#locked` and therefore outside its
  Tab trap. The keyboard was then loose in an app the user had not paid for
  and could not see. The gate now outranks even a modal in that chain; the
  hash it was asked for is remembered and honoured the moment a key opens it.
- **The `/` shortcut did the same thing**, from a Buy button — a `<button>`,
  which its "not in a text field" test happily allows.
- **The iOS file:// warning opened behind the gate.** That guard waits for
  `#firstrun` with a comment explaining that a dialog behind an overlay is
  invisible and steals the keyboard. v6.0 added a higher overlay with exactly
  that property. On the one platform the warning exists for, an unlicensed
  launch raised it 1.2 seconds in, unseeable, with Tab trapped inside it.

The gate had no safety notice, and left none behind it:

- **The first screen every buyer now sees carried no warning of any kind.**
  The welcome screen has the full "Before you start" block and `#safety`
  repeats it on every view for the rest of the session — but v6.0 put a
  screen in front of the welcome, and because `boot()` returns before
  `bootShell()` while the gate is up, the strip is not merely scrolled past,
  it is never built. The gate carries the block now, with the complete notice
  a tap away in a `<details>` — not a modal, because `.modal` is z-index 60
  and would have opened underneath the screen that raised it, which is the
  same tie that was swallowing the toasts. The disclosure line is padded to
  24px: it is the only route from this screen to the full notice, and v5.2.1
  fixed a 19px target on the safety strip for the same reason.

Two things behind the gate that were simply wrong:

- **The licence check repainted a shell that did not exist, over a garden
  that had not been loaded.** 75-pro runs at parse time and `crypto.subtle`
  usually settles inside the same tick, so on a served page the whole check
  finished *before* `boot()` — which is where `RW.load()` lives. A key that
  failed verification therefore rebuilt the nav, the top bar and the entire
  current view from the default garden: the Today heading read "Your garden"
  over a garden with a name, and the units button read Imperial over a metric
  one. Then `boot()` took the locked branch and returned without
  `bootShell()`, so `#brand` and the safety strip were never built and none
  of it was ever corrected. The pack overlay still runs — that is model state
  — but the chrome waits for `RW.shellReady()`. `boot()` and `afterUnlock`
  both own that paint, and both run after the garden is loaded.
- **The tier whitelist did not whitelist.** `tierOf` looked its value up with
  a bare bracket, so a payload carrying `p:'constructor'` came back as
  `'constructor'` and became the tier: `RW.licensed` went true so the gate
  never showed, `TIERS[tier]` resolved to `Object` so Settings read
  "**undefined** is active", and the upgrade ladder compared numbers against a
  function and came out empty — a licensed session with no tier and no way
  up. It takes the private signing key to mint, so it was never a way in; it
  is simply the eighth own-key check in these two modules and the only one
  that had been left off.

Copy that had stopped being true:

- **The landing page still said v5.0**, in the header badge and the footer,
  two releases after it was.
- **The storefront sold two content packs that do not exist.** The packs
  column listed "zone-specific week-by-week calendar packs" and a "dye, fiber
  & craft yield expansion" under a heading reading "the same five packs" —
  and `packs-unsigned/` holds five payloads, neither of them those. The app's
  own Packs empty state opened with the same two, then closed with "all five
  are already in this app", leaving a reader counting seven things and unable
  to tell which two they were not getting. Both surfaces name the five that
  exist; the app still *supports* those pack types, it just does not sell
  them, and the checklist now says so.
- **The founder price was struck through the wrong number.** `<s>$14.99</s>
  after launch` — a strikethrough marks the price that is *no longer*
  charged, so the device meant to make the founder window honest was
  announcing that the higher price had been cancelled. It reads "goes to
  **$14.99** after launch" now, which is what the app has always said.
- **The landing page told iPhone users something the app was corrected not to
  say.** "It must be Safari — Chrome on iOS cannot do this" — v5.2.1 rewrote
  the in-app iOS nudge specifically to stop naming Safari, because every
  browser on the phone is the same engine underneath and naming one points a
  user at a browser they are not using. The two surfaces agree again.

And the small ones:

- **The ✦ had no key outside Settings.** It marks a Lite feature on Today,
  the Calendar, the Journal, the Seed drawer and the Kitchen, and the only
  words explaining it were one line in the Settings theme card. One helper
  owns them now, and every marker carries them as its tooltip and its
  accessible name.
- **The gate opened the phone keyboard over its own price list.** It focuses
  the first button instead on a coarse pointer, which keeps the Tab trap and
  raises nothing.
- `#locked`'s phone block was missing `screen and`, the one qualifier this
  stylesheet has a paragraph of comment about; and the nav's comments still
  described nine views, which stopped being true at v5.1.
- **Suite 8 grew from 38 checks to 55**, over the gate as a layer: what it
  paints, what it refuses to paint behind itself, what it says when a key
  fails, what it will not let a hash change do to focus, and the safety
  notice it now carries.

## v6.0 — 5 August 2026

**Bloomsteader is a paid app now, in three tiers.** The free planner with a
$14.99 Pro unlock is gone; what replaces it is a ladder you buy once and own,
with founder pricing that names its own end date:

| Tier | Founder | After launch |
|---|---|---|
| **Bloomsteader** | $9.99 | $14.99 |
| **Bloomsteader Lite** | $19.99 | $24.99 |
| **Bloomsteader Full** | $29.99 | $34.99 |

Base is the planner, the whole 446-plant catalog and every safety warning.
Lite is what Pro was — themes, unlimited photos, dated succession schedules,
plan-into-bed planting, germination tests, the reorder list, the Year in
review and the premium PDF exports. Full is Lite plus all five content packs,
unlocked in place. The packs are still sold on their own at $4.99–7.99, and
the all-packs bundle is still $24.99, for anyone below Full.

Verified in-browser over HTTP at 375 and 1280px with the service worker and
caches cleared before every reload — the gate, all three tiers, the Settings
licence card, Full's five packs and the four-column landing grid — and by
eight suites: 625 checks, 625 passed. Two layout fixes came out of that pass:
the landing grid was leaving the fourth card alone on a stretched second row
(now an explicit 4 → 2 → 1), and on a phone the gate's `width:100%` Buy
button was being shrunk by its own baseline flex line, breaking "$14.99 after
launch" across three lines beside it (the button now claims a whole row).
The hosted and download builds ship byte-identical.

The licence, rewritten:

- **The key carries a tier, not a boolean.** `p` is now `base` / `lite` /
  `full`, and `RW.tier` is the one source of truth. `RW.pro` survives as a
  *derived* alias meaning "lite or better", which is why all eleven
  `requirePro` gates, every ✦ marker and the photo cap needed no logic
  changes at all — there is no second flag to drift out of step with the
  first. `RW.atLeast('lite')` is there for anything that wants to ask
  directly.
- **Keys issued before today are honoured as Lite.** They carry `p:'pro'`,
  and Lite is precisely the feature set they were sold for. One line, one
  place, and suite 8 pins that it stays one place.
- **The app opens on a licence gate.** No key, no garden: paste a key, or buy
  one of three tiers with both prices on screen. It is a boot-time screen and
  nothing else — no timer, no mid-session re-prompt, and a verified key never
  sees it.
- **The gate never traps a garden.** If there is a saved garden in the
  browser, "Export my garden file" sits on the lock screen, works with no key
  entered, and always will. A lost key must never cost anyone a season of
  records. (With nothing saved, the offer is not shown — an Export button with
  nothing behind it is a broken promise, not a kindness.) Removing a key from
  Settings says plainly that the app will lock, and that Export is there.
- **A browser that cannot verify no longer loses the app.** Over plain http
  `crypto.subtle` does not exist, and before v6 that cost a Pro customer their
  themes for the session; now it would have cost them everything, over a
  limitation they did not cause and cannot fix. A well-formed stored key is
  taken at its word there, once, with the situation stated. Verification was
  never DRM — the file is MIT and a fork can strip it — it is there to make an
  honest key work everywhere.

Full, and the packs it includes:

- **The five packs ship inside the build.** ~174 KB of payload as an
  `rw-packs` JSON block, switched on by a Full key, so a Full buyer has
  nothing to download, import, re-import on a new device, or lose. They go
  through the same `sanitizePack` validator an imported file does — one
  validator, no second trust path.
- **Included packs never touch the buyer's pack storage.** Readers see a
  merged list; every write path (`import`, `remove`) works on the stored list
  alone, so removing an imported pack cannot silently persist the baked five
  into `localStorage`, and an imported file always wins over the baked copy of
  the same id — a buyer sent a correction should get the correction.
- **The Packs view offers the right verb.** Included packs carry an "Included
  with Full" badge where the others carry Remove; there is no removing
  something that came with the app. The empty state names Full and its price
  once, under the explanation, and never for someone who already bought it.

The ask, and the storefront moments:

- **The upgrade modal sells Lite and states Full beside it.** A Lite buyer who
  actually wanted the packs should learn that before paying twice.
- **Settings has a real licence section.** Your tier, who it is licensed to,
  the ladder *upwards only* — a Full customer is never sold anything — and a
  key box that takes an upgrade key over the old one. Upgrading is a key,
  never a new app: nothing is re-downloaded and the garden does not move.
- **"Pro" is retired as a user-facing word.** The pills, the ✦ hints, the
  photo-cap toast, the print footers and the theme note all name the tier that
  actually opens the thing. The internal identifiers (`RW.pro`,
  `requirePro`, `proSettingsCard`) keep their names — renaming ~30 call sites
  would have been the riskiest diff in this release for zero user-visible
  gain.

Around the app:

- **The landing page is a four-column grid** — the three tiers plus packs —
  with every founder price shown against what it becomes. `BUY_URL` split into
  three placeholders, so the checklist now counts 18 in the app and 5 on the
  landing page.
- **`keygen.html` mints by tier.** A selector writes `p:'base'|'lite'|'full'`;
  there is no separate upgrade product to build, because a higher-tier key
  pasted over the old one *is* the upgrade.
- **README, LAUNCH-CHECKLIST and REVENUE-PLAN say what is true now.** The
  revenue plan keeps its sequencing argument and is marked superseded on
  price. The MIT licence and the public source stay exactly as they were: you
  can still read every line of what you bought.

The tests:

- **Suite 8 is new: 38 checks over the licence** — the gate and what it does
  and does not withhold, each tier's surfaces, the pre-v6 key's promotion,
  Full's five packs and the promise that they never leak into pack storage,
  the downgrade reverting the care engine and the troubles table, forged and
  malformed keys, and the source-level guarantees the checklist counts.
- **All eight suites now seed a licence, and none is committed.**
  `tests/licence.js` mints a key at run time from the private JWK in the
  seller folder, or takes one from an environment variable. A key committed to
  this repo would open the app for everyone. Suites 1–7 seed **base**, which
  is the exact state they have always run in: app open, `RW.pro` false, no
  packs — which is why their 587 checks needed no assertion changes beyond the
  two version pins (the badge, now v6.0, and the service worker, now v6).

## v5.2.1 — 5 August 2026

**A review sweep over everything v5.2 shipped the day before.** Fifteen
findings, all of them in code that is one release old; the suites were green
through every one of them, which is the point of reading the diff as well as
running it. Verified in-browser over HTTP at 375, 960 and 1280px with the
service worker and caches cleared before every reload: boots clean, 446
plants, empty console, all twelve views render, 587 checks still pass, and the
hosted and download builds ship byte-identical.

The install offer, which was the release:

- **The banner's Install button went dead the moment you declined.** A
  declined prompt cleared `deferredInstall` and then *re-enabled* the button
  over it, so the next tap hit the `if (!ev) return` guard and did nothing at
  all — no prompt, no toast, no explanation — while `refreshTop` had just
  removed the top-bar button that still worked. The top-bar button was written
  with a comment promising it is "never a button that does nothing"; the
  banner it grew into forgot. Every path now ends the same way: the prompt is
  single-use, so once it is spent the banner retires. Not dismissed, though —
  the user declined the browser's prompt, not this offer, so a later
  `beforeinstallprompt` is still allowed to ask.
- **One tick on the sample garden counted as a real investment.** The whole
  "wait for something to lose" gate rested on `investedInGarden()` filtering
  journal entries by an `smp-` id — but ticking a care task goes through
  `addEntry()`, which mints a fresh `uid()` every time. So the entry's own id
  could never tell demo work from real work, and one tap on a sample bed's
  care task opened the install pitch over four rows of data that another tap
  deletes on purpose. That is exactly the confusion v5.2 (#4) removed from the
  backup nudge, through a key nobody carried across. It now tests the *bed*
  the entry hangs off, which is the thing that made it sample work.
- **"Clear it" left the tick behind, permanently.** Same root, worse ending:
  the three seeded entries carry `smp-` ids and went, but anything logged
  *against* the sample survived — pointing at a bed that no longer existed.
  From then on `gardenHasContent()` read a garden with real work in it for
  good: the welcome screen would hide its sample button forever,
  `buildSampleGarden()` would refuse forever, the install nudge kept firing
  over nothing, and the Journal rendered an entry attached to a deleted bed.
  The clear now sweeps by bed as well as by id.
- **The install offer is only built when it could win.** It is outranked by
  every backup branch except day-one `never`, and it was being constructed —
  UA sniff, `matchMedia`, full DOM — on every Today render regardless, for a
  node dropped on the next line.
- **The iOS banner stopped naming the wrong browser.** The user-agent test
  matches Chrome and Firefox on iPhone too — both carry "iPhone", both are
  WebKit underneath, both evict on the same schedule — and the copy told them
  about Safari. "iOS clears…", and "find the Share button", which is true
  wherever their browser keeps it.

The empty-garden hero card:

- **It had no read-only guard, and it led the page in the one session that
  cannot save.** Both banners above it bail on `RW.readOnly()`. A `corrupt`
  tab loaded nothing, so `beds.length` is 0 and the card rendered as the most
  prominent thing on Today — in the session where the user's real garden is
  sitting unread in storage. The flow ran to the end and toasted "Bed
  created." over a `localStorage` that never took it. The savebar was saying
  the opposite four inches up.

The phone strip and the safety line:

- **The scroll fade masked the top bar's own background and bottom border.** A
  mask applies to an element's whole rendering, not just its children, and
  `#top` paints a surface and a hairline. The border dissolved over the last
  28px and popped back at scroll-end — the most visible thing the fade did was
  the thing it was not meant to touch. The hairline moves to `#main` at the
  widths where the mask exists, so there is nothing there to dissolve, and one
  rule still draws it.
- **The fade was missing across 901–1023px.** It shipped in the 900px block
  while the scroll rules it depends on live in the 1023px block — the block a
  previous release widened precisely because "960 is what a half-screen window
  is on a 1920 display." So across that whole band the strip scrolled with its
  scrollbar hidden and no fade at all, which is the failure the fade exists to
  fix. `paintTopFade()` was setting `.can-scroll` there the entire time;
  nothing matched it.
- **The folded strip's only way back was a 19px target.** `min-height:0`
  defeated the 38px floor every other button in the phone UI gets, leaving the
  sole route back to the safety notice — the notice the app tells you to read
  before you eat, brew or handle anything — as the smallest tap target it
  owns, sitting directly on the 52px nav tabs, where a low tap changed view
  instead. Now 24px, the WCAG 2.5.8 minimum, paid for out of the strip's own
  padding and with no overhang in either direction. The fold saves ~2px now
  rather than ~5px, which costs nothing that was ever the point: v5.2 said it
  plainly, the win is the words going quiet.
- **The folded ⚠ was 3px off centre** — `buildSafety`'s leading icon span
  survives `.min` as a zero-width flex item and the 8px gap was still being
  applied behind it.
- **The fold controls announce their state.** `aria-expanded` on both, kept in
  step with the class, including on the session restore. Without it a screen
  reader met two unrelated buttons that swap places, with nothing saying the
  notice was a thing currently collapsed.

The toast:

- **It has always landed inside the safety strip on desktop.** v5.2 measured
  the phone bands carefully and moved the pill to 88px there; every width above
  900px stayed on a 22px floor under a strip that measures ~26.5px. 36px.
- **It was transitioning `all`.** The bare `.24s` resolved to every animatable
  property, and once v5.2 made `bottom` depend on a media query, a rotation
  across 900px slid the pill vertically for a quarter second. The show
  animation was only ever transform and opacity; now that is what it says.

Two smaller things:

- `firstRun()` hid the sample button with no matching show. Nothing reaches it
  today — the function is called once at boot — but `RW.firstRun` is exported,
  and the first caller to reopen the welcome over a garden since emptied would
  have found the button hidden for good.
- The `.frlead` comment argued carefully about "14px, not the 16px this
  replaced" and the two pixels it buys back, over a declaration that says
  15.5px.

Known and deliberately unfixed:

- The scroll fade still fades the top bar's own background over its last 28px.
  `--surface` against `--paper` is `#FFFEFB` against `#FBF7F0` in light and
  `#20241A` against `#191C13` in dark — under a gradient, across 28px, at the
  edge of the screen, it is not a difference anyone will find. Removing it
  properly means an inner scrolling wrapper inside `#top` and a `buildTop()`
  that appends into it, which is a structural change to a load-bearing element
  for an invisible one. The border, which *was* visible, is fixed above.

## v5.2.0 — 5 August 2026

**The growth release: the app learns to ask for the install — politely, and
only after it has earned it.** Every install this app will ever get comes from
three moments the UI controls: the first sixty seconds, the install path
itself, and what a user shows another gardener. v5.2 works the first two —
items 1–7 of UI-RECOMMENDATIONS.md; the share-card engine (items 8–12) is the
next release's job. Verified in-browser over HTTP at 375px and desktop widths
with the service worker and caches cleared before every reload: boots clean,
446 plants, empty console, and all seven suites pass. The hosted and download
builds ship byte-identical again (suite 5's `fc` check had gone red
mid-development while the changes below sat in `app/index.html` alone).

The first sixty seconds:

- **The welcome card leads with the promise, not the warning.** The yellow
  toxicity block — unchanged in wording, still on the first screen, still
  fully visible without scrolling on desktop — moves below the buttons, where
  it reads as terms rather than as the first thing the product says about
  itself. "Two questions and you are in" is now the bold lead.
- **"Poke around a sample garden first" is a real secondary button** —
  leaf-washed, directly under Start — instead of a tertiary text link styled a
  step below Skip. The demo is the best conversion device the app owns; it no
  longer dresses like an afterthought.
- **An empty garden gets one next step.** A hero card at the top of Today —
  "Make your first bed" — leads straight into the New-bed flow and removes
  itself the moment a bed exists. Beds, not plantings: an empty bed already
  turns the checks on, and one decision is a better ask than two.
- **The backup nudge learned what the sample garden is.** It now counts only
  work the user actually made (the same `smp-` prefix the sample's own clear
  button filters on), so the first minute of a first session is no longer two
  stacked blue banners — one of them offering to back up four rows of demo
  data that a tap deletes on purpose.

The install path:

- **iOS Safari finally hears about Add to Home Screen.** An iPhone visiting
  the hosted app got no guidance, ever — `beforeinstallprompt` does not exist
  there, and the old modal only fired from `file://`. Now: hosted, not
  standalone, and only once there is something to lose (a bed of your own, or
  a care task you ticked), one dismissible banner explains Safari's
  quiet seven-day storage eviction and where the control lives.
- **Android and desktop get the same better-timed door.** The stashed install
  event that only ever drew a passive top-bar button now also surfaces an
  inline offer at the moment of investment; the top-bar button stays as the
  persistent fallback. At most one banner speaks per Today visit — on day one
  the install offer outranks a never-backed-up nudge, because on iOS it is
  the cure for the very risk that nudge names; a stale backup outranks
  everything.
- Install dismissals live in `localStorage` (`rootwork.installNudge`), not in
  exported state: a garden imported onto a new phone arrives un-dismissed, on
  the one device most worth asking.

The polish sweep:

- **The version badge tells the truth** — v5.2, after reading v5.0 through
  two releases. The suite-5 check that should have caught it was matching
  `/v2\.0/` against the whole file and passing off comments; it now pins the
  literal badge markup.
- **The phone top strip signals its overflow.** "In-ground" clipped mid-word
  with the scrollbar hidden and nothing saying there was more; a right-edge
  fade now sits over the cut while more remains and lifts at scroll-end. The
  repaint is called directly rather than through `requestAnimationFrame`,
  which never fires in a hidden tab — a background boot would have had no
  fade until the first scroll.
- **Toasts clear the phone's bottom bands.** The 72px offset cleared the tab
  bar but landed the pill exactly on the safety strip (measured at 375px: bar
  59px, strip 27px); 88px clears both with air to spare.
- **The safety strip can fold to its ⚠ for the session** — a phone-only
  chevron collapses the strip to a slim full-width row; tapping the ⚠ brings
  the words back. `sessionStorage`, not state: the fold is a this-phone,
  this-sitting choice, a new session says the notice again, and the first-run
  screen carries the full warning regardless. Desktop is unchanged.

Known and deliberately unfixed:

- An empty garden's Today shows both the new hero card up top and the older
  "Start with a bed" card after the plantable list. Bookends, not a bug: the
  second card catches the reader who scrolled past the first, and no test or
  user journey is harmed by the redundancy.
- The folded safety strip saves only ~5px of height. A shorter row was
  possible only by shrinking the tap target or overhanging it into the tab
  bar, where it would eat taps meant for the tab beneath — the win here is
  the words going quiet, not the pixels.

## v5.1.1 — 5 August 2026

**Code-review sweep over the Kitchen: eleven fixes from a senior review of
everything v5.1 added.** Verified in-browser over HTTP after the change (the
`file://` tab lies about edits, and a stale service-worker cache lies twice):
boots clean, 446 plants, 44 recipes, thirteen views render with an empty
console, and every fix below was reproduced broken and then reproduced fixed.

The two that a user would actually have hit:

- **"All 4 in the Kitchen" opened on "No recipe matches."** The filter state
  survives view switches — right for the sidebar — but the catalog strip only
  set the crop, so a search term or a category chip left over from the last
  visit came along and could reject every recipe the button had just promised.
  Reproduced with `sauerkraut` in the box: the Tomato drawer offered four
  recipes and delivered zero. Entering the Kitchen *from* a crop is a fresh
  question, so `RW.openKitchen()` clears what the caller did not ask for; the
  Packs card's "Open the Kitchen" goes through it too.
- **The crop dropdown never wrote the hash.** "The crop filter rides the hash"
  was true only of the programmatic path the strip uses — choosing a crop from
  the dropdown left the address saying `#kitchen`, so reload, bookmark and Back
  all disagreed with the screen, and the next category-chip click retroactively
  rewrote the URL to a crop chosen minutes earlier. The suite tested past this
  by setting the crop through `setKitchenCrop`; it drives the control now.
  `RW.syncHash()` is the shell's new one-liner for a view that changes its own
  addressable state in place, with go()'s same no-op-on-identical rule.

Safety schema:

- **The recipe canning scan was strictly weaker than the `preserve-guide` scan
  its comment claims to mirror**, and three canning instructions walked
  through: *"boil the sealed jars 25 minutes in a water-bath canner"* (method
  named after the time — the rule only looked backward), *"hold 10 lb of
  pressure for 20 minutes"* (preserve-guide has a separate no-prefix rule for
  a pounds figure; folding it into the prefix one lost it), and *"hold at 11
  pounds until the gauge steadies"*. Both context rules read both directions
  now, and a pounds reading needs a pressure word within a clause on either
  side — a bare "4 lb of tomatoes" is an ingredient, and dropping the recipes
  that say so would be its own bug. Ordinary kitchen prose is explicitly
  spared: a blanch and a cup of tea both use boiling water, so plain "boiling
  water" is not a canning context. All 90 shipped recipes (44 baked, 46 packed)
  still pass, checked before and after. The seller tool carries the identical
  regex, as it must.

Consistency inside the filter bar — three controls that behaved three ways:

- **The category chips rebuilt the whole view** (`RW.go('kitchen')`) purely
  because `paint()` could not restyle them, which scrolled to the top and
  yanked focus back to the `<h1>` mid-typing. All three controls run through
  one `apply()` now: repaint the list, re-mark the controls, sync the hash.
- **The chips carry `aria-pressed`**, as the top bar's setup segment does, and
  **the crop dropdown marks itself** with the leaf border every catalog
  dropdown gets — `sel()` lives inside 40-catalog's closure, so it is done by
  hand rather than left undone.
- **The search is debounced** at 140 ms with the same outlives-navigation guard
  the catalog's paint carries. Not a speed problem at 90 recipes; it stops a
  render per keystroke and the guard stops one firing into an abandoned view.
- **A crop link opens the plant.** It was seeding `catFilters.q` and navigating
  to the catalog — which left every *other* catalog filter applied (a `use`
  filter set earlier turned a crop link into an empty catalog) and, even
  unfiltered, landed on a result list ("Cabbage" matches two plants) instead of
  the plant. `RW.openPlant` is what the app's ten other crop links do.

Counting, plumbing and copy:

- **The pack count is taken from the recipes that survived the merge**, not the
  raw baked array: a base entry `all()` rejected was still counted, so the pack
  under-reported by exactly that many, and enough of them printed a negative.
- **A crop filter orphaned by a removed pack clears itself** instead of
  rejecting every card while the dropdown reads "All crops" — the same
  stale-choice guard the catalog's group select carries.
- **One print-window implementation, not two.** The recipe card carried a
  near-verbatim copy of 75-pro's `printDoc` because 75-pro loads after it;
  `RW.printDoc` is exported and resolved at click time instead.
- **The Pro ask lists recipe cards** among what Pro adds. It interpolated
  "Recipe cards is part of Bloomsteader Pro" into the headline and then omitted
  them from the list directly underneath.
- **The landing page knows v5.1 shipped**: "Twelve views" → thirteen (the same
  stale count v4.2.1 fixed once), a Kitchen card in the feature grid, the
  Kitchen in the free tier, Recipes in the content-pack list, and recipe cards
  in the Pro tier.

Tests: suite 7's section K now drives the dropdown and the chips as controls
rather than through the sub-state setters, awaits the new debounce, pins the
strip's promise against what it shows, pins the crop link as a drawer opener,
and pins both directions of the canning scan. Its print-button selector moved
to `.btn.ghost` — the crop links are `.linkish` buttons now, and the old
`.recipecard button` would have grabbed the first one. The pack block gains a
pack-only crop to exercise the orphan guard.

Service worker VERSION → `bloomsteader-v5.1.1`. Both copies re-synced and
verified byte-identical (SHA-256 + `fc /b`).

Known and deliberately unfixed: the search term and the category chip stay out
of the hash (only the crop is addressable, as only `q` is on the catalog); the
Kitchen's filters still survive an ordinary sidebar visit, which is the
catalog's behaviour and the reason the strip needed its own entry point rather
than a change of rule; and every recipe still renders its full ingredients and
method into a collapsed `<details>`, which is 90 cards of DOM at worst and not
yet worth virtualising.

## v5.1.0 — 5 August 2026

**The kitchen release: recipes for the harvest the app already predicted.**
The app walked a gardener from seed through storage and preserving methods —
and then handed them forty pounds of tomatoes and no dinner. v5.1 adds the
last yard of that road: a **Kitchen** view of recipes keyed to catalog crops,
a free layer baked into the build, a new purchasable **Recipes pack**, and a
light Pro touch. Verified in-browser after the change: boots clean from
`file://`, 446 plants, thirteen views render, no console errors; suites 5–7
pass (suite 7 grew a fourteen-check Kitchen section).

The free layer, in the build:

- **44 recipes across the twenty most-grown food crops** — glut-first (the
  zucchini avalanche, the tomato pile, the cabbage that became sauerkraut),
  2–3 per crop, in a new `recipes` table in the baked data. Every recipe
  carries its category (fresh / preserve / herb / staple), a season, a time,
  a serves line, and — the quiet investment — a **per-pound scaling basis**
  (`scale.lb`: pounds of primary crop one batch consumes), which is the hook
  the planned harvest-aware phase (glut suggestions on Today, Pro scaled
  batch math) hangs from without a schema change.
- **The Kitchen view** — thirteenth in the sidebar, eighth on the phone More
  sheet: search across titles and ingredients, a crop filter (primary crop
  only — a recipe that uses two cloves of garlic is not a garlic recipe), and
  category chips. The crop filter rides the hash (`#kitchen/tomato`), so a
  strip link is a bookmarkable address, same as the catalog's search.
- **"In the kitchen" strips on catalog pages** — up to three recipes on the
  crop's drawer, fresh before preserves, with the rest one tap away on the
  pre-filtered Kitchen.
- **The safety rules are schema, not promises.** Recipe methods are
  `freeze | dry | ferment | fridge` — there is no canning option at all, in
  either tier; shelf-stable canning stays entirely in the Preserving pack's
  pointer-only regime. Ferments and refrigerator pickles state their salt,
  their submersion, their refrigerator and their discard rule. Every recipe
  on a plant with a `warn` entry repeats the relevant edible-part line
  (tomato: fruit only; rhubarb: stalks only; tomatillo: ripe husked fruit
  only). Garlic-in-oil and vegetables-in-oil carry the botulism line
  (refrigerated, days not weeks, never room temperature).

The Recipes pack (`recipe-book`, payload in `packs-unsigned/`):

- **46 more recipes plus three technique essays** — deeper coverage of the
  free crops, the herb & pantry chapter (compound butter, herbes salées,
  drying done right, herb salt, chive-blossom vinegar, zhoug, salsa verde,
  sage brown butter, lavender sugar, herb-oil cubes), and the homestead
  staples (scrap-bag stock, the roast-and-blend soup formula, the frittata
  formula, minestrone, the any-vegetable galette, a real ratatouille, root
  mash, pico by ratio, the grain bowl).
- **The sanitizer enforces the editorial line** exactly as `preserve-guide`'s
  does: no canning method exists in the whitelist, and a canning-figure scan
  (PSI, pressure-pounds, headspace, process times in a canner context) drops
  the recipe outright — "simmer 35 minutes" passes, "process 35 minutes in a
  water bath" does not, even in a signed pack. Pack recipes merge
  **fill-only by id**: a pack can never rewrite a recipe the app ships with,
  and removal reverts the Kitchen without a reload, the same restore
  discipline the yield and troubles packs follow.
- The Packs view renders an installed recipe pack grouped by category with
  the red no-process-times rule printed on the card; the seller tool knows
  the ninth type and validates it with the app's exact sanitizer.

Pro:

- **Printable recipe cards** — the `printDoc` pattern, one recipe per sheet
  in the app's own print styling, ✦-marked and gated through the same
  requirePro ask as every other Pro convenience.

Tests:

- Suite 5's sidebar and More-sheet pins updated (thirteen views, eight sheet
  items); its scrim-dismiss tap now aims at the part of the dim area the
  taller sheet leaves dim — the gesture under test is unchanged.
- Suite 6's sidebar pin updated; its pack-removal check now dispatches the
  storage event it was implicitly relying on, pinning the app's contract
  (react to the event) rather than a Chromium file:// mood — the original
  form failed against the *pristine* v5.0 build on current Chromium.
- Suite 7 gains section K: the Kitchen renders its baked count, search and
  crop filters narrow, the filter rides the hash and rejects nonsense, every
  baked preserve names a non-canning method, no baked recipe states a
  canning figure, the tomato strip lands on the pre-filtered Kitchen, the
  recipe-book pack merges fill-only / renders / reverts, and the ✦ gate
  holds. 79 checks, up from 65.


## v5.0.1 — 5 August 2026

**The review sweep for v5: 52 fixes from a fourteen-part senior review of the
whole surface.** v5.0.0 shipped sixteen features at once, and it shows in one
consistent way — the hardening passes of v4.2.x were not carried into the new
code, and several of the new features were wired to data or to UI that did not
actually connect. Verified in-browser after the change: boots clean from a
served origin, 446 plants, 122 of them warning-carrying, all twelve views
render, no console errors.

Prototype-name holes, reopened by the new code and now closed everywhere:

- **A single imported planting could mute a bed's every crowding warning.**
  `byId` was a plain object and `plant()` returned on truthiness, so
  `"plantId":"constructor"` resolved to `Object.prototype.constructor` — a
  truthy function that survived the orphan check, rendered as a plant named
  "Object", and made `bedFill` NaN. Null-prototype map, own-property lookup,
  and normalizeState now rejects the three dangerous names on both custom
  plant ids and plantings.
- **The three new v5 catalog lookups crashed the plant drawer.**
  `DATA.troubles[p.id]` and its siblings for `storage` and `pheno` walked
  `Object.prototype`: with id `constructor`, `tr5.length` was 1 and
  `tr5.forEach` undefined, so `openPlant` threw before the drawer opened and
  tapping the card did nothing at all. Own-property checks, matching what
  `PACK_TYPES` and the cart already did.
- **A harvest labelled `__proto__` polluted every object in the app.** The
  journal's totals accumulator was a plain object keyed by free text, so one
  entry set `Object.prototype.lb` for the session — corrupting Year in review
  — while its own row silently never appeared. Both accumulators, and the
  per-unit map inside them, are null-prototype now.
- The Shop's `vendorOf` walked the prototype for a pack-supplied vendor name,
  and the cart's `__proto__` write hit a setter that silently discarded it, so
  "Add to cart" toasted success forever without adding anything.

The new shading check was giving inverted advice, from two compounding faults:

- **Nine of the 33 mature-height keys matched no plant.** They were written as
  slash-names in the wrong order or spaced (`'artichoke'` for
  `globe artichoke`, `'sorghum'` for `grain sorghum` and `sweet sorghum`,
  `'jerusalem artichoke / sunchoke'` for `sunchoke/jerusalem artichoke`,
  `'broom corn'`, `'kiwi / hardy kiwi'`), so an 8 ft sunchoke measured zero and
  the check advised swapping the tallest crop in the bed *into* the shade. The
  four keys for crops the catalog does not carry are gone rather than left to
  look covered.
- **`/Part sun/` matched `'Full–Part sun'` by substring**, so the SUNRANK 2.5
  tier was unreachable and 121 of 446 plants — 27% of the catalog, including
  essentially every soft fruit — were silently classed as cool-season crops
  that benefit from shade. A shaded blueberry now warns; lettuce, which really
  is a cool crop, still gets the "use that" note.

The care-task engine — the release's headline feature:

- **It ignored the planting's own recorded planted date.** Adding a tomato on
  the day you set it out put "Transplant tomatoes deep · now" on Today for the
  next seventeen days. A recorded date is a fact and a window is an estimate;
  the fact wins.
- **Perennial jobs vanished after year one.** 80 of the 154 templates are
  planted-anchored and resolved only in the year the planting was created, so
  "Pinch mint-family herbs" fired once in 2026 and never again. Perennials now
  recur on the anniversary; annuals keep the single-shot behaviour.
- **`once` meant "the first matching window", not "once ever"**, so "Don't cut
  asparagus the first 2–3 years" returned every spring for a decade-old crown.
- **A task's identity moved when its window count changed.** Adding a cold
  frame re-keyed the task, so a job already marked Done reappeared and an
  active snooze was lost. The key no longer depends on how many sibling
  windows happened to match.
- **"Not for this garden" had no way in.** `RW.careOff` existed from the start
  with no call site anywhere, so a gardener who direct-sows everything got
  "Start hardening off tomatoes" with nothing but a snooze that re-fires
  inside the task's own duration. There is now a control, and it writes a
  year-less stem so the answer holds next spring too.
- **Forty-five lettuces made forty-five identical rows**, filled the Tending
  card, pushed every other crop off it and asked for forty-five Done taps.
  Collapsed on bed + crop + job + date, the way Harvesting-now already did.
- Perennial harvest windows ignored the v5 microclimate and protection shift,
  so a sheltered bed's planting bars and its tending list disagreed by up to
  five weeks; and garlic, having no `sow` window, never showed the two tasks
  anchored to one.

Data safety and recovery:

- **Corrupt storage permanently blocked Import — the one recovery the app
  itself kept recommending.** The savebar, the Settings data card and the
  corrupt-boot redirect all pointed at Import, which then refused and said to
  reload; reloading re-read the same corrupt blob. There was no in-app escape.
  A confirmed replace now clears the latch (only for 'corrupt', never for
  'stale' or 'blocked').
- **A full quota on Firefox was reported as "this browser is blocking local
  storage — use Chrome or Edge"**, advice that loses the session, because
  `ok()` tested only the error name and the Settings branch order hid the real
  case. Both paths now share one predicate covering code 22, code 1014 and
  both error names.
- **The sample garden landed on a loaded garden.** The guard counted only
  beds, so a plot tracked purely through the Journal was offered a sample
  garden that unshifted three fabricated entries — including a harvest that
  then counted in the real totals — and a 40 lb goal into the real plan.
- **A future-dated watering froze the clock for years**: one mistyped year
  made a bed read "last watered in 104 weeks", style itself green and never be
  due again. The input is capped at today and the reader ignores the future.
- The backup ledger stopped nudging the tab that held the only good copy,
  survived Start Over into a brand-new garden, never dismissed itself after an
  export, never fired at all for a garden with no beds, and missed installed
  iPads entirely.

Paid content, and the safety claim the app makes in its own voice:

- **Two-thirds of the Pests & Disease pack never rendered.** 25 of the 36 crop
  entries in the shipped payload are family-scoped, and the troubles fill
  handled only plant-scoped ones — so Solanaceae-wide entries like
  Verticillium wilt never reached the Tomato page, though the Packs view
  listed them and the same pack's care tasks honoured family scope.
- **"This pack states no process times, ever" was asserted but not enforced.**
  The only canning rule was that a source exists; nothing scanned the text,
  and the safety line was optional. The sanitizer now rejects a water-bath or
  pressure entry stating a time, PSI or headspace figure and requires the
  safety line. All four shipped payloads validate unchanged — 35 of 35 canning
  entries already name a tested source and carry safety text.
- `int()` failed open, returning its low bound on garbage: `"zoneMin":"6b"`
  became zone 1, showing zone-4 overwintering advice to a zone-3 gardener, and
  a pasted Unicode minus turned a +21-day task into −400. Bad values are
  dropped now, not silently rewritten.
- The seller tool's Validate compared only top-level array counts, so nested
  drops and string truncations both reported "all kept" — it now walks the
  structure and names the field, and its copy of the sanitizer is byte-identical
  to the app's again (verified by hash, as the docs claim).

Consistency between what one screen says and what the next one says:

- The glut warning on Today ignored the container factor the harvest planner
  applies, so a potted Meyer lemon read "roughly 175 lb on the way" against
  the planner's 26 — and a date palm, which the planner refuses outright,
  promised 250 lb.
- Year in review reported the *current* year although it is offered November
  through February, so all of January and February produced an empty document
  with no way to reach the season that had just ended; and it compared against
  the plan only for entries logged in pounds, which a metric user can never
  do — their biggest crop sorted to the bottom of the table.
- The seed-starting calendar printed the same four dates on every row.
  Peppers and okra want a materially earlier start than basil, and now say so.
- The harvest CSV was the last export ignoring the metric setting; the picker
  quoted un-derated yields directly under the banner saying yields are scaled
  down for pots; and "you are about 0 m² short" could appear beside two
  identical figures.
- The Seed drawer recorded a permanent 0% germination test from an untouched
  form (`+''` is 0), branding fresh seed "old" forever with no way to delete
  the entry — and the obvious recovery, logging a real test the same day, was
  itself discarded by a stable sort. A future pack year is refused rather than
  clamped, the reorder list judges viability at the *sowing* year, and "the
  drawer covers the plan" is no longer claimed over an empty drawer.
- Deleting a custom plant left its seed packets behind as a row reading
  "Unknown"; amendments saved against no bed and so never entered the feeding
  ledger their own form promised.

Interface, accessibility and cost:

- The season-protection dropdown carried an inline 11.5px that beat the 16px
  iOS floor — and because the app is a fixed-height grid with `overflow:hidden`,
  a zoomed page could not be scrolled back. It was also a 24px target, and it
  dropped keyboard focus on every change.
- Dark theme rendered white on clay at 3.53:1 — including the "your work is
  not being saved" strip the CSS's own comment says must never be missable.
  A separate token now carries the text-bearing clay at 4.94:1 while the
  lightened clay stays for borders and accents.
- A backgrounded phone call during a drag stranded the gesture: the half-drag
  coordinates were committed and every later arrow-nudge left the checks panel
  stale for the rest of the session.
- Bed-planner keyboard walking announced every step twice, and a pending
  settle timer yanked focus back to the previously nudged plant; the compass
  read "not set" for a real north of 0 and never updated its label; the cart
  modal let focus escape behind the dialog, and Escape closed the whole cart
  from the empty-cart confirmation.
- The catalog's second filter row lost its divider on every desktop (v5 put
  the Filters button between the two rows, so `+` stopped matching), the phone
  Filters count never updated, and the desktop active-filter border was wrong
  in both directions.
- Thumbnails and printed plans drew every gridline unthinned, so a 100 ft bed
  rendered as a solid grey wash.
- **The file is 235 KB smaller.** `companions` was a 229 KB long-form
  duplicate of the `comp` prose already baked onto every plant, and nothing
  read it; `companionGroups`, `conflicts` and `rotationBeds` were likewise
  never referenced. 2.12 MB → 1.99 MB, in the download and in every
  service-worker cache generation.
- `byPlant()` re-parsed the whole installed-pack store from localStorage once
  per row; the bed suggester scored per planting rather than per distinct
  plant, which both skewed its rankings and cost ~54,000 regex passes on a
  large bed; `subHeads()` re-split a 134 KB guide body on every keystroke.

Corrections to this changelog's own record: v4.2.2 justified a meta-description
change with "351 of 446 plants carry warnings" — the real figure is **122**,
which is what the landing page and README have always said. v5.0.0's care-task
note claimed "all fourteen families"; the shipped table covers **ten**.

Service worker VERSION → `bloomsteader-v5.0.1`. Both copies re-synced and
verified byte-identical (SHA-256). The seven Playwright suites now default to
`download/bloomsteader.html` resolved relative to the suite file, instead of a
sandbox path that exists in no checkout; they remain unrun on this machine,
which has no Node.

## v5.0.0 — 5 August 2026

**The tending release: all sixteen items on the v4.2.2 roadmap, in one version.**
The assessment behind that roadmap said it plainly: Bloomsteader was superb at
deciding and placing, and thin on tending. v5 is the answer. Verified against
the full regression battery — all six existing suites (500 checks) now pass,
plus a new 65-check suite for everything below.

### The care-task engine (#1) — free
The jobs between sowing and harvest, derived from what the app already knows.
Task templates for the ~35 most-grown crops (hand-authored), all fourteen
families, and generic ladders for every transplant crop, direct-sown crop and
perennial — thin the carrots at two inches, pot up at first true leaves,
harden off over the 7–10 days before the transplant date the app already
computes, stake before fruit set, hill the potatoes twice, net the brassicas
before the moth flight. Each is a dated row on Today ("Tending") with a
one-line why; **Done** writes a journal entry (new kind `task`) so the record
builds itself; snooze and "not for this garden" live in a new `taskState`
store. The calendar carries a six-week lookahead. Real planted dates beat
estimates, exactly as Harvesting-now already worked.

### Seed inventory (#2) — free core, Pro depth
A twelfth view: the **Seed drawer**. Record what you own — crop, variety,
year packed, roughly how much is left — and the Seed & supply list starts
saying "in the drawer" instead of telling you to buy it (screen, CSV and text
export all agree, because they share one aggregation). Every packet shows
honest odds from extension seed-life figures: parsnip and onion fade in a
year; tomato and the brassicas hold four or more. **Pro:** germination-test
logging (ten seeds on a damp towel) that overrides the table with *your*
measured rate plus "sow thicker" arithmetic, and the end-of-season **reorder
list** — the plan diffed against the drawer, skipping packets that are low or
past their odds, linked to the Shop's seed shelf.

### Pests & disease (#3) — free strips, pack depth
"What's eating this?" gets an answer at the moment of anxiety. Fifty crops
carry a **Common troubles** strip on their catalog page: the 3–5 likeliest
culprits, what the damage looks like in plain words, and the least-toxic
response first — exclude, hand-pick, trap, then (and only then) a named
least-toxic product, with the bee and butterfly cautions in the same red the
safety notes use. The deep version is the **Pests & Disease pack**: a
symptom-first diagnostic key, 24 prevention tasks that ride the care engine
(row cover goes on *before* the moth flight), beneficial-insect and trap-crop
tables.

### An example garden on first run (#4) — free
"Look around a sample garden first" on the welcome card: one raised bed built
through the same constructors real gardens use, with a deliberate rotation
conflict and a root-depth note so Checks has something to say, three journal
entries and a harvest target. Clearly announced on Today, one tap to clear,
and it refuses outright to land on top of a loaded garden.

### Catalog filters and plant-first rows (#5) — free
On a phone the catalog's second filter row — nine dropdowns — folds behind
one **Filters** button with an active count; search and section stay visible.
And Today's rows now lead with the plant ("Bachelor's button — sow now for
fall"), because the plant is what the eye scans for.

### Per-bed soil (#6) — free core, pack depth
Beds can now carry texture, drainage, a tested pH, and "new bed this year".
The checks sharpen accordingly: blueberries in pH 7.5 is a red stop with the
iron-lockout explanation, not a shrug; slow drainage flags the Mediterranean
crops; and a new feeding check notices heavy feeders two years running with
no amendment logged between. **Amendment** is a first-class journal kind
(compost, lime, sulfur, manure, fertilizer, mulch — with a rough quantity),
so each bed accumulates a feeding ledger next to its rotation history. The
**Soil & Compost pack** adds compost mathematics, cover crops by problem, and
amendment rates the bed editor scales to each bed's own square footage.

### Season extension (#7) — free
An optional per-bed **microclimate** nudge (south wall +2 weeks … frost
pocket −2), and per-planting **protection** (row cover +1 week, low tunnel
+2, cold frame +3 — published margins, stated as "about"). Both shift every
window that planting owns — the same frost arithmetic, honestly extended. The
**Season Extension pack** carries the build guides and per-crop overwintering
notes, zone-gated on each catalog page.

### Seed-starting workflow (#8) — free core, Pro polish
The ladder between the start-indoors bar and the transplant date ships as
care tasks (pot up → harden off with a day-by-day exposure schedule →
transplant → water in), and the supply list now counts **seed-starting
cells** (25% spare) with a 72-cell-tray conversion. **Pro:** the dated,
printable **seed-starting calendar** for the whole plan — the February
document for the light shelf.

### Harvest to kitchen (#9) — free lines, pack depth
Food crops carry a storage line ("cure 10 days warm, then months cool and
dry") on the catalog page and on Harvesting-now cards, and Today warns about
gluts the plan's own math implies ("roughly 40 lb of tomato is on the way"),
using the same researched yields as the harvest planner. The **Preserving
pack** adds method-by-crop guidance under a hard safety rule enforced by the
import sanitizer itself: water-bath and pressure entries must name a tested
source (NCHFP/USDA), and no process time appears anywhere, ever.

### Backup nudges (#10) — free
The app now notices when the record is worth protecting: first bed ever
saved, 25 journal entries since the last export, or six months quiet — one
line on Today, one tap to export, dismissible, never a modal. Bookkeeping
lives in its own storage key so an export never dirties the garden it just
exported. Thresholds halve on installed iOS copies, where eviction is real.

### Sun geometry (#11) — free
A compass button on the bed plan (one tap per quarter turn; the arrow rides
the on-screen plan, the thumbnails and the printed sheet), mature heights for
~35 tall crops, and a shading check: "Sweet corn will shade the lettuce
behind it by midsummer — or use that: cool crops bolt later in afternoon
shade." A warning only when a heat-loving fruiting crop sits in the shadow.

### Year in review (#12) — Pro
The January screen: harvest by crop against the plan's target (beat it, or
what percent), pickings and season span per crop, the year's tending and
watering tallies, and a "carry this forward" note that saves into the
journal. Nothing is invented: it reads only what was logged. Offered from the
Journal header and from Today all winter.

### Garden areas (#13) — free
A lightweight area tag on beds ("Front yard", "Allotment"), a grouped bed
list, chips on Today and an area filter on the calendar's planted view.
Deliberately not multi-garden files: same garden, same storage keys.

### Rain logging (#14) — free
"It rained" — one tap on Today, or with a rough amount from the Journal. It
resets the watering clocks for uncovered beds; a bed whose plantings all sit
under solid covers is honestly excluded, and a trace under 0.3 in is kept in
the record without pretending it was a watering.

### Phenology notes (#15) — free
Fifteen crops carry the old signals on their season windows — sow peas when
the forsythia blooms, corn at oak-leaf squirrel's-ear — as calibration
wisdom: the old signals read the actual spring, which the zone average
cannot.

### Keyboard and screen-reader pass on the bed planner (#16) — free
The plan is now **one tab stop**, not forty: roving tabindex across markers,
`[` and `]` (or Home/End) walk the plants, arrows still nudge, and a polite
live region announces every move and every change in the Checks verdict.
Small buttons on phones rose to the same 38 px finger floor as the rest, and
the license-key box no longer renders at 12 px on iOS.

### The four new packs, and the tooling
Pack types `pest-guide`, `soil-guide`, `season-guide` and `preserve-guide`
join the format — whitelist-rebuilt sanitizers, fill-only application with
full revert on removal, rendered in the Packs view and echoed on the surfaces
where they earn their keep. The pack **content** ships in this repo as
unsigned payloads (`packs-unsigned/`) with `SIGNING.md` and a new
`make-bloomsteader-pack.html` seller tool whose Validate button runs the
exact v5 sanitizer. The signed path was exercised end-to-end with a throwaway
key during the build.

### The test battery, finally run — and repaired where it had aged
The six suites had never been executed against a published build (no Node on
the build machine). They have been now. Reaching a clean 500/500 baseline on
v4.2.2 required honest triage on both sides, all recorded here:

- **Restored pre-split APIs** the suites (and deep links) rely on:
  `RW.setJournalFilter` / `RW.getJournalFilter`, `RW.dayGap`,
  `RW.harvestPick`, `RW.guideMd`, `RW.recordPastYear`. One missing function
  cascaded into 67 of suite 1's 82 checks.
- **Two real phone bugs found by suite 5, fixed in the app**: the license
  textarea rendered at 12 px on phones (inline style beat the 16 px iOS-zoom
  floor), and `.btn.sm` bottomed out at 34 px against the 38 px tap floor.
- **Test-side updates that trace to documented app changes**: the storage
  key (`rootwork.v1` → `rootwork.v2`, per the v2 split), suite 4's
  harvest-card check (predates v4.2.2's planted-clock fix), suite 5's
  service-worker source regexes (predate v4.2.2's nav-only fallback), and
  suite 6's cart checks (made order-independent; the suite's own
  setQty(0)/setQty(2) round-trip reorders the stored keys).
- **Suite 7 is new**: 65 checks over everything in this release, including
  hostile-input probes for every new normalizeState clause and pack
  sanitizer.

Suites 5–7 expectation updates for this release: twelve views in the sidebar,
seven items in the phone More sheet, service worker `bloomsteader-v5.`.

### Numbers
One build, still one file: **2.1 MB** (was 1.9). 446 plants, twelve views,
154 baked care-task templates, 180 baked trouble entries across 50 crops,
storage lines for 124 food crops, 15 phenology notes, ~35 mature heights,
32 seed-life figures. 565 automated checks, all passing, from `file://`,
offline, with no console errors.


## v4.2.2 — 4 August 2026

**Second review sweep: 36 fixes from a nine-part deep review** (CSS/PWA/boot,
core utils, model/state, checks, shell/settings, catalog/bed list, bed
editor, calendar/journal, guide/list/export, harvest, pro/shop). Verified
in-browser from `file://` after the change: boot clean, 446 plants, all 11
views render, no console errors.

Data safety and correctness:

- **A cancelled drag can no longer persist the half-drag position.** The
  drag mutates the live model every frame, so a debounced flush landing
  mid-gesture wrote the in-flight coordinates; every revert path (pinch
  takeover, pointercancel, lost capture) restored the screen but never
  re-saved. All revert paths now save when the gesture had moved.
- **The first-run wizard no longer overwrites a loaded garden's settings.**
  It shows for any loaded garden whose `seen.welcome` is falsy, and its
  selects sat on the markup defaults (zone 7 / "a mix") — Start, the
  primary button, wrote them over the user's zone, shifting every
  frost-derived date. The selects are now seeded from the loaded garden.
- **The service worker can no longer trade a working offline cache for a
  broken one.** The fault-tolerant install swallowed a failed fetch of the
  1.9 MB shell itself; activate then deleted the previous cache. A failed
  shell fetch now fails the install and the old worker keeps serving.
- **Import validation closes the inherited-name hole.** Every whitelist in
  normalizeState tested `MAP[key]` truthiness on a plain literal, so
  `"setup": "constructor"` or `"type": "hasOwnProperty"` passed via
  Object.prototype — recreating the blank-Beds crash and watering "every
  NaN days". All five maps are null-prototype now; the state merge also
  copies own keys only, so an imported `"__proto__"` can no longer replace
  the live state object's prototype.
- **"Tomatoes" now means Tomato.** nameKeys never made o-plurals, so all
  22 "tomatoes" and 15 "potatoes" in the companion prose matched nothing —
  every warning phrased that way silently never fired. ("Sweet potatoes"
  is still correctly not a mention of Potato.)
- **"Harvesting now" respects the planting's own clock.** It read only the
  zone's generic window, so a tomato transplanted in late July was
  "Harvesting now" the day it went in; with a planted date and
  days-to-maturity known, nothing is ready before planted + maturity. And
  year-round herbs (window open forever, gap 0) sort after anything with a
  real season instead of monopolising all six card slots.
- **The Pro succession schedule prints the right year across New Year.**
  fromDoy wraps the day-of-year but pinned the year, so a warm-zone window
  (sow ≈ Dec 15 – Jan 29) printed sowing #2 eleven months before #1.
- **The supply exports honour the metric setting.** CSV and text hardcoded
  sq ft / cu ft beside a "5 cm" mix string; the soil table, its totals and
  both exports now come from one supplyData aggregation (the comment that
  claimed this is finally true), and the exports gained the mulch line the
  screen always showed. A fill total of zero no longer prints "0 cu ft of
  mix to fill".
- **The picker can no longer silently reset a tuned harvest goal.** Tapping
  a crop already in the plan re-added it at 10 of the remembered unit —
  destroying a configured "50 lb" with no undo. It now points at the
  table instead.
- **The meta description no longer claims a warning-free catalog** — that
  claim predates v4.2's full-catalog restore and contradicted the app's
  own first-run safety notice (351 of 446 plants carry warnings).

Robustness:

- Corrupt-storage backups no longer accumulate one full-size copy per
  reload until quota dies — the newest backup replaces the older ones.
- A transiently failed IndexedDB open (or WebCrypto key import) is no
  longer cached as permanent: photos and license/pack verification retry
  instead of silently no-oping for the session.
- A non-quota save failure no longer claims "storage is full" — blocked
  storage gets its own advice, matching store.ok()'s distinction.
- uid() uses crypto randomness and a guaranteed length (a short
  Math.random expansion could shrink the random part to one character).
- Custom-plant import validates `section` (a stringified "1" silently
  turned an annual into a perennial, losing its indoor-start window) and
  the garden name is finally length-capped and type-checked like every
  other settings field.
- freeSpot's emptiest-spot fallback ignores a planting with x but no y
  instead of letting NaN collapse it to the bed centre.

Inconsistent logic and UI:

- The sun check works both ways: a shade crop in a full-sun bed now warns
  (the zone check was already bidirectional; this one was not).
- A persisted catalog group/family filter whose value no longer exists is
  dropped instead of emptying the catalog while the select claims "All".
- "Add to a bed" with no usable bed lands on the bed LIST under the
  new-bed dialog, not inside the last-open bed's editor.
- Delete-custom-plant counts plants (qty summed), not planting rows.
- The harvest footer no longer calls a container-refused crop "no yield
  data", and a plan whose every row was excluded no longer earns a green
  "needs at most 0 sq ft. It fits."
- The theme picker marks the theme actually on screen, not the stored
  choice a removed key can no longer apply; a failed license check now
  repaints the optimistically-unlocked chrome and view, not just the theme.
- A pack that cannot be verified (plain http) is told so, instead of being
  called altered; "Empty cart" returns to the (empty) cart on Confirm and
  to the intact cart on Cancel, instead of closing everything; the Pro
  journal print opens its window inside the click so a photo-heavy journal
  is no longer popup-blocked at the last step.
- Edit-bed Save stopped rounding every planting to one decimal (the
  9.84 → 9.8 same-cell drift drag() documents as fixed) and stopped
  double-rendering the editor; a lifted finger of a three-finger pinch
  re-baselines instead of snapping the zoom; one-finger pan only claims
  the gesture below 900px where touch-action:none makes it ours, ending
  the double-scroll on wide touch screens; drags keep the grab offset
  instead of teleporting the plant centre to the pointer.
- Print: a zoomed bed plan unclips (and the zoom bar hides) on paper; the
  bed editor and guide media queries got the `screen and` guard the file's
  own rule requires; the toast layer sits above #firstrun so the cross-tab
  "stopped saving" warning is visible during setup; "for a in-ground in
  this season" reads "for an in-ground bed"; "1 days ago" reads
  "yesterday"; the photo-cap toast interpolates PHOTO_CAP.
- Performance: mentions() memoizes keys/regexes/haystacks (the same cure
  keysOf documents); the checks memo is shared with previewAdd's baseline
  and its custom-plant signature covers every field the checks read;
  the journal's Pickings column is one pass instead of per-crop refilters;
  journal photos decode lazily; the dead `--clay-l` token is gone.

Known and deliberately unfixed, in addition to v4.2.1's list: the Feb 29
day-of-year aliasing (internally consistent, self-corrects March 1); tool
ticks keyed by display label (stable unless a label is reworded); the
journal still fetches every photo record on open (only decode is lazy).

Service worker VERSION → `bloomsteader-v4.2.2`. Both copies re-synced and
verified byte-identical (SHA-256).

## v4.2.1 — 4 August 2026

**Code-review sweep: 23 fixes from a six-part senior review of the whole
surface** (core/model, checks/shell/catalog, beds, calendar/journal/guide/
list/export, harvest/pro/shop/boot, CSS/PWA/landing). Verified in-browser
from `file://` after the change: boot clean, 446 plants, all 11 views.

Robustness and data safety:

- **A malformed `rootwork.packs` value can no longer blank the whole app.**
  `applyYieldPacks()` runs at script top level, before the error handlers
  exist; a non-object pack entry or a yield pack missing `yields` threw
  there and killed everything after it. `readPacks()` now drops non-object
  entries, the filter checks `yields`, and the top-level call is wrapped so
  the worst case is base yields without the pack overlay.
- **A corrupt-storage tab no longer offers an Export that writes an empty
  garden.** Settings only consulted the quota probe, so the 'corrupt' latch
  never reached it — the save bar sent the user to Settings and Settings
  handed them the trap the bar refused. The data card now disables Export,
  explains that nothing is loaded, and points at Import.
- **Two tabs can no longer silently lose an edit to the save race.** The
  write-time revision check couldn't catch two flushes inside the same
  debounce window. A `storage`-event listener now latches a tab read-only
  the moment any other tab writes the garden key — before its next flush,
  not during it.
- **Pack sanitizer:** `cf` (container-yield fraction) is clamped to [0, 1]
  — the 1e6 cap let a signed pack's `cf: 80` typo multiply container
  yields eighty-fold; two-element ranges (`pl`/`sf`/`yb`) are sorted so an
  inverted pair can't render "34–9 plants".
- **Shop cart:** own-key check (`hasOwnProperty`, matching sanitizePack)
  so a product id like `constructor` can't corrupt the quantity to NaN.
- **Journal `seq`:** `Number.isFinite` replaces the coercing global in
  both normalizeState and addEntry, so `"seq": null` in a hand-edited
  import can't reintroduce the same-day timeline shuffle.

Inconsistent logic:

- **Bed checks now agree with the plant drawer about life cycle.** The
  out-of-zone advice keyed on `p.section === 1` where the drawer uses
  `p.annual` — a zone-4 bed said cotton "will not survive the winter"
  while its drawer said the opposite. Both now read `p.annual`.
- **The guide renders `*` bullets like `-` bullets.** The paragraph
  gatherer didn't break on `* `, so a star list after a text line was
  swallowed as literal asterisks.
- **`harvestSoon()` resets the window cache itself** instead of relying on
  the Today card calling `actionable()` first — a reorder would have
  served harvest windows computed under old frost dates.
- **Metric strings:** the watering task's "check 2 in down" and the
  supply-list export's "2 in compost top-dress" now render "5 cm" in
  metric like the views they mirror; the snap toast names the 50 cm grid.
- **Drag stores 2-decimal coordinates** like keyboard-move and auto-place,
  so a metric grid cell has one snapped position, not two; a key-nudge
  followed by a tap re-arms the deferred repaint instead of skipping it,
  so the checks panel can't be left stale.

Service worker (VERSION → `bloomsteader-v4.2.1`):

- **The HTML shell fallback answers navigations only.** Any un-cached
  same-origin GET used to get the 1.9 MB document with an implicit 200 —
  an icon request answered with HTML. Other misses now get an honest 503.
- **One cached copy of the document, not two.** `./index.html` is out of
  the install list (~1.9 MB saved per cache generation); the shell lookup
  tries both keys.
- **Registration actually waits for window load.** The comment claimed
  "registered after load" but boot() runs at DOMContentLoaded, so the
  1.7 MB worker install competed with first paint.

Cosmetics, PWA and landing:

- Dark theme: the select chevron (the one hard-coded light literal the
  dark pass missed) gets a visible stroke; sow/bloom calendar bars use
  dark ink on `--leaf-l`/`--gold` (white sat near 3:1, under AA).
- `theme-color` now follows the seed and river themes' brand bars, not
  just dark.
- The guide's link tooltips no longer double-escape (`&amp;amp;` for `&`);
  the journal photo-picker comment no longer claims a sweep that doesn't
  happen; the drawer's unreachable "needs more room" hint says the one
  thing its state can mean.
- Boot is a few hundred ms faster on slow phones: `nameKeys` is memoized,
  collapsing tens of thousands of redundant normalizations in
  `decorate()`'s 446-plant loop to ~516.
- Landing: "Ten views" → eleven; LAUNCH-CHECKLIST step 8 now says to set
  `og:image` to an absolute URL at publish time (scrapers ignore relative
  ones). The `REPLACE_WITH_…` commerce links are intentional pre-launch
  placeholders per the checklist and are untouched.

Known and deliberately unfixed (documented tradeoffs): undated plantings
still stamp rotation history on removal (`since = 999` — changing it would
erase legacy history); a crop with container "No" but no researched `cf`
still shows open-ground figures under a container setup; harvest goals
whose plant id no longer resolves stay invisible rather than gaining UI;
two-finger pinch started over bare soil on ≥900px viewports can still be
claimed by the browser (the fix broke phone scrolling when tried plan-wide).

- Both copies re-synced and verified byte-identical (md5).

## v4.2 — 4 August 2026

**The full catalog is back: 446 plants.** All 121 plants removed by the V2
catalog cut are restored, so the published build once again carries every
plant in the reference — including all 122 whose entries carry a safety or
legal warning, each shown in red on its plant, exactly as before the cut.

- **Restored verbatim from the archived full-catalog build**, whose `rw-data`
  payload was verified to be a strict superset of this build's before the
  swap (every shared plant, companions, uses, yields and rules entry
  byte-for-byte identical — tomato included, from its v4.1 restoration). The
  whole payload was carried over: plants 325 → 446, companions 338 → 453,
  uses 325 → 445, yields 269 → 343, runner rules 19 → 32, acid-soil rules
  7 → 8. No app code changed.
- **The safety notices no longer describe a reduced catalog.** The first-run
  notice and the full safety text revert to the original full-catalog
  wording ("Many useful plants are toxic, restricted, or easily confused
  with something that is…"); the "all but one of the warning-carrying
  plants are removed" paragraph is gone because it is no longer true.
- **Counts and claims updated everywhere a person sees them:** the manifest
  description, the landing page (hero, meta/og descriptions, the 446 card,
  the Pro feature list, the Limits table's "The catalog" row — which now
  says the catalog is the full reference — and the download size, now
  1.9 MB), README (intro, catalog note, "What it does"), and GITHUB-STEPS.
  The top-bar badge, landing header and footer read v4.2.
- **The test suites' natural target is the published file again** — the
  catalog they were written against is back. README and LAUNCH-CHECKLIST
  now point them at `download/bloomsteader.html`; they still have never
  been executed on this machine (no Node), and failures that trace to the
  post-split code fixes this build carries and the archive does not would
  need triage.
- **Service worker:** VERSION bumped to `bloomsteader-v4.2.0`.
- **Deliberately unchanged:** the storage keys (`rootwork.v2`,
  `rootwork-photos-v2`), the archived pre-v2 snapshot itself, and all app
  code.
- Both copies re-synced and verified byte-identical (`fc /b`).

## v4.1 — 4 August 2026

**One build, one name — and tomato is back.** The published build (formerly
"Bloomsteader V2") is now plain **Bloomsteader**, and tomato returns to its
catalog: 325 plants.

- **Tomato restored, with its warning.** Its plant record, companions entry,
  use-role tags and yield entry were carried over verbatim from the archived
  full-catalog build (where each is unchanged since v1.x), and the Shop's
  tomato seed row is back — the catalog link, drawer cart button and
  seed-list integration all resolve again. Its red warning ("leaves and
  stems are toxic — fruit only") ships with it, so the safety notice now
  reads: all but one of the reference's warning-carrying plants are removed
  from the catalog; the exception is tomato, with its warning shown in red.
  The other 121 stay out.
- **The V2 name is gone everywhere a person sees it.** The folder is `app\`
  (was `app-v2\`), the download is `download\bloomsteader.html` (was
  `bloomsteader-v2.html`), the icon file is `bloomsteader.ico`, the manifest
  is named plain "Bloomsteader" (325 plants, and its no-warning-plants claim
  removed — it is no longer true), and the landing page, README,
  GITHUB-STEPS and LAUNCH-CHECKLIST were updated to match, counts included
  (324 → 325, "V2" wording dropped). The in-app top-bar badge reads v4.1;
  the landing page header and footer agree.
- **Service worker:** VERSION is `bloomsteader-v4.1.0` — the `bloomsteader2-`
  prefix is retired. Its activate step now owns the whole cache space and
  discards every earlier generation (`bloomsteader-*`, `bloomsteader2-*`,
  legacy `rootwork-*`). Consequence, noted in the archive README: the
  archived original can no longer be re-hosted on the same origin as this
  build without renaming a cache prefix, since both now use `bloomsteader-`.
- **Deliberately unchanged:** the storage keys (`rootwork.v2`,
  `rootwork-photos-v2`) — so any garden saved in this build before the
  rename survives it — and everything else about the app.
- Both copies re-synced and verified byte-identical (`fc /b`). The test
  suites still target the archived full-catalog file and still cannot run on
  this machine (no Node); with tomato back, some of their tomato-dependent
  checks may now apply to this build too, but that has not been verified.

## Original build archived; V2 is the published build — 4 August 2026

The pre-V2 build — `app\` and `download\bloomsteader.html`, the full 446-plant
catalog — is retired to `archive\pre-v2\` (with its own README), complete and
working, byte-identical pair verified with `fc /b` before and after the move.
**No app code changed in either build**, so no service-worker VERSION was
bumped: `app-v2\sw.js` stays at `bloomsteader2-v4.0.2`, and the archived
`app\sw.js` at `bloomsteader-v4.0.3`.

Everything that presented the original build as the live app was updated to
point at V2 (`app-v2\` + `download\bloomsteader-v2.html`, 324 plants):

- **The landing page** — Open/Download buttons, footer links and favicons now
  target the V2 build; the plant count reads 324 everywhere it read 446; the
  stale per-catalog counts (433 companions / 245 families / "every safety
  warning") were reworded rather than left wrong; the header and footer badge
  read "V2 · v4.0"; and the Limits table gained a "The catalog" row stating
  plainly what the V2 catalog omits and where the original lives.
- **README** — intro states this is the V2 build and what that means; the
  folder map, byte-identical rule, download path and build instructions name
  the V2 files; a note documents the V2 storage keys and cache prefix.
- **GITHUB-STEPS.txt** — upload list swaps `app` for `app-v2` (and gains
  LAUNCH-CHECKLIST.md, missing since v3.0, plus the optional `archive`
  folder); the app URL, downloaded filename, 1.6 MB size and the
  service-worker bump instructions (`bloomsteader2-*`, plus re-syncing the
  download copy) are corrected.
- **LAUNCH-CHECKLIST.md** — placeholder paths point at `app-v2\index.html`
  (re-verified by grep: 16 occurrences, 15 distinct — identical to the
  original build); the re-sync and test steps name the V2 pair and the
  archived suite target.

Stated honestly, twice (README and checklist): the six test suites were
written against the full catalog and are **not adapted to V2** — several
checks name plants V2 does not carry (tomato among them). Their target is now
the archived file; adapting them to V2 is open work.

## UI review fixes, both builds — 4 August 2026

A full code-review pass over the UI of both builds (`app\index.html` and
`app-v2\index.html`, each mirrored byte-identical to its `download\` copy).
No feature changes. `app\sw.js` VERSION bumped to `bloomsteader-v4.0.3`,
`app-v2\sw.js` to `bloomsteader2-v4.0.2`.

Data safety:

- **"Start over" in a stale tab no longer deletes the other tab's garden.**
  The read-only latch protects `store.write`, but wipe went around it with
  `removeItem` and `photos.clear()` — a tab whose banner promised "this tab
  has stopped saving so it cannot overwrite that work" could still erase the
  newer garden and every photo. It now refuses, exactly as Import does. With
  storage *blocked*, the wipe also no longer dies half-way: `removeItem`'s
  throw is swallowed so the photo wipe and reload still run.
- **The V1 corrupt-storage banner no longer offers Export** — the fix the V2
  build got earlier today, applied to the main build: Export from an
  unloadable save wrote an *empty* garden under a reassuring dated filename.
  The button now opens Settings, where Import lives.
- **Saving journal photos can no longer hang forever on a full disk.** The
  IndexedDB write promise handled `oncomplete`/`onerror` but not `onabort`,
  and quota failures commonly arrive as an abort (Firefox fires no error
  event at all) — the save loop awaited a promise that never settled and the
  photos vanished with no toast. `put`/`del`/`clear` all settle on abort now.

The two "can I plant this here?" paths now agree:

- **The plant drawer's bed picker runs the same-season replant check.** It
  cloned the bed and re-ran checkBed, which never sees an incoming candidate
  — so tomatoes pulled in July showed a "clear" bed for an August replant
  that "What goes well here?" flagged as a stop.
- **A bed's pre-existing problems are no longer charged to every candidate.**
  conflictsWith's capacity messages describe the addition, so the baseline
  mask could never catch them: a bed already at 140% stamped a stop on all
  40 suggestions and the dialog said nothing pairs with anything. Capacity
  findings now fire only when the candidate crosses the threshold.

Phone and touch:

- **Tapping a bed marker reliably opens its drawer.** The tap-vs-drag
  threshold was 0.4 *bed inches*, which is ~1 screen pixel for a 4×8 bed on
  a phone (sub-pixel on a long row) — finger jitter turned taps into drags,
  and with snap off the plant was silently nudged and saved. The threshold
  is now screen pixels (5 mouse / 9 touch).
- **A pinch-zoomed plan no longer traps page scrolling.** Pan consumption is
  tracked per axis; a plan overflowing only horizontally used to swallow
  vertical swipes into a scrollTop that could not move.
- **An interrupted mouse drag (Alt-Tab, release off-window) cleans up.** The
  hover bail-out now runs the full cancel path — before, it stranded
  `RW.abortBedDrag` (muting the keyboard-nudge repaint for the session) and
  left the half-drag position to be silently persisted by the next save.
- **The first-run welcome can always be scrolled to the top.** Grid-centred
  content taller than the viewport overflows above the scroll origin, so on
  landscape phones and short windows the logo and zone label were cut off
  unreachably. The card centres by auto margins instead.
- **The app fits iPad-landscape Safari.** The `100dvh` fallback applied only
  below 900px; above it, `100vh` + `overflow:hidden` put the safety strip
  behind browser chrome with no way to reveal it.
- **"Add photo" and garden import keep their file input attached until the
  picker returns** — iOS Safari has dropped the change event for a detached
  input, making both silent no-ops there.

The guide, the harvest planner and smaller fixes:

- **Guide search now finds words with diacritics and × dimensions.** The
  query was diacritic-folded but the indexed text was not, so "hügelkultur",
  "mâche" and every "4×8" were unfindable in any spelling — the guide
  reported "No matches" for a word sitting on screen. The haystack folds the
  same way now, char for char, so highlight indexes never shift.
- **The harvest planner refuses instead of inventing.** A blank or zero
  target added a silent 1 lb goal right after the panel said "Enter how much
  you want"; the inline editor turned a typed 0 into 1. Both now refuse out
  loud and keep the old value. A target so large the unit conversion
  overflows is refused as no-amount instead of showing "Infinity plants".
- **No more "Sow it 1 times … split into about 1 batches"** — one sowing is
  not a succession; the pill and the advice now appear only at 2+.
- **A succession crop's area range can no longer invert** (green beans at a
  0.5 lb target read lo 0.71 / hi 0.67 sq ft into the totals and CSV), and
  metric weight ranges no longer print "10–10 kg" when both ends round to
  the same figure.
- **"The plan calls for null plants" is gone** — planting a by-area crop
  (buckwheat) from the plan now says it is sown by area, the same answer
  proSchedule already gave.
- **The cart's "is in the cart" toast tells the truth at the 100-item cap**
  (add() reports refusal to all three add-to-cart buttons), **removing a Pro
  key keeps the saved theme choice** for re-activation (matching the
  failed-verification path), and the pack sanitizer's whitelists check own
  keys only, so a signed pack typed `constructor` no longer installs as an
  empty typeless pack.
- **Check messages dedupe plant names** ("Mint, Mint will take this bed"),
  the zone button's tooltip formats its dates ("April 3", not "04-03"),
  imported planting coordinates are clamped into the bed frame, and the
  `#modal` dialog carries `aria-labelledby`.
- **Print is no longer styled by the phone stylesheet.** A printed page box
  is ~700–800 CSS px wide, so the ≤900px rules applied on paper — cards
  became clipping scroll containers that truncated wide tables. The
  responsive blocks are `@media screen` now.
- Performance: suggestion scoring drops a per-candidate call whose output
  was always filtered away; the planner's plant-into-bed loop no longer
  forces up to 200 synchronous whole-garden saves in one click (debounced).
- `app-v2\manifest.json` is now actually **named Bloomsteader V2** — this
  changelog claimed it already was, but both manifests carried the same
  name, so installing both apps gave two indistinguishable icons.

## V2 review fixes — 4 August 2026

A code-review pass over the V2 build (`app-v2\index.html`, mirrored
byte-identical to `download\bloomsteader-v2.html`). No feature changes.

- **The corrupt-storage banner no longer offers Export.** With an unreadable
  save, nothing was loaded — so Export wrote an *empty* garden under a
  reassuring dated filename, right when the user's only real copy was the
  blob it could not read. The button now opens Settings, where Import lives,
  matching the banner's own advice. (Export stays for the blocked-storage and
  full-storage banners, where the in-memory session is worth saving.)
- **Import now clamps a custom plant's spacing and days,** with the add
  dialog's own bounds (1–240 in, 1–3650 days). A hand-edited `spLo` of
  `"abc"` rode through as a NaN spread: placements the canvas silently
  dropped, a bed-fill of NaN that muted every capacity warning, and an
  overlap check that could never fire. Zones got this hardening earlier;
  spacing and days now match.
- **The Shop's tomato seed row is gone from this build.** V2 removed tomato
  from the plant catalog with the other warning-carrying plants, but the
  product survived — its blurb pointed at a catalog page that does not exist
  here, and its cart integrations could never trigger. A shop-catalog pack
  can restock it for a build that has the plant.
- **Yield packs can now deliver `hp` (harvested part) and `rs` (rootstock).**
  The pack sanitizer typed both as numbers while the base data and the
  harvest panel treat them as prose, so packs silently dropped them — and a
  numeric one would have rendered "Harvested part: 3".
- **An empty bed no longer nags "Water …" on Today.** A bed generated a
  watering task the day it was created, with nothing in it to water.
- **A stale bed repaint no longer rebuilds a hidden view.** Leaving Beds
  keeps the editor's DOM attached but invisible, and a late caller (the
  keyboard-nudge settle timer) repainted it anyway — real layout work,
  thrown away. The repaint closure now retires itself off the Beds view.
- **The bed palette's "plantable soon" list now caches its season windows,**
  the same cure the calendar's winCache applies at the same ~450-plant scale;
  it used to re-derive the frost pair per plant on every editor render.
- **The calendar's mode segment announces `aria-pressed`,** like every other
  segmented control in the app, and Today's empty state reads "No sowing
  window is open …" instead of "Nothing's sowing window is open …".

## V2 build — 4 August 2026

**A second build, `download\bloomsteader-v2.html`, with zero dangerous
plants.** Same app, same version (v4.0.1), but every plant that carries a
safety or legal warning in the reference — all 122 of them — is removed from
the catalog, leaving 324. Companion, uses and yield entries and the
runner/acid-soil rule lists for the removed plants are dropped with them; the
guide text, the safety notice and the red-warning machinery stay, and the
notice states what this build is. It saves under its own storage keys
(`rootwork.v2`, `rootwork-photos-v2`), so it never reads, overwrites or wipes
a V1 garden on the same device, and it announces itself as **Bloomsteader V2**
in the title, the first-run screen and the top-bar badge. The original
`bloomsteader.html` is untouched.

A hosted copy lives in `app-v2\` — index.html (byte-identical to the download
file), its own manifest (named Bloomsteader V2, its own scope, so it installs
as a separate app) and its own service worker under a `bloomsteader2-` cache
prefix that only ever deletes its own caches. To keep the two apps from
wiping each other's offline copy on a shared origin, `app\sw.js` was given
the matching guard: its activate step now spares `bloomsteader2-*` caches
(and still discards old `bloomsteader-*` and legacy `rootwork-*` ones);
VERSION bumped to `bloomsteader-v4.0.2`. Verified served from one origin:
both workers activate and both caches survive each other's activation.

## v4.0.1 — 4 August 2026

**The app is now called Bloomsteader** (formerly Rootwork). Naming changed
everywhere a person sees it: titles, the manifest, the landing page, the Pro
branding, merch names, export filenames (`bloomsteader-garden-…`,
`bloomsteader-seed-and-supply-list`, `bloomsteader-harvest-plan`), the
download file (`download\bloomsteader.html`), and the service-worker cache
(`bloomsteader-v4.0.1`, which also discards old `rootwork-*` caches on next
load). Deliberately **unchanged**, so existing data and sold keys keep
working: the browser storage keys (`rootwork.v1`, `rootwork-photos`,
`rootwork.license`, `rootwork.packs`, `rootwork.theme`, `rootwork.cart`),
the `RWPRO.` license-key format, the `.rwpack` extension and its signing key,
and the internal shop vendor id. The publishable copy now lives in its own
`Bloomsteader\` folder, separate from the Rootwork folder; the private
seller folder stays at `..\Rootwork\rootwork-v4-seller\`.

## v4.0 — 3 August 2026

**The Shop release.** A new **Shop** view sells the things gardening actually
takes — seeds, tools, supplies, work clothes, and Bloomsteader's own merch — while
changing nothing about what the app is: it still makes **zero network
requests**, and nothing that was free became paid.

- **A Shop view** (sidebar under Reference; in the More sheet on a phone) with
  five shelves — Seeds, Tools, Supplies, Workwear, Bloomsteader goods — and ~52
  curated products. Nothing is sold *by* the app: every product is a link that
  opens the store in a new tab when tapped, and only then. Third-party links
  may carry an affiliate tag (disclosed in plain language, same wording as the
  v3 seed links); the Bloomsteader-made items check out at an outside storefront.
  Prices are display strings marked `~` and the app says so: a guide, not a
  quote. The tool shelf keeps to the guide's own tiers, so shop and guide
  agree. Seed products name their plant, so a seed card links to the plant's
  catalog page.
- **A cart** — a list, stated plainly as such. It lives in its own storage key
  (`rootwork.cart`), so a garden export, import or "start over" never touches
  it and it never rides into an export. Quantities cap at 99; the Shop's nav
  button carries the count. Checking out is an honest hand-off: items grouped
  by store, one link per item, plus a "Copy list" button — the app cannot fill
  another store's cart and does not pretend to. If browser storage is
  unavailable the cart works for the session and says so once.
- **Contextual links, sparingly:** the Seed & supply list and the plant drawer
  offer "add to cart" only on the ~15 plants whose seed the Shop actually
  stocks ("Find seed ↗" stays on every plant); Shop seed cards link back to
  the catalog.
- **A fourth pack type, `shop-catalog`** — a signed `.rwpack` that updates the
  Shop after release: products upsert by id, and a `remove` list delists dead
  store links, no new build needed. Same signature machinery, same
  whitelist-rebuild validation (plus: product URLs must be `https://`), same
  isolation from garden data. A v3 app given a v4 pack correctly says it needs
  a newer Bloomsteader. The seller folder gains `make-shop-catalog.html`, whose
  **Validate** button applies the app's own acceptance rules before signing —
  so a malformed pack is caught at the desk, not at the customer.
- **Corrections:** `app/sw.js` `VERSION` was still `rootwork-v2.0.0` in the
  v3.0 build — anyone who installed the app kept the old cache logic; it is
  now `rootwork-v4.0.0`. The suite5 nav-count assertions were still written
  for v2.0's nine views (v3.0 added Packs and never updated them); they now
  expect eleven.
- **Not changed, deliberately:** the phone bottom bar is still the same five
  views + More (the Shop lands in More). The Shop is free — it is never
  Pro-gated, and the one-modal upsell policy is untouched. The supply-list
  exports (print/text/CSV) carry no shop links, by construction: they build
  from data, not from the screen.

Placeholders: **16 in `app/index.html`, 3 in the landing page** — enumerated,
with which ones 404 and which degrade gracefully, in LAUNCH-CHECKLIST.md.
The six test suites were not run for this build (still no Node on the build
machine); run them before publishing. The Shop layer was verified by hand in
a browser: view rendering and category filters on desktop and phone widths,
hash deep-links (`#shop/merch`; junk falls back to Everything), cart
add/remove/cap/persistence and its isolation from garden state and exports,
the nav badge, the drawer and seed-list buttons, and the full pack path —
signed sample import, override + delist visible in the Shop, tampered
signature rejected, duplicate import refused, removal restoring the baked
catalog.

## v3.0 — 3 August 2026

**The monetization release.** Everything the free app did, it still does, free —
the catalog, every safety warning, beds, calendar, journal, harvest math,
seed & supply list. v3.0 adds a paid layer on top, per the monetization plan:

- **Bloomsteader Pro**, a one-time purchase unlocked by a license key. The key is
  an ECDSA signature verified offline against a public key baked into the app —
  no account, no server, no phoning home, works on `file://`. Pro adds:
  - **Dark mode and themes** (Night, Seedhouse, Riverbank) — token swaps on the
    design system; printing always comes out light.
  - **Unlimited photos** — the 60-photo cap stays on the free tier, and the
    import path honours the same rule.
  - **Harvest Planner Pro** — a dated succession schedule per crop (computed
    from your frost dates, printable) and "plant a plan into a bed", which
    places the plan's plant count through the normal placement + checks path.
  - **Premium PDF exports** — bed-plan sheets for every bed in one document,
    the printable journal with photos, and a year-at-a-glance poster.
- **Content packs** (`.rwpack`) — signed files bought separately and imported
  under the new **Packs** view. Three types: zone week-by-week calendars,
  growing guides, and yield-data extensions (which fill only crops the app has
  no figure for — they never overwrite shipped data). Pack data ships outside
  the MIT file, which is what makes the tier real. Packs live in their own
  storage key: a garden import/export/wipe never touches them, and the
  purchased file is the buyer's backup.
- **Affiliate links** on the Seed & supply list ("Find seed ↗" per plant, with
  a plain-language disclosure). Excluded from print/text/CSV exports.
- **Upsell behaviour, stated plainly:** one modal, only when a locked feature
  is actually clicked. No nags, no timers, no popups on open, and nothing that
  was free in v2.0 became paid.
- The landing page gained the pricing ladder (Free / Pro $14.99 founder,
  $19.99 after launch / packs $4.99–7.99, bundle $24.99).

Before launch, three placeholders must be replaced — see LAUNCH-CHECKLIST.md.
The five v2.0 test suites were not run for this build (no Node on the build
machine); run them before publishing. The Pro layer was verified by hand:
key verification (a forged key is rejected), activation/deactivation, theme
gating and revert, the photo-cap conditionals, pack rendering, the succession
schedule's dates, plan-into-bed placement, and all three premium print
documents generating with content.

## v2.0 — 3 August 2026

**The first public release.** v2.0 makes Bloomsteader work on a phone and packages
it so anyone can download or install it. **No feature of the garden planner
changed, and the desktop interface is unchanged** — same plant data, same frost
engine, same checks, same yield figures, same look.

The proof of that last claim is the test suites: all 279 checks from v1.1
through v1.4 pass against v2.0, unmodified, at the desktop width they were
written for.

### It works on a phone now

Before this release the app had a hard minimum width of **974 px**. On a 390 px
phone the sidebar took 216 px and left a 174 px column, and the welcome screen
was off-screen entirely — a new user could not finish setup. The cause was
`#top`, a flex row of nowrap buttons whose min-content width the grid refused to
go below.

- The 974 px floor is gone; the top strip scrolls instead.
- Below 900 px the shell is one column and the sidebar becomes a **bottom bar**
  of five (Today, Beds, Calendar, Journal, Catalog) plus a **More** sheet for
  Harvest plan, Seed & supply list, The guide and Settings. Above 900 px the
  sidebar is exactly what it was.
- Crossing the breakpoint — rotating a phone, resizing a window — rebuilds the
  navigation rather than leaving a bar with four views missing.
- `100dvh`, so the iOS toolbar does not clip the app, and `safe-area-inset`
  respected around the notch and the home indicator.
- Every input is at least 16 px, because iOS zooms the whole page whenever a
  focused field is smaller.
- Buttons, checkboxes and segment controls grow to finger size below 900 px.
- The plant drawer becomes full width and dialogs become bottom sheets on a
  phone. Toasts clear the bottom bar.
- Wide tables — the seed list, the harvest plan — restack into one card per row,
  with each cell labelled from its column heading. Tables added later get this
  automatically.
- The Today task rows drop their timing onto its own line instead of squeezing
  the plant's name into three words.

### The bed planner works with a finger

- **Pinch to zoom** anywhere over the plan, including two fingers that start on
  a plant. A pinch that interrupts a drag puts the plant back where it was
  rather than committing a half-finished move.
- Zoom buttons and a Fit control below the plan on small screens. Ctrl-scroll
  zooms on a laptop.
- Zooming keeps whatever was under your fingers under your fingers.
- Every marker gained an **invisible 26-unit grab target**. A radish marker is
  13 SVG units across — about 5 mm on a phone — and this makes it catchable
  without drawing anything differently.

### It installs

- A web manifest, a service worker, and icons at 192, 512 and maskable 512.
  Added to the Home Screen on iOS, or installed on Android and desktop Chrome,
  Bloomsteader opens with no signal and keeps its data indefinitely.
- **One build serves both routes.** `app/index.html` and
  `download/bloomsteader.html` are byte-identical; the manifest link and the service
  worker are injected at runtime and only when the app detects it is being
  served over http(s), because on `file://` a manifest link is a 404 and
  `serviceWorker.register` throws.
- The app asks for persistent storage, so a garden is not evicted as though it
  were a cache.
- An **Install** button appears in the top bar only once the browser has said an
  install would be accepted, so it is never a button that does nothing.
- Opened from a downloaded file **on an iPhone or iPad**, the app now says so
  once and explains that Safari erases a local file's storage after about a
  week — and points at the web address and Export instead. Shown once, after the
  welcome screen, never during it.

### Public-release changes

- Version badge reads **v2.0**.
- A safety notice on the welcome screen and a **permanent line at the foot of
  every screen** in both layouts, with the full text one tap away. The 122
  existing red per-plant warnings are untouched.
- MIT licence, author line "Bloomsteader", with an added plain-language notice about
  plants.
- A landing page, a readme, this changelog, and a step-by-step publishing guide
  written for someone who has never used GitHub.

### Testing

- **suite5.js** — new: 172 checks covering the phone layout, the bottom bar and
  More sheet, the touch targets, the stacked tables, the bed planner on touch,
  the install layer and the safety notice. Run against v1.4, 159 of them apply
  and **127 fail** — that is the evidence. The 32 that pass there are meant to:
  most assert the desktop and the `file://` build did *not* change, the rest are
  error guards, preconditions, and blocks that skip themselves.
- Four checks in the first draft of suite5 passed against v1.4 for the wrong
  reason and were rewritten. Three read `undefined === undefined` as success
  after a throw. The fourth measured the welcome card against
  `window.innerWidth` — which on v1.4 *was* the inflated 974 px layout viewport,
  so a card sitting at 227–747 on a 390 px phone counted as on-screen. That one
  is worth knowing about: the bug inflates the very ruler you would measure it
  with.
- Suites 1–4 unchanged and passing, which is the gate on "the desktop did not
  change".

### A code review of v2.0, before release

The first v2.0 build was reviewed and **fourteen defects were found and
fixed**. Section I of suite5 covers all of them. The three that mattered:

- **The new grab target stole taps from the neighbouring plant.** A flat
  26-unit invisible circle is fine on a small bed and reaches clean over the
  next plant on a crowded one — and because it sits inside its *own* group the
  tap did not go dead, it silently opened or dragged the wrong plant. Seven of
  eight, on eight radishes at 1 ft spacing. It also fired on the desktop, which
  broke the "nothing above 900 px changes" promise. The target is now capped at
  half the distance to the nearest neighbour, and is only enlarged on a touch
  screen at all. Measured against v1.4: identical, at every bed size tested.
- **Pinch-to-zoom died after about 1.27×, every time.** The moment the first
  zoom step made the plan wider than its box, the browser decided a two-finger
  gesture was a scroll, claimed it, and fired `pointercancel` on both fingers.
  `preventDefault` in `pointermove` is far too late to stop that. `#bedsvg` now
  takes `touch-action: none` below 900 px and the handler does its own panning —
  panning the plan when it is zoomed, and handing the swipe back to the page
  when there is nothing to pan, so a swipe over bare soil still scrolls past
  the bed.
- **A lost `pointerup` broke dragging for the session.** If the plan repainted
  mid-gesture and the finger then lifted somewhere else, the pointer stayed in
  the pinch map — so the *next* single finger counted as the second of a pinch,
  zoomed the plan to the limit and aborted every drag, across beds and views,
  until reload. The map is now cleared per editor, lifts are heard on the
  window as well as the wrap, and losing window focus clears it too.

The other eleven: the More sheet's focus trap had its arguments reversed and
did nothing; resizing while on a More view left the whole bottom bar unlit;
table stamping was gated at 900 px while the stacking CSS is at 560 px, so a
table rendered wide and then narrowed kept unlabelled cells; the service
worker's offline fallback was unreachable dead code (`caches.match()` returns a
promise, and a promise is always truthy) and its background revalidation was
never passed to `waitUntil`, so a slow connection could stay on a stale build
indefinitely; a trackpad ctrl+wheel could zoom the plan on a desktop where the
only control that undoes it was hidden; the 974 px floor was only lifted below
900 px, leaving 901–974 — including the 960 px half-screen — exactly as it was;
the save-failure warning ended up *below* the safety line; the More scrim sat
under the plant drawer; the drag-abort closure outlived its gesture; and the
iOS file notice, after a minute of waiting, would open itself behind the
welcome screen and trap focus there.

### Known and unchanged

Everything in the Limits section of the README still holds. In particular the
iOS Home Screen install could not be verified during the build — there is no
Safari in the environment it was made in. The behaviour it relies on is
documented by WebKit, but confirm it on a real iPhone before promising it to
anyone.

---

## v1.4 — 3 August 2026

Third code review: 50 findings, all fixed. Storage safety (an unreadable saved
garden is told apart from no saved garden; every write stamps a revision so a
second tab cannot clobber the first). Addressable views via the URL hash, so a
reload or a bookmark reopens where you were.

## v1.3 — 3 August 2026

Harvest planner backed by yield data for 343 food crops. Sixteen use-role tags
per plant. Text and CSV exports for the seed list and the harvest plan.

## v1.2 — 3 August 2026

Second code review: 43 findings, all fixed.

## v1.1 — 3 August 2026

First code review: 53 findings, all fixed.

## v1.0 — 3 August 2026

First build. Eight views, 446 plants, frost engine, smart checks.
