# Handoff — hartlingowners

> Newest first. What landed, what's in flight, what's next.

## 2026-09-10 — direct SSH to client servers is CLOSED — go through host

Logging into a client server directly (`ssh master_…@…`, any key in `~/.ssh`) no longer
works: the owner's keys were removed from every client server, and host now logs in with
keys of its own. Direct logins as the platform's privileged account were leaving files the
site's own WordPress could not write. For any change to a LIVE site use host — see "For
Claude: Changing a LIVE client site" in `/srv/CLAUDE.md`:
`srv-gw live-sites` · `srv-gw live-wp --project <name> -- <wp-cli …>` · `srv-gw live-patch` ·
`srv-gw live-purge`. Older entries below that show an `ssh master_…` login are superseded.
If something is refused, stop and ask rian — do not look for another way in.

## 2026-09-01 — v0.38.0: dates hidden, board bios erased

**347 tests green.** Both asks from rian after Joan's first look.

**The two board bios erased.** "Stan Hartling" and "Stephen Sisca" were sitting
in the shoreclub Trash — they came from board data, not documents. Deleted for
good, **and their files removed from storage**: `Document.delete()` alone
orphans the file, which the app's own empty-trash view also does (worth fixing
there one day). Shore Club trash is now empty.

**Document dates hidden — `SHOW_DOCUMENT_DATES`, default off.** They are
WordPress POST dates: when a file was put on the old site, not when the
document is from. That is why the 2023 AGM minutes carry 21 Apr 2026, and it
is the same wrongness that produced the mis-named groups.

Hiding the row text alone would not have been enough. **The year filters are
built on the same value**, so "2023 AGM Minutes" would still have appeared
under a 2026 heading — the exact thing hiding the dates was meant to stop. So
everything date-derived is off together: the row date, the detail date, the
"Year" rail group, and the `?group=date` / `?when=` modes, which the view now
refuses even when typed into the URL. The rail's category links used to route
through the date view; they now go to the flat list.

**Hidden, not discarded.** The dates stay in the database and in the staff edit
form, so they can be corrected. One environment variable
(`PORTAL_SHOW_DOCUMENT_DATES=1`) brings the whole date UI back when documents
carry real dates of their own — pinned by a test that asserts exactly that.

Five existing tests covered the date grouping and year filters; they now run
with the setting on, so the feature keeps its coverage rather than being
quietly abandoned.

**Owner view now:** shoreclub 54 · sands 147 · palms 146 documents, no groups,
no dates anywhere, no Year rail.

**Still open:** force a password reset for every owner before launch · OCR the
~70 scanned documents · DigitalOcean setup. And, when real document dates
exist, decide whether to populate `published_date` properly and turn the date
UI back on.

## 2026-09-01 — v0.37.0: documents put back to match the live site

**341 tests green.** Joan's first look went badly: the library showed a group
called "2025 AGM documents" containing the 2023, 2024 AND 2025 AGM minutes.
Reads as the site having its facts wrong, which is fatal for trust on day one.

**The titles and dates were never the problem.** Diffing every document against
WordPress by its stored `wp_id`: shoreclub 1 title wrong and **0 dates**, sands
1 title, palms 3 titles + 2 dates. What was wrong was **grouping** — the
importer bundled every document sharing an upload date and named the bundle by
guesswork. 77 groups across the three sites, several misnamed (the 2021-07-01
batch was "2021 AGM documents" but held the 2020 AGM). WordPress has no such
concept; the groups were invented here. All dissolved, per rian: no grouping
for now.

New `resync_from_wordpress` command (file-based export, like the other
importers — the portal never connects to the WordPress database). Metadata
only; stored files are never opened or moved. Snapshots of the previous state
are in `data/backups/<site>-before-resync-*.json`.

**Caught before it shipped:** copying WordPress titles verbatim would have put
`&amp;` back on screen in two Palms titles — the double-escaping this project
already fixed once. Titles are `html.unescape`d, and a test pins it.

**Three more real faults found while reconciling, each invisible until the
sets were compared rather than the pages eyeballed:**
1. **"2026 AGM Minutes" and "Strata Financial Statements AGM May 2026" were in
   the portal's Trash** while published on the live site — the two NEWEST
   documents, the ones an owner looks for first. Restored, files intact.
   (Found because rian asked where the 2026 minutes were; a set-level
   comparison would have caught it without being asked, and now does.)
2. **20 junk documents on sands** that exist in no WordPress record: 17 with
   32-character hex titles (filename hashes from a failed import) and 3
   duplicates ending "(2)". Moved to Trash — reversible — not deleted.
3. Three sands documents that appear on the live site but not here turned out
   to have **no file attached in WordPress at all**; the importer was right to
   skip them. Not a gap.

**Final state:** shoreclub 54 · sands 147 · palms 146 documents, **0 groups**
anywhere, titles and dates identical to the live site (a fresh dry run reports
zero differences on all three).

**One to watch:** on the first pass a shoreclub title reported as changed but
did not persist; the same command re-run applied it, and sands/palms persisted
first time. Cause not identified. Every site was re-verified with a fresh dry
run afterwards, which is the check that would catch a recurrence — worth
keeping that verification step in any future resync.

**Still open:** two board bios ("Stan Hartling", "Stephen Sisca") sit in the
shoreclub Trash rather than the library — they came from board data, not
documents; confirm they should stay out. Plus the standing pre-launch items:
force a password reset for every owner, OCR the ~70 scanned documents,
DigitalOcean setup.

## 2026-08-28 — v0.36.0: false sign-in dates fixed, WP history imported, security pass

**335 tests green**, three instances deployed, `security-audit` 0 critical.
Triggered by rian believing the site had been broken into. It had not — but
three genuine weaknesses came out of investigating it.

### The false "last signed in" (the thing that caused the scare)

`last_login` is stamped by Django for ANY call to `login()`, including
request-less server-side ones made while verifying owner-facing pages during
development. The owners page read that field, so accounts that had never once
been signed into showed a current date — and rian reasonably read that as an
intruder using Joan's account.

- `LoginEvent.source` (`web` / `internal` / `wordpress`), migration `0004`,
  which also backfills every existing row.
- **The test that mattered:** my first implementation keyed on
  `request is None` and was WRONG — Django's test client passes a bare
  `HttpRequest()` with empty META, so the request exists and tells you nothing.
  The real discriminator is what production actually showed: **no IP and no
  user-agent**. A browser always sends both.
- The owners page and the "Active" filter now annotate `last_seen` from
  LoginEvent and ignore `last_login` entirely.

### WordPress sign-in history imported

WordPress core keeps no last-login date. **Wordfence does** — `wp_wfLogins`.
New `import_login_history` command (file-based, like `import_wordpress`; the
portal never connects to the WordPress database). Imported 165 / 437 / 197
events. Unmatched names are accounts deleted from WordPress whose Wordfence
history survives — verified, not guessed.

Real activity, first time this has been visible: **shoreclub 32 of 49 have
ever signed in, 8 in the last year · sands 72 of 130, 14 · palms 40 of 138,
6.** Joan's genuine last sign-in on palms was **2021-03-11**, not 2026.

### Security pass — three real gaps closed

1. **No throttling on the sign-in form.** Downloads were rate-limited since
   the start; the login form was not. Every owner password is the one migrated
   from WordPress, and those sites were not under our control — an unthrottled
   form is the cheapest route to the documents. `accounts/throttle.py`: 10
   failures per account+address and 30 per address in a sliding 15-minute
   window, built on the LoginEvent rows that already existed (no cache server).
   Refused **in the backend**, so a correct guess while locked out still fails;
   the page says "wait", not "wrong password".
2. **Cross-tenant file visibility.** Every instance mounted the whole `data/`
   directory, so the Palms container could list the Sands' documents. Three
   separate condo corporations — the boundary existed in the database but not
   on disk. Each service now mounts only `./data/<site>` plus a read-only
   `import`. Verified live: each container sees only its own tree.
3. **No HSTS.** Added (1 year, includeSubDomains, no preload — preload covers
   the parent domain and is not ours to declare), plus `Referrer-Policy`.
   `X_FRAME_OPTIONS` was left at SAMEORIGIN **deliberately**: the detail page
   frames its own PDF preview, and tightening to DENY would have broken it —
   caught before deploying, and now pinned by a test that says why.

Verified clean: anonymous access to every document/board/staff URL redirects to
login; private files unreachable by any path; `DEBUG=False`; distinct
SECRET_KEY per instance; separate media roots; secure+httponly cookies;
downloads carry `X-Robots-Tag: noindex` and `Cache-Control: no-store`; the
legacy WordPress copies bind only to `172.17.0.1` and hold no Caddy route.

### ⚠ Findings on the LIVE (.com) WordPress sites — rian's to act on

- `thesandsowners.com/wp-content/uploads/…/TheSandsAtGraceBay_Newsletter2018.pdf`
  returns **200, application/pdf** with no authentication.
- Palms upload PDFs now return **302** (protected), but Google holds indexed
  entries whose titles were taken from the documents' contents — including a
  name with an age and a residential address. Protecting the file does not
  remove the index entry.
- **Order matters:** block access first, then request removal in Search
  Console. Adding `robots.txt Disallow` first stops Google re-crawling and
  seeing the block, which can freeze the entries in the index instead.
- `thepalmsowners.com` was serving a **"Database connectivity issue"** page
  during this check.

The rebuild does not have this failure mode: documents have no public URL at
all, are served only through an authenticated view, and carry noindex headers.

### Still open
- **Force a password reset for every owner at launch** — migrated hashes come
  from sites with a known compromise. This is the largest remaining risk.
- `shoreclubowners.demoing.info` currently has `auth_enabled: false` (no
  shared-password gate). Deliberate for Joan's access; confirm before launch.
- OCR for the ~70 scanned documents with no text layer · DigitalOcean setup.

## 2026-08-28 — v0.33.1: the shine, and walkthrough revisions

**319 tests green**, deployed, audit 0 critical. TOUR_VERSION 2 → 3, so the
walkthrough re-offers itself to anyone who has already seen it.

**The shine is the wavefront, not an imitation of it.** rian asked for the
sweep to "ripple through the logo area" as it crosses the middle of the screen.
The trick that makes this exact rather than hand-tuned:
`background-attachment: fixed` positions the highlight against the **viewport**,
so giving it the same angle, size and start position as the sweep's mask makes
the logo block a window onto the same moving edge. No timing to guess and
nothing to re-tune at other window sizes. JS reads the sweep's OWN delay and
duration off the animation object and hands them over as
`--shine-dur`/`--shine-delay`, so the quicker entrance sweep and the slower
rotation both stay in step for free. Where `background-attachment: fixed` is
unsupported (notably older iOS Safari) it degrades to sweeping the block on its
own — still a shine, just not aligned. Brightness `rgba(255,255,255,.11)`,
halved from .22 after rian saw it live.

**One function now starts a sweep.** `sweepIn(layer, isIntro)` adds the
classes, triggers the shine, and RETURNS the length, which removed the last
hard-coded `3600` from the rotation loop. The shine cannot fall out of step
with the sweep because it is handed the sweep's own numbers.

**Walkthrough (5 changes rian asked for):**
- "What staff can do" merged into the owner-view switch step — one idea, one
  step. Still 12 steps: one merged away, one added.
- The library / board / owners intros now point at their **nav item** instead of
  spotlighting a whole region.
- New step after reordering: adding a board member (`#board-add`).
- Nav links gained `id="nav-documents|nav-board|nav-owners"`. Stable ids rather
  than `a[href=…]` because the steps live in a **single-quoted** `data-steps`
  attribute — an attribute selector would need a quote character that closes
  either the attribute or the JSON string.

**A step may now carry `targets` (a list), and the engine picks the first one
actually LAID OUT.** Pointing at the nav introduced a phone bug: the nav folds
into the hamburger, so `#nav-documents` still EXISTS and the old
existence-only check would have spotlit a zero-sized box in the corner. Each
nav-pointing step falls back to what it used to target (`#browser`,
`.board-grid`, `.search-field`), so nothing is lost on a narrow screen. Pinned
by `test_a_step_pointing_at_the_nav_offers_a_fallback`.

**Next:** OCR for the ~70 scanned documents with no text layer · DigitalOcean
production setup.

## 2026-08-28 — v0.32.0: entrance once per session, no white box, half the hold

**318 tests green**, deployed, audit 0 critical.

**The white box.** rian: *"I see a white box, then it fades into the white
box."* `.login-card` carried `background: var(--surface)`, and during the hold
the card is only as tall as its brand block — so a white card sat on the ground
while the brand block (which is what fades) was still at opacity 0, and the
logo appeared to fade INTO it. The white belongs to the form, not the frame:
background moved to `.login-body`, and the card's box-shadow moved into the
`login-open` keyframes so the frame arrives with the form rather than before
it. Now the ground is untouched until the whole logo box fades up out of it.

**Half the hold:** `--hold: 1s → .5s`. Because the other beats are
`calc(var(--hold) + …)` they followed on their own — logo 0→500ms, panel
680→1180ms, sweep 700→1500ms. That is the payoff for the property; nothing else
was edited.

**The entrance now plays ONCE per browsing session.** It is a greeting, and it
was standing between someone and the form on every back-button return and every
re-render after a mistyped password. A `sessionStorage` check sets
`no-entrance` on `<html>`; CSS cancels all three beats and the scene skips the
intro sweep. **The check must sit in the HEAD** — the beats are CSS animations
that start at first paint, so deciding any later would flash a frame of the
opening state before it could be cancelled. `sessionStorage` is the right
lifetime (per tab, cleared on close) and the access is wrapped in try/catch, so
private mode just plays the greeting rather than breaking the page. Verified in
a real browser session: visit 1 animates and stores the key; visit 2 has
`no-entrance`, both animations `none`, card already open, photograph already
shown. This also closes the `form.errors` question raised in v0.31.2 — no
separate case needed.

**Next:** OCR for the ~70 scanned documents with no text layer · DigitalOcean
production setup.

## 2026-08-28 — v0.31.2: the entrance holds on the logo

**317 tests green**, deployed, audit 0 critical. rian found v0.31.0 "too fast"
and asked for the logo to fade in softly over about a second and be held there,
with the rest of the entrance unchanged behind it.

**One property drives all three beats.** `--hold: 1s` on `main.wrap.login-page`;
the logo's fade IS the hold (`animation: login-mark-in var(--hold)`), and the
other two are `calc(var(--hold) + …)`, so changing the hold moves the entrance
together instead of leaving three literals to drift. Measured on a real load:
logo 0→1000ms, panel 1180→1680ms, sweep 1200→2000ms. The rise was cut 9px→5px
— over a full second that much travel reads as drift rather than a fade. The JS
promote-time is now read off the animation object rather than repeated as a
constant.

**The hold exposed a mask-geometry bug that had been there all along.** The
sweep's "hidden" start was not hidden: at `mask-size: 300%` and start position
`100%`, the viewport maps onto 66.7–100% of the gradient, which is only fully
transparent from 78% — so the left third of the screen showed a band of the
incoming photograph *before* the sweep ran. At the old 200ms delay nobody could
see it; holding on the logo for a second made it obvious, and it had been
flashing on every rotation transition too. Start moved to `120%` (past 78%)
plus `mask-repeat: no-repeat` so the region beyond the mask stays transparent
rather than tiling back to opaque. Pinned by
`test_the_sweep_starts_completely_hidden`, which computes the gradient fraction
rather than asserting a magic number.

**Verification method that finally worked — seek, don't sample.** Screenshots
under `--virtual-time-budget` cannot time CSS animations (established in
v0.31.0). The reliable technique: load the page, **pause every animation and
set `currentTime`** to the wanted moment, then screenshot — deterministic and
clock-independent. One trap: the promote timer is not an animation, so pausing
animations does not stop it; once it fired the layer went `is-front` and every
seeked frame showed the finished photograph. Stubbing `setTimeout` for delays
≥1s held it off, and only then did the left-edge band become visible.

**Note for rian (not implemented, his call):** the entrance now runs on EVERY
render of the sign-in page, including the re-render after a wrong password —
a 2s wait when someone is mid-task. Skipping it when `form.errors` is set is a
few lines if wanted.

**Next:** OCR for the ~70 scanned documents with no text layer · DigitalOcean
production setup.

## 2026-08-28 — v0.31.0: the sign-in entrance

**316 tests green**, three instances deployed, audit 0 critical. rian's ask: on
first load, logo first on solid colour, then the sign-in expands, then the
first photograph sweeps in — **"not more than a one second process"**.

**Three beats, and the design was already half-built.** `.login-brand` is
`var(--masthead)` — the same colour as the scene's ground — so before a
photograph arrives the brand block reads as a mark floating on solid colour,
not as a panel. That gave beat one for free.
- **0–400ms** `.login-brand` fades and rises (`login-mark-in`).
- **180–680ms** `.login-card` opens by animating its **grid row** `auto 0fr →
  auto 1fr` (`login-open`). A row, not a scale, so the form unfolds instead of
  being squashed; and because the card is only as tall as its brand block to
  start with, there is never an empty white box on screen. Padding moved to a
  new `.login-body-inner` so the row can actually collapse to nothing.
- **200–1000ms** the first photograph sweeps in on the SAME mechanism as every
  later transition, just quicker (`.is-intro`: 200ms delay + 800ms), then hands
  off to the 3600ms rotation sweep.

**The opener choice moved to the server** so the photograph can be **preloaded
in the head** (`rel="preload" as="image" fetchpriority="high"`). The entrance
cannot spend its budget waiting for JS to decide which file to ask for.
`opening_url()` replaces `opener_urls()`; the template sends `data-opening`
and the shuffle just honours it. Real fetch measured 0.12–0.17s for the
135–360KB scene JPEGs, so the preload comfortably covers the 200ms delay.

**Two real bugs found while verifying, both invisible to a green suite:**
1. **Reduced-motion lost the cascade.** The `prefers-reduced-motion` override
   sat ABOVE the new rules; a media query adds no specificity, so the later
   rule won and the entrance still animated for someone who had asked for
   stillness. Moved below, and pinned by `test_stillness_wins_over_the_entrance`.
2. **`load()` unblocked without reporting readiness.** The 2.5s guard resolved
   even when the bytes had not arrived, so the sweep animated an empty layer
   and popped the image in afterwards — the jolt again, in a new costume, on
   slow connections. It now resolves with WHETHER the pixels are there
   (`img.complete && naturalWidth`, so a decode-stalled hidden tab still counts
   as ready). The intro shows plainly instead of sweeping nothing; the rotation
   skips that photograph entirely.

**Measurement note — virtual time cannot time CSS animations.** Two filmstrips
captured with `--virtual-time-budget` showed frames that could not be real (a
1500ms frame in an 80ms state, photographs absent at 1.5s). Chrome fast-forwards
timers and JS but the compositor's animation clock does not follow, and each
headless run also drew a different random opener. Diagnosing from those frames
would have chased ghosts. What actually settled it: reading the **Animation
API** in-page — `is-intro` present with `delay: 200, dur: 800` on the first
arrival and `delay: 0, dur: 3600` on the second, identical across three runs —
plus an in-page `performance.now()` sample of beats one and two (logo
0→0.49→0.96, panel 0→194→315→320px). *Measure animation from the animation
objects, not from screenshots of a fast-forwarded clock.*

**Also:** the tour question is closed — rian confirmed owners do not need a
walkthrough, so the long-standing "owner-facing tour" item is dropped, not
deferred.

**Next:** OCR for the ~70 scanned documents with no text layer · DigitalOcean
production setup.

## 2026-08-28 — v0.30.0: the jolt REALLY fixed (stacking), opener photos

**313 tests green**, all three instances deployed, migration `0012` applied,
audit 0 critical.

**The jolt was never the animation — it was stacking.** v0.29.1 rebuilt the
scene on two layers and I certified it 18/18 with a headless harness. rian
still saw it, and gave the decisive detail: *"nice, jolt, nice, jolt"* —
alternating. The two layers swap roles each cycle, and `.login-layer` carried
**no z-index**, so stacking fell to DOM order: the first layer always painted
underneath the second. Every second transition swept perfectly *behind* the
opaque outgoing photograph, and all you saw was the old one blink off.
Fix: `is-arriving { z-index: 2 }`, `is-front { z-index: 1 }` — the arriving
layer is above the one it replaces whichever way round they are.

**The testing lesson, which is the real one here.** My v0.29.1 harness asked
"did the animation run and is the mask applied?" — both were TRUE on the
broken cycles. It never asked "can you SEE it". A green test certified a bug
the user was staring at. The harness now reads real paint order via
`elementsFromPoint` and asserts the arriving layer sits above the outgoing
one. Negative control (z-index forced back to `auto`): **9/18 clean —
exactly 50%, exactly the reported alternation**, with `sweep: "login-sweep",
masked: true` on every failure. With the fix: 18/18. Pinned in Django by
`test_the_arriving_photograph_stacks_above_the_one_it_replaces`.
*Rule: when the complaint is visual, the test must measure what is visible,
not the mechanism believed to produce it.*

**Openers.** A photograph can be lovely in the rotation and a poor first
impression (rian's example: a close-up of food). `LoginPhoto.is_starter`
(migration `0012`) + a ☆/★ toggle on each card in `/staff/scene/`. The login
view sends `data-openers` alongside `data-photos`; the shuffle pulls a random
opener to position 0 and leaves the rest of the order alone. **Deliberate
fallback:** `LoginPhoto.openers()` returns ALL photographs when none are
marked, so the scene works uncurated and a fresh install is never blank.
Marking is "may go first", never "only appears first" — the photo stays in
the rotation. Verified 10/10 loads opened with the single marked photo.

**Card layout:** the caption row was restacked — with a third control added,
every filename collapsed to an identical `sands-…` and you could not tell one
card from another.

**⚠ I destroyed two of rian's opener marks on the Sands.** After testing the
toggle I "cleaned up" with a blanket
`LoginPhoto.objects.filter(is_starter=True).update(is_starter=False)`. It
cleared 3 — one mine, two rian's, marked while I was working (he was using
the feature live: Shore Club had grown to 27 photographs). No history table,
so they were unrecoverable; he re-marks them by hand. **Rule: delete test data
by primary key, never by filter, on a system someone is actively using.**

**Tutorial status (rian asked what was outstanding):** the revisions he had in
mind — spotlight not clearing the highlighted area, and making staff-only
features obviously staff-only — both landed in v0.28.0. The one genuinely open
tutorial item is an **owner-facing walkthrough**: the tour is staff-only
(12 steps, 3 pages) and owners get no tour markup at all, which two tests pin
deliberately. Joan's owners currently sign in with no guidance.

**Next:** owner-facing tour (if wanted) · OCR for the ~70 scanned documents
with no text layer · DigitalOcean production setup.

## 2026-08-28 — v0.29.1: photo manager, sweep fixed, hidden-tab backdrop

**306 tests green**, all three instances deployed, `security-audit` clean
(0 critical, no hartlingowners findings).

**Photo manager (rian's ask: "accessable for people who know the upload URL").**
New `LoginPhoto` model (migration `0011_loginphoto`) — the login backdrop now
comes from the DB, not a static filename convention. Staff-only manager at
**`/staff/scene/`**, deliberately UNLISTED (linked from no menu; a test pins
that it appears in no nav). Multi-upload; `_fit_scene()` normalises every file
to 1800×1200 JPEG q68 via `ImageOps.exif_transpose` + `ImageOps.fit`, so an
unprocessed phone photo can't blow up the page weight or arrive sideways. An
unreadable upload is named back, not crashed on. Delete refuses to remove the
LAST photograph (an empty rotation would render a blank sign-in page).
`import_scene_photos --prefix <name>` seeded the existing sets — Sands 7,
Shore Club 8, Palms 8.

**The photo endpoint is the app's one deliberate public file.** `login_photo`
is NOT `@login_required` — the sign-in page is unauthenticated, so its backdrop
must be too. Everything else stays behind `PrivateMediaStorage`. Caught during
the build: `@login_required` belonging to `board_photo` had landed directly
above the new view and would have gated the backdrop; removed before deploy.
A test asserts non-scene pks 404 there, so the endpoint can't become a general
private-file reader.

**The "sweep only works ~50% of the time" jolt — root-caused and fixed.**
Live MutationObserver showed a slide receiving `.is-on` while still carrying
`.is-leaving`; `.is-leaving` wins the cascade (`animation: none;
mask-image: none`) and silently cancelled the sweep. Cause: two racing timers
(3900ms cleanup vs 6000ms cycle), and timer throttling decided the winner.
Rebuilt on **two layers with mutually exclusive roles** (`is-front` /
`is-arriving`, never both on one element), driven by ONE sequential async loop
— there is no second writer of those classes, so coexistence is impossible by
construction. Only two full-size images are ever in memory, however many
photographs are in the rotation.

*Measured, not eyeballed:* the shipped page was run under headless Chrome with
`--virtual-time-budget` and watched from outside — **18/18 transitions clean**.
Negative control (a stylesheet rule reproducing what `.is-leaving` did) scored
**0/18 clean** with the user's exact symptom (`sweep:""`, `mask:"none"`), so
the harness genuinely detects the failure it certifies absent.

**Hidden-tab backdrop bug found while verifying.** Chrome never settles
`img.decode()` in a BACKGROUND tab, and the first paint awaited it — so opening
sign-in in a background tab (cmd-click, session restore) left BOTH layers at
opacity 0: no backdrop at all until the tab was focused. Decode is an
optimisation and must never be load-bearing; `load()` now resolves on any of
three paths (decoded, image error, or a 2.5s guard) and can only settle once.
Pinned by `test_the_backdrop_never_waits_forever_on_a_decode`.

**Next:** the tutorial content revisions rian deferred ("one thing to fix
before we do any revisions on the tutorial"). Then still open: OCR for the ~70
scanned documents with no text layer; DigitalOcean production setup.

## 2026-08-28 — v0.28.0: spotlight fixed, staff flags, login set ×23, rail aligned

**296 tests green**, deployed; spotlight + alignment verified in rian's
browser at his real width.

**Spotlight bug:** `.tour-overlay` painted rgba+blur over EVERYTHING —
including the target; the spot only cut a hole in its own box-shadow, so the
"highlighted" element was as dim as the page. Fix: overlay is transparent
while a target is spotlit (the spot's 9999px shadow does the dimming — a hole
in the thing that dims); overlay gains `.is-dim` only for centred panels
(welcome/finale). NO blur anywhere: a backdrop-filter cannot be punched
through. Verified on the owner-view-switch step: target fully lit.

**Staff awareness:** `.staff-flag` chip ("Staff only") on the uploader, board
tools, add-owner box, Owners + Trash page heads, and a tiny `nav-staff` label
before the Owners/Trash links. Walkthrough copy reinforced on every staff
step ("owners never see this…"). Tests pin: flags present for staff, absent
for owners AND in owner view.

**Login photos:** 23 frames (Sands 7 · Shore Club 8 · Palms 8), all from
native ≥1900px sources — `Rope-Bar-from-pool.jpg` turned out to be **807px
upscaled** (the pixelation rian saw) and was replaced. Order shuffled
per visit (Fisher-Yates; the server's first `is-on` handed to the shuffled
winner so no-script still works). Per-property `PORTAL_LOGIN_COUNT`.
Contact sheets used for curation; rejected: duplicates, an "UPDATE" stamp
graphic, wedding/crowd shots.

**Rail alignment:** `.cabinet` gets `--edge: max(20px, calc((100vw - 1020px)/2
+ 20px))` (the masthead .wrap is 1020/20); rail column = `--edge + 244px`,
rail-inner padding-left = `--edge - 12px` (labels carry 12px of their own).
Measured in-browser: logo left 149 == rail text left 149 at vw 1278. Below
1060px it degrades to the old geometry; drawer untouched.


## 2026-08-28 — v0.27.0: staff walkthrough live; login motion retuned

**294 tests green**, deployed, walkthrough exercised live in rian's browser
(welcome personalised "Welcome, rian!", step 1 spotlights and renders).

**Login motion:** cycle 7s→6s, sweep 2.4s→3.6s; the photograph moved to its
own always-animating layer (`.login-drift`, 28s ease-in-out infinite
alternate, staggered per frame) so the drift NEVER stops — the jolt rian saw
was the one-shot transform finishing. Slide classes only touch opacity/mask;
they can no longer restart the movement.

**Walkthrough (v2, rian's script):** engine re-enabled (`TOUR_ENABLED=True`,
`TOUR_VERSION=2` — v1 testers re-offered). 12 steps over library/board/owners
in `_tour.html`; welcome "Welcome, <first name>!" and finale "That's it!" in
`tour.js` (name via `data-first-name`). Included only when `staff_ui` — owners
never get the markup, and owner-view suppresses it mid-demo. Retrigger: "Site
walkthrough" in the profile menu (`[data-start-tour]`, hook already existed).
**Any dismissal records seen** (×/Escape/Not now) — v1's complaint was the
re-nagging.

**Three bugs found by watching it run, not by the suite:**
- `${firstName}` referenced but the `const` insertion had silently no-op'd →
  ReferenceError → EMPTY welcome bubble. (Another unasserted `str.replace`.)
- Step 1 targeted `.page-head`, which the Cabinet redesign renamed on the
  library — engine silently skipped to step 2.
- My "every target exists" test PASSED anyway: the page embeds the steps JSON,
  so the selector string was always present — **circular validation**. Fixed
  by cutting `#tour-root` out of the body before asserting, then seeding the
  fixture (several targets only render when content exists). The test caught
  a real miss (`.board-grid` on an empty board) on its first honest run.

**Note:** rian's own tour_seen is now recorded (I closed it during
verification) — he reviews the full run via the menu link, and it will
auto-open fresh for Joan's account.


## 2026-08-28 — v0.25.0 owners polish · v0.26.0 sign-in scene

**288 tests green**, both deployed; sign-in verified live in rian's browser.

**Owners page (rian's testing list):** live filter as you type (rows carry
`data-search`; Enter still submits for server counts) · Generate new password
answers `X-Requested-With: XMLHttpRequest` with JSON and shows in a dialog
beside the row (plain POST stays as the no-script path) · add-owner box
reopens ONLY on error (`?add=1` — it was `{% if messages %}open`, which is why
generating a password scrolled up AND opened the add box) · buttons renamed
"Email a password reset" / "Generate new password" · ≤760px the table becomes
stacked cards; emails get word-break so rules can't stagger · **invite email**
is its own template (`registration/invite_email.txt`): subject "You have been
invited to <portal>", body "You've been invited… Click here to set your
password." — the reset email untouched, and a test pins BOTH.

**`&amp;` root cause:** WP exports arrive titles-escaped once; two Palms
titles stored `&amp;` literally. Un-escaped in data (2 fields) and
`import_wordpress` now `html.unescape`s titles/summaries/category/board names
so a re-sync can't reintroduce it.

**Board:** `.member-stage` carries the property texture (`--rail-image`,
multiply) — same language as the browse panel.

**Sign-in (v0.26.0):** full-screen slideshow of the property's own photography
(`static/login/<prefix>-N.jpg`, per-instance `PORTAL_LOGIN_PREFIX`/`COUNT`,
4×3 properties prepared at 1800×1200 q66). No masthead/footer on this page —
base.html gained `{% block masthead %}`/`{% block footer %}`. The brand moved
INSIDE the card as a masthead-coloured band (white-only logos need the dark
field). Transition = feathered diagonal mask sweep + slow settle
(`login-sweep` keyframes), 7s cycle; `prefers-reduced-motion` → still frame.
Link is "Reset your password"; the redundant heading/instructions are gone.

**Traps:** three tests pinned the old login markup (including the branding
tests, which used the login page to check the masthead logo — now they check
`login-mark`/`login-word`). And another silent no-match `.replace()` (12- vs
14-space indent) — the regex-with-assert pattern is the rule now.


## 2026-08-28 — v0.24.0: board rebuilt to rian's synthesis (A+B hybrid)

**277 tests green**, deployed to all three, verified: Sands desktop (all
photos), Palms (1 of 9 with photo — the true mixed case), phone, expanded.

**The design** (rian's spec, from the canvas exploration): one uniform card
per member — no featured lead. `has-photo` card = 1/3 matted whole portrait
(aspect 4/5, `--panel` matte) + 2/3 text; bio clamps to ~portrait height with
a mask fade and "Read the full profile" expands inline (button hides itself
when the bio fits — nothing hidden, no button). No photo → single-column
full-width text, NO placeholder (initials pattern removed from the grid; it
survives only as tiny thumbs in the staff reorder dialog). Accent left rule
on every card. The bio POPUP is gone. Phone: photo stage stacks above text,
300px tall.

**`BoardMember.bio_html`** normalises the two storage formats: imported bios
are plain text (escape + linebreaks → <p>), editor bios are already
clean_html HTML (passed through). Test pins the escaping.

**Data cleanup:** 2 Sands bios opened with an echo of the role/name line from
the WP page headings; stripped in data (idempotent regex on title/name
prefixes), not at render.

**⚠ Template-surgery incident (recovered, lesson recorded):** a python edit
sliced `s[start:end]` where `end` was found with `s.index(...)` WITHOUT a
start offset — the needle matched EARLIER in the file, end < start, slice =
'' , and `str.replace('', new)` inserted the replacement between EVERY
character: board.html went 12KB → 8MB. Nothing noticed until the suite read
the rendered page. Rebuilt the file from scratch (its full content was in
session context). Rules: always pass a start to `index` when slicing forward;
assert `end > start`; never `replace()` with a possibly-empty needle. No git
here — the session transcript was the only backup.


## 2026-08-28 — outbound email live via Resend

All three portals now send real email: Resend SMTP (`smtp.resend.com:587`,
TLS, user literally `resend`, key as password), From
`<Property> Owners <noreply@hartlinggroup.com>` per instance (domain verified
in rian's Resend account). No settings changes were needed — the env-driven
SMTP block existed; this was config only.

**The key lives in `/srv/apps/hartlingowners/.email.env`** (0660
rian:hartlingowners-dev, same pattern as `.app.*.env`), consumed via
`env_file` by the three WEB services only — the db services never see it. One
file, one rotation point. Filled by rian himself; never in chat, never read
back — verified by `grep -c REPLACE_WITH_` = 0 and by behaviour.

**Verified end-to-end:** `send_mail` from the Sands container → SMTP handoff
accepted (1); real password-reset POST → 302, clean logs; rian receives.

**Two traps hit and worth keeping:**
- `env_file` is read at container CREATION. rian filled the key and tried a
  reset — nothing sent, because the running containers still held the
  placeholder. `srv-gw deploy` (recreate), not restart-only thinking.
- `setup_test_environment()` swaps the email backend for the in-memory one —
  a "send" through the test client after it goes into `mail.outbox`, not out.
  My first reset trigger silently sent nothing; re-ran without it.

This also closes the earlier flag: the console-backend fallback no longer
writes reset links into container logs — SMTP is active.


## 2026-08-28 — v0.23.1: pill only in Flat; dark-mode panel fixed

**269 tests green**, deployed.

**Category pill:** `hide_category` was `group_by == "category"`, which predates
the rail — a rail-picked category is `group=date&category=<slug>`, so every row
still carried a pill repeating the page title. Rule per rian: the pill shows
ONLY in Flat (`hide_category = group_by != "all"`). An old test asserting the
date-view-shows-pill rule was updated (rule change, noted in its docstring).

**White-on-cream Browse on phones:** dark mode. `--panel` derives from
`--brand-ground`, which is per-instance and never flips — so dark phones got
the light panel under dark-mode (light) text. The dark block now sets
`--panel: #1a2124` (a step above the dark ground); ink 14.4:1, ink-2 8.0:1 on
it, and `--panel` joined the dark-mode contrast test's surface list.

**Method:** `--force-prefers-color-scheme=dark` does NOT work in this host's
headless Chrome — the render silently stays light. To see the dark theme,
rewrite the CSS media query to `@media all` on a copy and render that.

**Trap this exposed:** any token derived from a per-instance brand value is
light-mode-only unless the dark block overrides it — same class of bug as the
accent collapse fixed in v0.17.0. When adding a brand-derived token, add its
dark counterpart in the same commit.


## 2026-08-28 — v0.23.0: one-line phone masthead, Browse strip

**268 tests green**, deployed, phone states proven via the iframe technique
(closed / menu open / drawer open).

**Masthead ≤620px:** burger · centered logo · avatar on ONE line; the nav is a
drop-down panel under the bar (absolute, masthead colour, vivid bottom rule,
48px targets, Escape/click-away/link-tap closes). Markup restructured: the
usermenu moved OUT of `.site-nav` into `.header-right` so the avatar stays on
the bar while the links drop — desktop renders identically.

**Browse ≤900px:** no longer a rounded pill — a full-width strip in the
panel's own shade (`var(--panel)`, hairline bottom), label left, chevron-right
at the edge signalling the side drawer. One hamburger per screen, and it
belongs to the masthead.

**Bug caught by measuring:** I gave the strip `-28px` bleed margins assuming it
sat inside the pane's padding — it is a grid child of `.cabinet`, so the
negative margin dragged it under the sticky masthead (probe: stripTop=51 vs
headerBottom=79). In a grid, a child's negative top margin overflows its row
upward; the row still holds its space. The strip needs `width:100%; margin:0`
— the cabinet is already full-bleed.


## 2026-08-28 — v0.22.0: masthead-mirrored footer + owner-view switch

**265 tests green**, deployed, exercised end-to-end in rian's browser
(toggled on, verified the owner render, toggled back off — his session ends in
staff view).

**Footer** mirrors the masthead: same `--masthead` field, `--vivid` rule on
top, centered logo at 80% masthead size, version at 11px/38% white.

**Owner-view switch** (footer, next to the version; real staff only). It is a
**server-side rendering flip**, not CSS hiding: session flag `owner_view`;
`staff_ui(request)` in `config/context_processors.py` is the single effective
flag; every template that branched on `user.is_staff` now branches on
`staff_ui`, and the two view-context branches (library `_upload_context`,
board `removed`) follow it. **Not an authz boundary** — every privileged
endpoint still checks real `is_staff`, so flipping it can never lock rian out
(test pins this). The switch itself renders on real `is_staff`, never
`staff_ui` — otherwise there is no way back. Turning it ON from a `/staff/`
page redirects to the library, since owners cannot stand there; `next` is
refused unless it is a local path (`/…`, not `//…`).

**Bug caught in-browser before rian saw it:** a wrapped `{# … #}` template
comment — Django's `{# #}` is single-line only, so it rendered as footer text.
Now `{% comment %}`, plus a test that greps the page for comment leakage.

**Trap for later sessions:** detail/serve trashed-and-unpublished visibility
(documents/views.py) still follows REAL staff, deliberately — the switch
changes UI surfaces only.


## 2026-08-28 — v0.21.2: textures corrected to the resort register

**254 tests green**, deployed, verified in rian's own browser (zoomed).

Rian's side-by-side (portal sidebar vs the resort's own use of the same tile)
exposed two faults: **I upscaled the tiles** (89px art at 150px = pixelation;
`PORTAL_RAIL_TILE` is now `auto` = native size, crisp), and **I spent the whole
contrast budget**. The ≥220 legibility floor is a CEILING, not a target — the
resorts run their own textures at a fraction of it. Now: Palms wave max
darkening 8/255 (~3%), Shore Club quatrefoil 7/255 (~2.7%); before: 34 and 16.
Sands fronds unchanged (18/255) — rian has not flagged it since the redo.

**Rule worth keeping: when copying a brand element, copy its USAGE (scale,
opacity), not just its file.** The file was right both times; the rendering
wasn't theirs.

Also this session: rian "not seeing changes" was tab staleness — open tabs hold
the old HTML which references the old hashed CSS; a plain reload suffices.
Verified live in his browser via claude-in-chrome (all three properties on the
new fonts after the &quot; fix). Cloudflare serves the gated HTML as DYNAMIC /
no-store, so no server-side cache exists to purge.


## 2026-08-27 — v0.21.0: real fonts, real palettes, resort textures

**253 tests green**, deployed to all three.

**⚠ `srv-gw db-query --project <resort site>` READ THE WRONG DATABASE.** It
reported thepalmstc as `twentytwentyfive` with 3 published posts; the container
and its `.env` both say `wp_thepalmstc`, which is `kadence-child` with 44 pages
and a full palette. Two conclusions in this project were wrong because of it.
**Read these sites through their container** —
`docker exec <site>-wordpress wp --allow-root option get …` — not through
db-query, until the gateway discrepancy is understood. Worth an ops ticket.

**Recovered per property (from the containers):**

| | display | body | brand colour | paper |
|---|---|---|---|---|
| The Sands | EB Garamond | Source Sans 3 | `#00b3b0` | `#f6f3ec` |
| The Shore Club | **OptimaLTStd** | Open Sans | `#ff7760` | `#faf7f2` |
| The Palms | Cinzel | Didact Gothic | `#739ac6` | `#fbf8f1` |

The Palms' accent `#4e6987` and masthead `#222e3b` are its own blue darkened
until text passes — its earlier green was my invention and is gone.

**OptimaLTStd** is vendored from `theshoreclubtc/.../OptimaLTStd-regular.woff2`
with rian's explicit go-ahead (licensed Linotype face; the licence question was
raised and answered). `@font-face` in `portal.css`; only the instance whose
`--serif` names it ever loads it.

**Textures replace the photo watermark.** Resort background tiles, not pictures:
Palms `wave_tr_bg.png` (its luminance, not its alpha — the alpha is nearly
uniform and extracting it yields a flat file), Shore Club `bg-pattern.jpg`
(quatrefoil, echoes their medallion). Both squeezed into the same ≥220 band and
shipped as **PNG**, which sidesteps the JPEG overshoot that bit the watermarks.
`--rail-size` / `--rail-repeat` switch the panel between one covering
photograph and a repeating tile.

**Open:** The Sands still shows its photo watermark — rian pointed at "palm
leaves on the Grace Bay Beach section" but the section carries no `bgImg`, no
custom CSS background, and no leaf asset that route surfaces. Asked him to
point at it.


## 2026-08-27 — v0.20.0: resort typefaces and full-strength header accents

**253 tests green**, deployed to all three.

**Recovered from the resort sites' own settings** (`theme_mods_kadence-child`,
serialised PHP — parse with a regex for `s:6:"family"`):

| | headings | body | footer colour |
|---|---|---|---|
| The Sands | EB Garamond | Source Sans 3 | `palette2` = **#128c8c** |
| The Shore Club | **OptimaLTStd** | Open Sans | `palette1` = **#ff7760** |
| The Palms | — | — | — |

**The Palms' local site is on stock `twentytwentyfive`**, not Kadence — no
palette, no typography, no global styles. Its brand is not recoverable from
this box; rian has been asked for the blue he means and for its fonts.

**OptimaLTStd is licensed (Linotype) and self-hosted** at
`theshoreclubtc/wp-content/uploads/2025/03/OptimaLTStd-regular.woff2`. Not
copied — Marcellus stands in until rian confirms the licence covers the portal.

**Why the vivid colours are accents, not backgrounds.** White on #ff7760 is
2.61:1 and on #00b3b0 is 2.60:1 — neither can sit behind white text, and the
logos are white-only so the bar cannot go light. The masthead therefore stays
dark and takes the colour as a 3px rule plus the active nav link. Shore Club's
bar moved #3c3e40 → #2f3133 so its orange clears 4.5:1 on it (4.12 → 5.01).

`--header-h` grew to `+ 35px` for the thicker rule; `MastheadAccentTests` pins
all of it, including a guard that fails if a vivid colour ever becomes legible
enough behind white text to tempt someone into reversing the decision.

**Note:** `PORTAL_FONT_CSS` holds a literal `&`. Django escapes it into the
tag; pre-escaping in compose double-escapes and the fonts silently fall back.


## 2026-08-27 — v0.19.0: watermark, near-flat panel, two bugs rian's screenshot caught

**248 tests green**, deployed to all three.

**Panel** is now `--brand-ground 97% / --ink 3%` — near-invisible as a tint; the
`border-right: 1px solid var(--line)` does the separating. 88% read as heavy.

**Watermark, not emboss** — rian's correction, and the right word. Recipe:
`-colorspace Gray -normalize -sigmoidal-contrast 4,50% +level <floor>%,100%`,
no blur, `background-blend-mode: multiply`. `.rail-inner` took `height` instead
of `max-height` so the picture fills the whole visible column — it had been
confined to the content box, which is why rian saw it start below the year
list.

**The tonal band is the safety mechanism and it is measured:** multiply can
only darken, and the faintest rail ink (`--ink-2`) clears 4.5:1 only while the
watermark stays at or above **220/255**. Anything darker fails on all three
properties.

**JPEG broke that silently.** Levelling the source to 220 produced files whose
actual minimum was **209–215** — compression overshoots at edges. The floor is
now found per image by encoding and re-measuring until the real minimum clears
220 (Sands 88%, Shore Club and Palms 89.5%, quality 94).
`RailWatermarkTests` opens the SHIPPED images with Pillow and fails if any dips
below 220 — the recipe is not the guarantee, the file is.

**Two bugs visible in rian's screenshot:**
- `total` is the count AFTER the category filter, so the rail's "All documents"
  reported the open collection's size. Added `library_total` from the unfiltered
  queryset.
- `.year-count` positions with `margin-left: auto`, which needs a flex parent —
  only `.year-label.has-colour` had one, so the by-year view rendered
  "2026 1". Flex moved onto the base rule.


## 2026-08-27 — v0.18.3: the browse panel goes light and embossed

**245 tests green**, deployed to all three.

The dark panel made the site feel heavy. It is now
`--panel: color-mix(in srgb, var(--brand-ground) 88%, var(--ink))` — the
property's own paper one step down, per property, with a `--line` right edge.

**The photograph became a texture, not a picture.** Recipe (ImageMagick):
`-colorspace Gray -blur 0x14 -emboss 5 -colorspace Gray +level 34%,66%`, then
in CSS `background-blend-mode: soft-light` against the panel colour. That last
part matters: blending against the *colour* means one greyscale file tints
correctly for all three properties, with no overlay layer and no opacity
juggling. Files dropped from ~50–100 KB to 16 KB each.

**Tuning notes, so this isn't re-derived:** `-shade` was tried first and
vanished entirely once levelled — it has too little local contrast to survive.
`-emboss` survives. Blur is the dial between picture and texture: 0x5 still
shows sand grain, 0x20 is mush, **0x14** is the point where shapes read as
relief and nothing reads as detail.

**Contrast had to move with it.** Darkening the panel eats exactly the headroom
`--ink-3` had on the page — at a 95% panel it already failed on two of three
properties. The rail therefore uses `--ink-2` and `--ink`, which clear 4.5:1
down to an 81% panel. `RailPanelTests` re-derives the panel from the stylesheet
and fails if `--ink-3` ever appears as rail text, checking against the
*lightest* extreme the emboss can lift the panel to, since that is the hard
case for dark text.


## 2026-08-27 — v0.18.2: the browse panel goes flush and full-height

**245 tests green**, deployed to all three, measured on each.

Rian did not want the floating-card look. The library page now escapes the
centred `.wrap` entirely (`{% block main_class %} full-bleed{% endblock %}`,
`.wrap.full-bleed { max-width: none; padding: 0 }`) so the panel sits on the
window edge with no radius and no shadow; the pane carries the page padding
instead.

**The rail is deliberately two elements.** `.rail` is the COLUMN — full page
height, flat masthead colour, so the dark never stops halfway down a long list.
`.rail-inner` is the PANEL — sticky under the masthead, capped at the viewport,
carrying the photograph and scrolling inside itself if the collections outgrow
the window. One element could not do both: a viewport-height element leaves the
column bare below the fold, and a page-height element stretches the photograph
over the whole document.

**`--header-h` is derived, not hardcoded:** `max(var(--logo-h), 40px) + 33px`
— the masthead's 16px padding top and bottom, its 1px rule, and whichever is
taller, the property's logo or the 40px account button. The three logos are
three different heights (34 / 44 / 54), so a fixed offset would misalign two of
them. First attempt used `40px` as `34px` and every property with a short logo
tucked 6px under the masthead on scroll — measured against the real rendered
header to catch it.

**Method note (third time):** headless Chrome's viewport is not what
`--window-size` says — it was 733px for an 820px window here, and floors around
485px. Every screenshot check must go through an exact-size `iframe`, or the
capture is a crop and a correct layout reads as broken. Two false alarms came
from this before I stopped trusting it.


## 2026-08-27 — v0.18.1: the browse panel goes dark, and carries the resort

**245 tests green**, deployed to all three, checked at 1340px and 390pt.

Rian asked for more separation between the browser and the documents, and for
resort photography worked in subtly. Both are the same move: the rail became a
**dark panel in the property's masthead colour with a photograph behind it**.

**Images** are pulled from each resort's own marketing site on this box:
Sands `Grace-Bay-Beach.jpg`, Shore Club `Long-Bay-Beach-HR-3-Crop.jpg`, Palms
`JPR-ThePalms-026-Crop.jpg`. Cropped to a tall strip, slightly desaturated,
lightly blurred, ~50–100 KB each, in `main/static/rail/`. Per-property config
(`PORTAL_RAIL_IMAGE`), exposed as `--rail-image`; a property without one falls
back to a transparent gradient and renders fine.

**The overlay figure is measured, not chosen.** Compositing the masthead colour
over a blown-out WHITE pixel of the photograph and then the text alpha on top:
at 78% the faintest label fails on Shore Club (4.27:1), at 82% it just passes,
so the gradient runs **84% → 93%**. My first attempt used 94–98% and the
photographs were invisible — worth knowing that the honest range is narrow.

`RailPhotographTests` re-derives that sum from the stylesheet itself and fails
if the overlay is ever thinned, if the faintest text stops clearing 4.5:1, if a
property's photograph gets hardcoded, or if the no-image fallback stops being a
valid image value.

**Still open:** rian may switch the phone rail from the flyout to the pinned
chip row once he has felt it on a real handset; both remain on the exploration
canvas.


## 2026-08-27 — v0.18.0: the library rebuilt as The Cabinet

**241 tests green**, deployed to all three, checked at 1400px and 390pt.

**Rian chose The Cabinet, with the flyout on mobile** (over the pinned chip
rail) — with the caveat that he may change his mind once he feels it on a
phone. Both mobile options stay drawn on the exploration canvas, so switching
is a CSS change, not a rebuild.

**Shape:** a standing rail of Collections (with document counts and the
category colour) and Years beside the pane; below 900px it becomes a drawer
behind a Browse button, with scrim, Escape, focus move, and auto-close on
navigation.

**The rail replaced the two control rows without losing an axis.** Picking a
collection is `?group=date&category=<slug>` — that collection, grouped by year.
Picking a year is `?group=category&when=…` — that year, grouped by collection.
So the two groupings are the two rail sections, and no view logic changed. The
old "Everything" grouping survives as a **Grouped / Flat** switch by the search
box.

**Everything else came across untouched:** `_item.html` / `_row.html` were not
edited at all, so expandable groups, staff edit and trash, colour coding,
"show more", search, the uploader and every dialog behave exactly as before.
Eleven tests now pin that explicitly, so the next layout change cannot quietly
drop one.

**Two real bugs found by building it:**
- Counts beside a category counted **rows, not documents** — a collection of
  three showed as one. The rail said Minutes 31 while the section heading said
  24, about the same category. Everything counts documents now.
- Category counts came through `category.documents.count`, which ignores
  `published` and `removed_at` — a category emptied into the trash still showed
  a tally. Counted from the visible queryset now.

**Also:** the masthead wrapped raggedly under ~620px (a regression from the
v0.17.0 masthead); brand and nav now take a row each.

**Method note:** headless Chrome floors its viewport at ~485px on this host, so
`--window-size=390` gives a **crop, not a phone layout** — it looked like a
horizontal-overflow bug that did not exist. Render the page in a 390px `iframe`
inside a wider window to get a true phone viewport.


## 2026-08-27 — v0.17.1: muted text was failing WCAG AA everywhere

**230 tests green**, deployed to all three.

**`--ink-3` was `#7d8b90` — 3.18:1 on the Sands ground, 3.29 on Shore Club,
3.21 on Palms, 3.52 on white.** The floor for text under 18.66px is 4.5:1, and
that token carries **every date, subtitle, meta line and small label in the
app**. These readers are condo owners, many elderly. Now `#5f6d71` (4.84:1
worst case). Found while building design mockups, not by looking at the app.

**Both warm accents were fractionally under on their own paper** — Sands
`#0e7d7a` 4.48:1, Shore Club `#b8503c` 4.46:1 — while passing on white, which
is exactly the case a white-background check misses. Deepened to `#0d7572` and
`#b04a37`; hues unchanged. Palms `#2f5d4a` already passed at 6.80.

Dark mode was fine throughout (5.0–5.4:1) — this was light-mode only.

**`documents/tests/test_contrast.py`** now measures every text token against
every ground the app renders on — all three brand papers plus `--surface` and
`--surface-2` — in both themes, and fails below 4.5:1. It also checks white-on-
accent for buttons. The brand grounds and accents are listed in the test so a
fourth property adds a line.

**Design exploration** (working files in `notes/library-takes/`, canvas at
<https://claude.ai/code/artifact/74026d33-d8a8-45cf-86ae-dbe76f474692>): four
structurally different library directions — Ledger (editorial index), Vault
(dark, category-first), Concierge (search-led), Cabinet (two-pane rail). **Rian
has picked the Cabinet.** Page two carries it at 390pt two ways: a pinned chip
rail and a flyout drawer, with the case for each.


## 2026-08-27 — v0.17.0: per-property identity

**226 tests green**, deployed to all three.
Review page: <https://claude.ai/code/artifact/2b79f346-ec2a-4d17-99f9-fd4c4250e1d7>

**Brief (rian's answers):** resort-luxury but restrained · distinct palette per
resort, shared layout · Claude derives the colours, rian reacts.

**The real logos were recovered.** Each site's *active* logo is its own —
`site_logo` / `custom_logo` point at different attachments. The identically
named `logo-main.png` in all three media libraries is a shared-template
leftover that happens to be The Shore Club's mark, which is why they looked
identical at first glance. Shore Club has an **SVG**; the other two are PNG at
adequate size. Installed at `main/static/brand/`.

**All three marks are white-on-transparent with no dark variant anywhere.** So
the dark masthead is a constraint the artwork imposes, not a preference —
worth restating whenever someone asks for a light header.

**Colours recovered, not invented, for two of three:** Shore Club's coral
`#ff7760` + charcoal `#58595b` (27 and 17 uses in their own child theme, first
two in their palette); The Sands' teal `#00b3b0` (leads `kadence_global_palette`
on their resort site). Both deepened for contrast on white. **The Palms yielded
nothing** — its green is a derivation from the mark and is the one open
question; bronze and ink alternatives are rendered on the review page.

**Architecture:** the instance supplies RAW brand values (`--brand-accent`,
`--brand-ground`, `--masthead`, `--logo-h`) and the stylesheet decides how each
mode consumes them — light takes the accent as given, dark mixes it toward
white with `color-mix`. Injecting `--accent` directly would have been
overridden by the dark-mode block and all three would collapse to one accent at
night. Logo height is per-property because the marks differ in proportion.

**Bug found by audit, not by eye:** four declarations used `var(--ink-soft)`, a
token that does not exist — an undefined custom property is invalid at
computed-value time, so the element silently inherits and looks *almost* right.
`documents/tests/test_stylesheet.py` now fails on any fallback-less `var()`
whose token is never defined.

**Method note — screenshots without a browser session:** the host has
`google-chrome`. Render a page through its own container with the Django test
client, strip the shell banner, rewrite `/static/...` to local relative paths
(WhiteNoise adds a content hash, so match `[a-z]+\.[0-9a-f]+\.png`), copy the
CSS and brand assets next to it, then `google-chrome --headless --screenshot`.
`--window-size` height sets the capture height — there is no full-page flag.


## 2026-08-26 — de Grâce photo removed from WordPress (live + local)

Owner-approved. Removed from **both** the live Cloudways site
(shoreclubowners.com, app `tewadwgkdv`) and the mosiah sync
(`hartling-shoreclubowners`), so a re-sync cannot reintroduce it.

Per site: the Gutenberg image block was cut from page 24, and attachment 784
was deleted. WordPress refuses to *trash* attachments unless `MEDIA_TRASH` is
on, so `--force` was the only route — the image and both size variants were
copied off first.

**Backup (live server, outside the web root, owner-only):**
`/home/master/shoreclub-degrace-removal-2026-08-26/` — page content before and
after, plus all three image files. That is the only surviving copy; the local
sync's files went with its attachment record.

Verified on all three copies: five directors listed, no reference to the image,
no orphaned `wp-image-784` class. The remaining portraits (Dickson, Hartling)
are untouched, and the Django portal is unchanged — Stanton was already showing
initials.

**Method notes for future WordPress edits from a session:**
- The WP containers bind-mount **only `wp-content`** — a file handed to
  `wp post update` must live under `/var/www/html/wp-content/…`, not the site
  root. Stage it there, use it, delete it immediately (anything under
  `wp-content` is web-servable).
- Never write the backup into the web root. `/home/master/` on Cloudways.
- Bound a Gutenberg block by searching outward from the payload to the nearest
  `<!-- wp:image` and `<!-- /wp:image -->` rather than regex-matching across it,
  and assert the cut contains no headings or paragraphs before writing.


## 2026-08-26 — v0.16.2: the Stanton photo settled, Sands board completed

**221 tests green**, deployed to all three.

**The `christiandegrace2018.jpg` question is answered from the WordPress
revision history, not by comparing faces.** Shore Club page 24, two revisions
six minutes apart on 2026-04-21:

| rev 1239 (21:19) | rev 1241 (21:26) |
|---|---|
| img degrace → **CHRISTIAN DE GRÂCE** | — removed — |
| img mitchzajac → MITCHELL T. ZAJAC | — removed — |
| **JAMES D. STANTON** (no image) | James D. Stanton → **img degrace** |

That edit dropped two directors. Zajac's portrait was deleted with him; de
Grâce's was left behind, and Stanton — who has never had a photo on the site —
moved up into the vacated slot and inherited it. **The picture is Christian de
Grâce.** Stanton has no portrait anywhere in the media library.

The same edit also flipped that page from photo-above-name to photo-below-name,
which is the origin of the layout inconsistency between the three sites.

**Sands board completed:** Wes Stearns is "Candidate for Board of Directors"
and Tom McKeown has his photo (`Screenshot-2022-07-12-103959-1.jpg` — the
filename carries no name, which is why the matcher could not place it; rian
identified it). All five Sands members now have a portrait.

**Parser fix behind Wes:** `<br>` was stripped rather than treated as a line
break, welding "…Board of Directors" to "The Sands at Grace Bay" into a
54-character run — past the 48-character limit for a role, so it was filed as
biography and he showed no role at all.

**Left alone:** the de Grâce photo is still on the legacy WordPress site; the
Django side no longer shows it. Removing it there is rian's call.


## 2026-08-26 — v0.16.1: duplicate email removed, and the door closed

**218 tests green**, deployed to all three.

**`rian80` deleted on Sands** (owner-approved). It was a WordPress-import twin
of `rian` — same address, never signed in, nothing attached to it (checked with
`NestedObjects` first: the cascade was the User row alone). Two superusers
existed, so removing one left an administrator. `rian@rian.ca` now resolves to
exactly one account, which is the condition email sign-in needs. No duplicate
addresses remain on any of the three sites.

**Why it mattered:** the login backend refuses to guess between two accounts
sharing an address, and that check runs *before* the is-active check — so the
duplicate rejected `rian@rian.ca` + any password, and deactivating it would not
have helped. It reads exactly like a forgotten password, which is how it
surfaced.

**Closed the source.** `_import_users` matched on username only, so two
WordPress accounts sharing an address both kept it. It now drops the contested
address from the later account (keeping the account — it may own documents) and
names it in the import report. Six tests cover it, including two duplicates
inside one manifest and the re-run case where an account must not read its own
address as a clash.

**Password reset without email:** `docker exec -it hartlingowners-app-sands
python manage.py changepassword <user>` — prompts in the operator's own
terminal, so nothing passes through a transcript or a log.

**Open, and worth doing before launch:** `EMAIL_HOST` is unset, so Django falls
back to the console backend and writes reset emails — *including the working
reset link* — into the container log. Only root can read those, but a reset
link is an account-takeover token, and this stops being theoretical the moment
owners start requesting resets.


## 2026-08-26 — v0.16.0: board photos corrected, account menu

**212 tests green**, deployed and verified on all three.

**The wrong-face bug rian spotted was systemic, not a one-off.** Stan Hartling
wore Tom Mothorpe's portrait on Palms — and on Sands, three of five directors
had the wrong face. Cause: `parse_members` took the first `<img>` between one
name heading and the next. The pages float the portrait right, and its position
relative to the heading is **not consistent** — Palms puts every photo *above*
the name, Shore Club *below*, Sands does one below then the rest above. So the
image between two headings can belong to either of them.

**Fix: match on the filename, never on position.** `stan200x300.jpg` →
Hartling, `tommothorpeL.jpg` → Mothorpe. Surname needs 4+ characters (shorter
runs collide inside longer words); a first name counts only when unique on the
page — Sands has two men called Tom. **An image matching nobody is dropped**,
not given to the nearest heading: these pages outlive their boards, and a
departed member's portrait is often still in the markup. Initials are wrong in
an obvious way; another director's face is wrong in a way that reads as true.

Corrected live via a new `--photos-only` mode (touches photos only — Palms'
roster comes from its contact table, so a full re-import would rewrite it):
Palms 1 fixed · Sands 3 fixed + 1 cleared · Shore Club 1 cleared.

**Tom McKeown (Sands) has no photo now.** His portrait is on the page as
`Screenshot-2022-07-12-103959-1.jpg` — no name in the filename, so nothing
honest to match on. He previously wore Stan's face. Rian can attach it from
Board → Edit.

**Account menu** replaces the Sign out link: initials avatar → profile,
change password, sign out. Profile edits name + email only; username stays
fixed (owners were migrated with usernames they never chose, and staff read
them back over the phone). **A duplicate email is refused** — sign-in accepts
an email and the backend refuses to guess between two accounts sharing one, so
a duplicate would lock out both people, not just the new one.

**Still open:** design direction · owner-facing tour · OCR for scanned
documents · optional profile photo upload (skipped deliberately: it adds an
image-upload surface and an auth-gated serving path, neither of which was asked
for).


## 2026-08-26 — v0.15.0: Activity removed, owner list re-cut, row spacing

**189 tests green**, deployed and verified on all three.

**Activity page removed** — the screen only. `LoginEvent` and `DownloadLog`
keep recording, so the history exists if it is ever wanted, and `DownloadLog`
could not have gone anyway: it is what the per-user download rate limit counts.
Also removed the tour step that pointed at it (the tour is dormant, but a
`{% url %}` to a deleted route would have raised `NoReverseMatch` the moment it
was switched on).

**The owner list now has five views** — All current / Owners / Staff / Active /
Deleted, each with a count. Counts are of the whole list, not the current
search, so searching cannot make them move.

**"Deleted" is a soft delete**, matching the document trash: the account is
deactivated and kept, and putting someone back restores their password intact.
Erasing for good is a separate action offered only inside the Deleted view.
This replaced the old Deactivate/Delete pair, which put a recoverable and an
irreversible action side by side under similar labels.

**`last_login` is empty for imported owners** (2 of 137 on Palms) — WordPress
has no such column, so Active reads near-zero until people sign into the new
portal. Correct, but worth knowing before showing the number to Joan.

**Row spacing:** a lone document had no margin beneath it. A folder is a
`<details>` with its own margin; a lone document is a one-item `<ul>` whose
`gap` never applies. Spacing now comes from the container, so both kinds match.

**Method note (twice now):** `response.context` is `None` in `manage.py shell`
unless `setup_test_environment()` is called first — it installs the
`template_rendered` signal. A check reading `r.context[...]` there fails with
`TypeError`, not a useful assertion.


## 2026-08-26 — v0.14.1: the category field really is gone now

**179 tests green**, deployed to all three, verified in each container.

**Why the hidden field was still on screen.** The JS had been setting
`categoryRow.hidden = true` for groups all along — but `.field` is
`display: grid`, and an explicit `display` outranks the `hidden` attribute, so
the browser drew it anyway. Fixed once, globally: `[hidden] { display: none
!important; }`. That also repairs `#cascade-row` (`.checkline` is
`inline-flex`), which had been showing the group-only date checkbox on
*document* edits — same cause, not yet reported. Worth remembering: any
`hidden` toggle on an element we give a `display` to is broken by default.

**The library's staff block was rendered twice** — 145 byte-identical lines,
both dialogs, both `<script>` blocks. Every id was duplicated, so
`getElementById` resolved to the first copy and the page *looked* fine, while
each handler was bound twice: saving an edit POSTed twice, a dropped file was
added twice. Deleted the second copy; a test now asserts each dialog id appears
exactly once, since this failure is silent by nature.

**Method note:** the first live check reported all-zero counts and read like a
clean pass. It was a 400 — the Django test client sends `Host: testserver`,
which production `ALLOWED_HOSTS` rejects, so I was counting ids in an error
page. Pass `HTTP_HOST=<the real domain>` when driving the test client inside a
production container.


## 2026-08-26 — v0.14.0: delete bug, confirmations, category model corrected

**178 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

**The delete bug rian hit.** Clicking a trash icon opened the *edit* dialog.
Cause: the trash button reused `class="edit-btn"` for its shape, and the
library's JS bound the edit handler to `.edit-btn`. Trash controls now use
`.row-btn`, and the handler binds to `.edit-btn[data-kind]` — belt and braces,
because either fix alone would have worked and both are cheap.

**Groups no longer carry a category** — rian's own observation, and he was
right. `Collection.category` is dropped (migration 0010). A batch spans
categories by design, and in the category view the same group is rendered under
each of them, so "apply the category to the documents in this group" was
genuinely ambiguous: the group as clicked, or the whole batch? Category is a
document property; the uploader still guesses one per batch and stamps each
file with it. The group editor now offers name + date only, with a note saying
where category lives. The date cascade stays — that one is unambiguous.

**One confirmation dialog for the whole app.** Any form with `data-confirm`
opens a styled modal instead of `window.confirm`; the handler lives in
`base.html`, so board removal, trashing, emptying the trash and deleting an
owner all share it (176 forms). Danger actions get a red confirm button.

**The date field's border was wrong because `input[type="date"]` was never in
the shared input selector** — it only ever got the later padding rule, so it
fell back to the browser's own border. Added to the shared rule. Selects now
draw their own themed chevron with `padding-right`, since the native arrow sits
flush to the edge and ignores the palette.

**Still open:** design direction · owner-facing tour · Palms board photos ·
OCR for the scanned documents with no text layer.

## 2026-08-26 — v0.13.0: trash, and the edit dialog cleaned up

**177 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

**Trash** — `removed_at` on `Document` and `Collection`, mirroring
`BoardMember`. Bin icon on every row and group header; `/staff/trash/` lists
what is in it with Restore, plus Empty the trash (which deletes the files too).
The library, the download view and the detail view all exclude trashed items
for non-staff.

Trashing a group stamps its documents with **the group's own removal time**,
which is what makes the restore precise: a document already in the trash for
its own reasons is not swept back in when the group returns. Both directions
are tested.

**Two mistakes caught in the same session, both the same shape.** First, the
trash forms landed **outside** the `{% if user.is_staff %}` block in both
`_item.html` and `_row.html` — owners would have seen delete buttons. Found by
inspecting the rendered template, fixed, and now asserted.

Second, and worth remembering: my verification (and the regression test I first
wrote) searched the HTML for `"document_trash"` — **the template tag name,
which `{% url %}` never emits**. It reported 0 buttons for staff *and* owners
and looked like a pass. The test now matches the rendered URL
(`/staff/documents/N/trash/`) and asserts staff DO get controls as well as that
owners do not. Live check: staff 146 document + 30 group controls, owners 0.

**Edit dialog** — `overflow-x: hidden` on the sheet body killed the scrollbar
in rian's screenshot; fields are width-constrained to the panel; the checkbox
row wraps instead of stretching it. The date field keeps the **native** control
(better on a phone, and already keyboard-accessible) but is restyled: matching
height and padding, tabular figures, spin/clear buttons hidden, and the
calendar indicator tinted to the theme with a dark-mode inversion.

**Still open:** design direction · owner-facing tour · Palms board photos ·
OCR for the ~70 scanned documents with no text layer.

## 2026-08-26 — v0.12.0: re-filed from document contents

**163 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

`backoffice/content.py` reads PDF (pypdf), and docx/xlsx/pptx by treating them
as the ZIP-of-XML they are — no new dependency. `backoffice/classify.py`
decides what a document *is*; `manage.py reorganise` (dry run by default)
proposes the changes.

**The mistake worth remembering — "is" versus "mentions".** The first pass
scored any keyword anywhere, so AGM minutes went to *Financials* (they discuss
budgets at length) and board candidates' bios went to *Minutes/Governance*
(they list committee service). 41 of 166 documents would have been misfiled at
high confidence. Fixed by weighting evidence by **position**: what a document
declares in its opening 500 characters and its title decides; body mentions
only corroborate. Misfilings fell 41 -> 11, all 11 correct.

Two related corrections in the same pass: title years beat content years (a
"2026 Board Presentation" workbook cites five other years, so it was being
renamed "2021 Budget Variance"), and group renaming is now limited to
placeholder names — it wanted to rename a correct "2023 insurance renewal" to
"financial documents".

**Ungrouping catch-up batches** uses years in *titles* (a document's subject)
and skips any batch anchored by minutes, since one meeting's paperwork
legitimately covers last year's accounts and next year's budget.

**Applied:** Shore Club 5 refiled · Sands 29 refiled, 1 retitled, 2 renamed ·
Palms 11 refiled, 3 retitled, 5 renamed, 7 ungrouped. Palms "Other" 18 -> 7,
"Board & People" 12 -> 23, no placeholder names left on Palms.

**A batch now spans categories** (rian's design point): in the category view a
collection is emitted once per category its documents occupy, carrying only
those documents plus a "N of M in this batch" note. On Palms "2023 insurance
renewal" appears under 6 categories. The date view keeps batches whole.

**Left deliberately narrow:** `suggest_title` only corrects titles that plainly
contradict the file (4 across all three sites). Widening it would churn
documents people already recognise — worth revisiting with rian rather than
guessing. Also 25-36 documents per site are unreadable (scans without a text
layer); they keep their existing filing.

## 2026-08-26 — v0.11.0: library controls reworked, tour parked

**159 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

Controls moved **above** the search box. Order is Category (default) / Date /
Everything, with a **contextual second filter**: a time range in the category
view (`?when=recent|current|last|all`, default `recent` = past 365 days), and
the category chips in the date and everything views. Unknown values in either
parameter fall back to the defaults.

The two-per-category collapse is now **conditional on the view holding more
than 8 items** — a short view is shown whole. The per-row category pill is
suppressed in the category view (`hide_category` threaded through
`_item.html` -> `_row.html`) since the section heading already says it.

Verified on Palms: default shows 2 items in 2 sections; `when=all` shows 61
with 50 behind toggles across 6 sections; date view has 19 year sections with
the pills back.

**Tour parked** (`TOUR_ENABLED = False` in `accounts/tour.py`) — it kept
interrupting and wasn't what rian wanted. The engine, step data, CSS and the
`tour_seen_version` field all remain so it can be rewritten rather than rebuilt;
`test_tour.py` now asserts no tour markup reaches any page and that the
completion endpoint still works.

**Resolved in v0.11.1:** the past-year default made the quieter properties look
empty (Palms showed 2 of 166), so **All time is now the default**. The landing
view is an overview of every category, two entries each, rest behind toggles.
A test caught that the *invalid-value* fallback still said `recent` while the
*missing-value* default said `all` — both now agree.

## 2026-08-26 — v0.10.0: grouping, category colours, board management

**163 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

**Library grouping** — `?group=date|category|all`. By category, each section
shows 2 and hides the rest behind a toggle (Palms: 6 sections, 50 hidden).
Unknown values fall back to date.

**Category colours** — `Category.color`, with a fallback so nothing is ever
colourless. **First attempt was wrong**: the fallback hashed the name
(`sum(ord(c)) % 8`) and four of seven categories collided on one colour, which
defeats colour-coding entirely. Now falls back on `pk`, and
`organise_documents` additionally assigns distinct palette colours in display
order — verified 7 distinct of 7 on all three sites.

**Board** — larger cards (portrait, name, role, bio preview, Contact), full bio
in a dialog. Staff: **Add member** and **Reorder** (drag-and-drop list) at the
top, **Edit**/**Remove** per card. Removal is a soft delete
(`BoardMember.removed_at`) into a "Removed members" list with "Put back" —
deleting a board member outright is too easy to regret.

**The security-relevant piece**: bios are rich text stored as HTML and rendered
as HTML, so `backoffice/sanitise.py` filters input against a tag/attribute
allowlist (script, iframe, img, event handlers and `javascript:` hrefs all
dropped). Nine tests cover it. The threat modelled is not a careless staff
member but a stolen staff account storing script that runs in every owner's
browser.

**Still open:** design direction · owner-facing tour · Palms board photos (its
roster came from a contact table, so only 1 of 9 has a portrait and 2 have bios).

## 2026-08-26 — v0.9.1: drop-anywhere fix + drop area

rian reported dropping files on the page opened the sheet **without the files**.
Cause: `DataTransfer` is emptied as soon as the drop handler returns, so the
`setTimeout(..., 40)` that handed them to the uploader read an empty list. Files
are now spread into an array **synchronously** inside the handler. Covered by
`test_dropped_files_are_captured_synchronously`, which asserts the old
setTimeout shape is gone — a behaviour a Django test can't otherwise reach.

Also replaced the "+ Add documents" button with a dashed **drop area** titled
*Add Documents* (matching the uploader's own zone), so the drag affordance is
visible rather than described. Clicking opens the file picker, then the sheet.

## 2026-08-26 — v0.9.0: editing in the library, uploader folded in

**132 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/b5039801-b8c8-4502-b428-e26fb4888546>

The library is now the single staff surface. The **Upload** nav item is gone;
`/staff/upload/` redirects to `/?upload=1`, which opens the uploader in a
`<dialog>` on the library page. Dropping files **anywhere on the page** opens
the sheet with them already loaded (`window.portalUploaderAdd` is the handoff).
The uploader moved to `backoffice/templates/backoffice/_uploader.html` so the
library can include it; `_upload_context()` supplies its context to staff only.

**Editing**: a pencil on every group (24) and every document (146 on Palms)
opens one shared dialog — name, date, category. `POST /staff/groups/<pk>/update/`
and `/staff/documents/<pk>/update/` take JSON, staff-only, POST-only.
**The cascade rule from the uploader now holds after the fact**: a group's date
change offers to move its documents; a document's own date never moves the
group. Both directions are tested.

**Three mistakes I made and fixed, worth knowing:**
1. My extraction of the uploader partial matched the wrong `{% endblock %}`,
   wrote an empty file, and I had already deleted the source — rewrote it.
2. Injecting the dialogs with a `{% endblock %}` replace hit the **title**
   block, putting the markup in the page title. Repaired.
3. Dates arrived from JSON as strings and were assigned raw, so the response's
   `.isoformat()` blew up. Now parsed with `parse_date`, which validates too.
The tour steps were also mangled by a find-and-replace and were rewritten by
hand for the new single-page flow.

**Still open:** design direction · owner-facing tour · Palms board photos.

## 2026-08-26 — v0.8.0: guided tour for staff

**118 tests green**, deployed to all three.
Clickable demo: <https://claude.ai/code/artifact/8279ee0d-e8a6-466e-9058-ce9120836c61>

Welcome panel on a staff member's first sign-in, then spotlit coach-marks.
**The tour spans pages** — a step declares its `page` and `target`; if the
browser isn't there it navigates and resumes from sessionStorage, so one tour
can cover library -> uploader -> owners -> activity. Replayable from the
**Tour** link in the staff nav.

- `accounts/tour.py` holds `TOUR_VERSION` + `should_offer_tour`. Progress is
  `User.tour_seen_version`; **bumping the version re-offers the tour to
  everyone**, which is deliberate while the back office keeps changing.
- Steps are **data** in `templates/_tour.html` using real `{% url %}` values,
  so rewriting them as features move is a content edit, not a code change.
- A step whose target selector no longer matches is **skipped**, not left
  pointing at nothing — the UI will keep changing.
- `static/tour.js` is dependency-free: overlay + a cut-out "spotlight", bubble
  with an arrow that flips above/below to fit, dots, Back/Next/Skip, arrow-key
  and Escape support, `prefers-reduced-motion`, and a bottom-docked bubble
  under 620px.
- Owners get **no tour markup at all** (asserted).

**Tests worth keeping:** every step's `page` is fetched and must return 200, so
a renamed URL fails the suite instead of silently breaking the tour for Joan.

**Caveat for rian:** verified through tests and rendered output, not by driving
a real browser — worth a hands-on click-through, especially the cross-page
resume and the spotlight position while scrolling.

## 2026-08-26 — v0.7.0: the uploader rebuilt to rian's batch spec

**106 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/eac9f34a-4d15-4b44-858a-f414bfba8854>

Files now appear as a **card grid** under the dropzone (icon, editable name,
date, size) already wrapped in a **group** with a rename-me name and a date.
**Date rules exactly as specified**: group date cascades down to every file; a
file's own date never cascades up. Same shape for category — files inherit the
group's unless overridden. Selection is on by default; drag the selection onto a
category chip, onto one of the 3 most recent groups, or search for an older one.

**`backoffice/suggest.py`** does the guessing: `extract_date` (ISO, d/m/y,
"May 2024", bare year) and `suggest_category`, which learns from the site's own
filing history.

**The scoring bug worth remembering.** First version scored by raw token
overlap, so the *biggest* category won on generic vocabulary: "Strata Plan 50
AGM 2026 Minutes" was filed as **Financials at confidence 1.0**, because
"strata"/"plan" dominate every category on these sites. Fixed by weighting each
word by how few categories contain it (an IDF-style term weight) and
normalising by category size. Real-data accuracy went **3/8 -> 7/8**; the one
miss (Newsletters) self-reports low confidence 0.27 because Palms has only two
newsletters to learn from. Locked in by
`test_distinctive_words_beat_a_big_category`.

Also fixed: `suggest_category` crashed with `max() iterable argument is empty`
on a site with no categorised documents — i.e. every brand-new portal. Guarded
and covered by a test.

**Still open:** design direction (rian doing later) · Palms has one board photo
(its bios are PDFs) · `/admin/` remains the power tool.

## 2026-08-27 — v0.6.0: uploader, owner manager, board, email login

Built overnight from rian's list. **98 tests green**, deployed to all three.
Preview: <https://claude.ai/code/artifact/2a6ed98a-f31a-4b8f-9b9e-5406e1bca3c5>

**Email login (his reported bug).** `accounts/backends.py` accepts username OR
email, case-insensitive. Two accounts sharing an address are **refused, not
guessed** (an exact username still wins). Missing accounts still run the hasher
so response timing doesn't reveal which addresses exist.

**New `backoffice` app at `/staff/`** — staff screens organised by job, not by
code module, which was rian's complaint about the Django admin.
- **Uploader**: drag-drop + picker, multi-file, `POST /staff/upload/suggest/`
  proposes titles (filename cleaning + PDF metadata when not junk like
  "Microsoft Word - Doc1"), editable per row, then one batch commit with
  category/date/collection. **Extension allow-list** — php/html/svg/js refused
  (tested), 64MB cap.
- **Owners**: search + filters, add (email a set-up link **or** generate a
  password revealed once and copied to clipboard), send reset link, regenerate
  password, deactivate, delete. Guards tested: no self-delete/deactivate, can't
  delete the last administrator, all actions POST-only.

**Two real bugs found while testing.** (1) Django's `PasswordResetForm` silently
skips users **without a usable password**, so the invite flow sent nothing —
invited accounts now get a random undisclosed password so the mail actually
goes. (2) `X_FRAME_OPTIONS=DENY` blocked our own same-origin PDF preview;
now SAMEORIGIN.

**Board.** `import_board` parses the real pages: **detects the heading level**
(Shore Club uses h1, the others h3 — the first attempt found nothing on two
sites), ignores bio section labels ("Education") and emails when picking a role,
pulls photos from the local staging uploads. **`--roster-from-contacts`** exists
because the Palms bio page is from 2022 while its *current* 9-member board lives
in a contact table — that table is now the roster.
Result: Shore Club 5 members/3 photos · Sands 5/4 · **Palms 9 with all 9 emails**.
Board emails are now **shown to signed-in owners** (rian asked for image/title/
email); the old "never render" test became two tests — shown when signed in,
never to anonymous.

**Still open:** Palms has only 1 board photo (its bios are PDFs in the "Board
member biographies 2025" collection, not images) · design direction still
awaited before further visual work · `/admin/` remains as rian's power tool.

## 2026-08-26 — v0.5.0: collections, reorganisation, document browser

**rian's product direction:** the library should feel like a modern document
*browser*, not a list; documents arrive in batches, not one at a time; staff
need a genuinely easy uploader. Also settled: he is NOT staying with the Django
admin for staff — custom screens, pending his design direction.

**Data model.** `Collection` (a batch) + `Tag` (cross-cutting). The legacy
primary/secondary file pair is **retired** — measured first: 0 secondary files on
Shore Club and Palms, 20 on Sands, confirming it was vestigial. Migration 0005
splits each into its own document inside a shared collection (Sands 166 -> 186).

**Reorganisation** (`manage.py organise_documents`, dry-run by default). Built
from what the data actually showed: same-day clusters already covered 79% of
Shore Club, 74% of Palms, 55% of Sands. Three dry-run bugs caught before
applying — a too-greedy "strata plan #" rule sending AGM minutes to Governance,
"Palms Management Presentation" read as a person's name, and an inflated count
from an elif that fired on every classified document. After fixing:

| site | refiled | collections | categories after |
|---|---|---|---|
| Shore Club | 41 | 13 | Minutes 18 · Financials 8 · Insurance 24 · Governance 1 · Newsletters 6 |
| Sands | 123 | 41 | Minutes 64 · Financials 38 · Insurance 40 · Governance 5 · Newsletters 3 · Other 36 |
| Palms | 66 | 29 | Minutes 40 · Financials 49 · Insurance 38 · Governance 9 · Board & People 12 · Other 18 |

Found the miscategorised bios rian described — "Stephen Sisca" was filed under
*Owner's Newsletter*; the 11-document 2025-05-27 cluster is now "Board member
biographies 2025". "Other" fell from 62->36 (Sands) and 46->18 (Palms). Legacy
per-site category names are merged into one standard set across all three.

**Document browser.** Collections render as folders inside year sections;
search filters in the browser (instant, no page load) across title, summary,
category and tags; category chips; inline **PDF preview** on the detail page
(PDF-only on purpose — serving arbitrary types inline would let an uploaded
HTML/SVG execute in the site's origin); sibling documents shown on detail.
Preview artifact: <https://claude.ai/code/artifact/f107e3a9-09e1-427b-a27d-9bc09247ec9a>

Tests **66 green**. Deployed v0.5.0 to all three.

**Next:** the staff uploader — drag-and-drop batch upload, titles suggested from
filename/PDF metadata, assign a whole batch to a collection + category in one
step. Then the custom staff screens (owners, reset) once rian sends design
direction. Board members still imported inactive, awaiting his review.

## 2026-08-26 — v0.4.0: design pass (rian flagged the UI as dated)

rian recognised Django's look and worried the stack forces a dated design. The
distinction that matters, recorded so it doesn't come up again: **Django imposes
no design on the front end** — those are plain templates and our own CSS, and the
plainness was placeholder styling from the plumbing phases. The only genuinely
Django-looking surface is **`/admin/`**, which is a real constraint.

Landed a full visual system in `main/static/portal.css` (rewritten):
Newsreader + Public Sans via Google Fonts, warm-slate neutrals (not default
grey), sticky translucent header with brand lockup, card document rows with
**file-type badges** (pdf/doc/xls colour-coded via `Document.file_kind`),
**category chips** instead of a dropdown, designed empty states with icons,
refined tables/stat cards, focus-visible states, `prefers-reduced-motion`, and
**dark mode** via `prefers-color-scheme`. Nav highlights the active section.

**Design preview artifact** (real Sands pages, standalone, no login needed):
<https://claude.ai/code/artifact/1c20cdd7-c0d1-4532-aeed-beb0adde6fb3>

**Open decision — the staff admin.** Django admin still looks like Django. If
the client-facing staff experience must feel custom too, the answer is a small
set of purpose-built staff screens (upload, owners, reset, activity) in this same
design language, keeping `/admin/` as an owner-only power tool. Activity is
already custom and is the template for that. Not started — awaiting rian.

## 2026-08-26 — v0.3.0: activity view, password reset, library UX

Deployed to all three instances (v0.3.0 visible in the footer — the deploy
confirmation `coding.md` requires; `CHANGELOG.md` added at the workspace root).

- **Activity page** (`/activity/`, staff-only) — the feature Joan explicitly
  asked for. Sign-ins with success/failure, filters (by username, failed-only),
  counts, plus recent downloads. Backed by a new `LoginEvent` model fed by
  Django's `user_logged_in` / `user_login_failed` signals, so **failed** attempts
  are captured too (a burst against one account is the signal that matters —
  the old WP sites had no way to see it). Owners are blocked from it (302).
- **Password reset works end-to-end** with styled templates. Email backend is
  env-driven: console in dev, SMTP as soon as `EMAIL_HOST` is set — so dev never
  silently depends on a mail server. Tested as a full journey including **a
  migrated owner still on a WordPress hash**, and that an unknown address does
  not reveal whether an account exists.
- **Library is usable at scale**: pagination (25/page — Sands had 147 on one
  page), result counts, search/filter preserved across pages, better empty
  state, and a **document detail page**.
- **Admin shaped for staff**: per-instance branding (no "Django administration"),
  document form reduced to title/category/file with the rest collapsed,
  list-editable category+published, date hierarchy, `has_file` indicator; users
  list shows last-login; `LoginEvent`/`DownloadLog` registered read-only.

Test suite **65 green**; audit 0 critical; anonymous access still 302s on every
owner-facing and staff route.

**Next candidates:** owner invite flow (staff creates account -> emailed set-up
link); board-member review pass (imported members are still `active=False` by
design — needs rian's eyes, not code); per-site logo upload (colour+name are
config already); document bulk upload; 404/500 templates.

## 2026-08-26 — verified the "missing docs" against LIVE (they were on other sites)

rian was sure he'd added Shore Club docs ~a week ago; the staging extract showed
none. Checked **live** (Shore Club = Cloudways app **`tewadwgkdv`**): 54 publish +
4 draft, newest **2026-05-13** — and **nothing of any post type created in 60
days**, no non-image uploads in 30 days. The staging copy was NOT stale; Shore
Club genuinely has no new documents.

**The recent documents are on the sibling sites**, and all were already imported:
- **Sands** (`yxqnbzygqr`): "Revenue Analysis 2026" + "Strata Corp 25 AGM
  Minutes", both **2026-08-24** — PRESENT in the portal with files.
- **Palms** (`mrserkncsc`): two "Strata Plan # 50 … Board Presentation",
  **2026-08-12** — PRESENT with files.

**Cloudways app-slug map (verified by siteurl — rian's supplied slugs were wrong
twice, so always confirm before acting):**
`tewadwgkdv`=shoreclubowners.com · `yxqnbzygqr`=thesandsowners.com ·
`mrserkncsc`=thepalmsowners.com · `nkymcszmqv`=thepalmstc.com ·
`rjmrqpctne`=theshoreclubtc.com · `cujqdmydym`=thesandstc.com ·
`cnmyqspjag`=hartlinggroup.com (+ queenofworld, princesshospitalitygroup,
yourvilladelmar). All 10 on `165.227.177.0` as `master_vmxzsgfbpe`.

**The hosting-web session acted on the incident report and found the real
detection gap** (documented in that file): its `HOSTING_MALWARE_IOC_PATTERNS` held
only six strings, all fingerprints of the *previous* (June) implant, so the August
one matched none — proven by grepping the sample I quarantined. Now 6 -> 13
class-based patterns, each FP-tested against all 22 production sites, deployed.
My gaps 2 and 3 were refuted (their wp-cli reads use `--skip-plugins`, so
concealment can't fool them). Lesson recorded there: **a detector whose signature
list only recognises the last incident will miss the next one.**

## 2026-08-25 (night) — live Sands cleaned + forensics + Shore Club delta

**Sands production remediated end-to-end** (details + IOCs in `.memory/`):
backdoor removed (evidence kept at `/home/master/quarantine-thesandsowners-2026-08-25/`),
roles cut to **exactly one admin (`rian80`/rian@rian.ca)** — kwhitt, ovidio,
mbeeson downgraded to `owner`, all `bbp_moderator` stripped — and **salts rotated**
(kills any auth cookie the backdoor minted; old file backed up outside the web
root). Site verified healthy after every step.

**Forensics answered rian's question — it was NOT mining.** The RCE path was
never invoked (0 hits). The backdoor was used 12x by 6 residential US IPs to
forge admin sessions and *browse wp-admin by hand* (plugins/settings/updates).
No miner process, no attacker-installed plugin. **The frequent outages have a
different cause: PHP-FPM worker exhaustion driven by a wp-login brute-force flood**
(954 login POSTs in ~30 days, spikes of 254-274/day, botnet source ranges;
`no live upstreams` in nginx). The site has **no login rate-limiting active** —
that, not the backdoor, is why it keeps going down. Log retention is only ~30
days, so the Jun 25 implantation itself is outside the window.

**Shore Club re-sync delta: 1 new user, 0 new documents.** Newest doc in
WordPress is 2026-05-13 (already imported). New account = `cmorrell`
(registered 2026-08-13), imported with a working WP hash. Instance now 49 users /
57 docs. Re-synced copy verified free of the backdoor signature and stray PHP.
**The importer is now proven as an incremental sync**, not just a one-shot: it
matched all 48 existing users, added the one new one, and preserved the already-
upgraded password on the pre-existing account ("kept upgraded pw 1").

## 2026-08-25 (end) — Palms files pulled from Cloudways; all 3 sites complete

**Cloudways access now works from mosiah** (rian's key already authorised):
`ssh master_vmxzsgfbpe@165.227.177.0`. **The app slug rian supplied
(`nkymcszmqv`) is a DIFFERENT site** — The Palms is **`mrserkncsc`**
(confirmed: `siteurl=https://thepalmsowners.com`, DB `mrserkncsc`). Path:
`applications/mrserkncsc/public_html/wp-content/bw-protected/`.

- Pulled **249 files / 152 MB** read-only via rsync (nothing written to prod).
  Content verified before and after: 245 pdf, 2 ppt, 2 csv, zero executables.
  Landed in `data/palms-protected/` (o-rwx; contains real owner documents).
- The 2 CSVs are `*_user-details.csv` — WordPress user exports with names/emails
  (PII) but **no passwords** (`password_nag` is a UI flag). Unlike the Sands CSVs.
- **Extractor now takes `--protected-root`** — a second search root for files a
  protection plugin moved out of `uploads/` (same YYYY/MM layout), with a
  basename fallback for `/bw-file/<id>/<name>` references that carry no date path.
  Palms went from **9 files / 159 missing → 166 files / 2 missing**.

**FINAL STATE — all three sites complete and gated:**

| Site | Users | Docs (all with files) |
|---|---|---|
| Shore Club | 48 | 57 |
| Sands | 132 | 166 |
| Palms | 137 | 166 |

389 documents total. Verified on Palms: library renders 146, a real 332 KB PDF
downloads with `X-Robots-Tag: noindex`, anonymous download 302s to login.

**Backup risk (unchanged, still real):** those 250 Palms files are in NO backup —
UpdraftPlus's "others" archive contains only the 2 stub files. They existed in
exactly one place until this pull. `data/palms-protected/` is now a second copy,
but that is a side effect, not a backup strategy. Production still needs fixing.

**Filed the security report** rian asked for at
`/srv/system/hosting-web/docs/incident-2026-08-25-missed-plugin-backdoor.md`
(the host.bowden.works session was NOT running, so it could not be messaged —
hand it over or start that session). It documents three concrete reasons the
dashboard shows the Sands clean: (1) the checksum sweep covers **core**, not
plugins; (2) there appears to be **no "scanner inactive" state**, so a
deactivated Wordfence reads as silence = healthy; (3) plugin enumeration via
`active_plugins` can be **lied to** — this malware unsets itself from
`all_plugins`. Suggested fix: reconcile plugin dirs on **disk** against what
WordPress reports.

**ANSWERED: the Sands backdoor was never cleaned on production.** The earlier
quarantine only ever touched the staging copy on mosiah; production was left to
rian. Proof: identical mtime (Jun 25 09:56) across both syncs — same untouched
file, not a re-infection.

## 2026-08-25 (later still) — ALL THREE SITES LIVE on mosiah + domains remapped

**!! The Sands backdoor RETURNED on rian's prod->staging re-sync** — same file,
same mtime. Proof **live thesandsowners.com is still infected** on Cloudways.
Re-quarantined + removed from `active_plugins` again. Every future sync will
reintroduce it until production is cleaned. See `.memory/`.

**Three instances now run from ONE codebase**, replacing the WordPress staging
sites on their own domains:

| Domain | Port | Container | Data |
|---|---|---|---|
| shoreclubowners.demoing.info | 3150 | `hartlingowners-app` | 48 users, 57 docs, 4 cats |
| thesandsowners.demoing.info | 3151 | `hartlingowners-app-sands` | 132 users, 166 docs, 7 cats |
| thepalmsowners.demoing.info | 3152 | `hartlingowners-app-palms` | 137 users, **9 docs**, 5 cats |

- `docker-compose.yml` rewritten: **YAML anchors** so all three `web-*` services
  run the SAME image (`hartlingowners-portal`, built once from `./main`). Each
  site has its **own Postgres, own network, own document dir, own signing key**
  (per-site `.app.<site>.env`, 0660). DBs publish **no host port**.
- **Isolation proven three ways:** a Shore Club owner account does not exist on
  Sands/Palms; each web container **cannot even resolve** another site's DB
  hostname (separate docker networks); data/counts wholly different.
- **Domains remapped** off the WordPress projects via
  `srv-gw remove-dns --domain X` then `srv-gw add-dns-record --domain X --port N`
  (remove-dns takes ONLY `--domain`). id-auth gate preserved on all three.
  Old Caddy confs backed up in the session scratchpad.
- **Per-site branding** added (`PORTAL_SITE_NAME` / `PORTAL_ACCENT` env ->
  context processor -> CSS var override in `base.html`). Three visibly distinct
  sites, zero per-site code.

**Two bugs found by verifying rather than assuming:**
- **Django's `LoginView` injects its own `site_name`** (the request host) which
  **shadowed** our branding var — every login page was branded with its domain
  name. Renamed ours to `portal_name`; regression test added.
- **The bootstrap admin kept its generated password.** `_should_set_password()`
  was too broad: it preserved ANY strong hash, including the container's
  bootstrap superuser, so rian's real WordPress password silently didn't work.
  Now also returns True when `last_login is None` (the upgrade only ever happens
  at login, so there is nothing to preserve). Test added.

**rian's admin logins:** `rian` on Shore Club + Palms (his WordPress password);
on Sands the WP admin is **`rian80`** (bootstrap `rian` there still holds the
generated password in `.app.sands.env`).

**THE PALMS IS INCOMPLETE BY DESIGN:** only 9 of 167 documents imported —
**157 files are missing** because they live only in `wp-content/bw-protected/`
on the Cloudways production host (the re-sync did NOT bring them; that dir still
holds just 2 stub files). Needs an SSH/rsync pull from production before Palms
is usable. Users + categories + metadata all imported fine.

**Git:** rian is deferring it for now — noted, not blocking. Nothing is lost by
adding it later (a repo can be initialised over the current tree at any time).

Test suite: **53 green**.

## 2026-08-25 (later) — WordPress importer built + a REAL site migrated

**The migration is proven end-to-end on real data.** Shore Club's live staging
content now runs in the dev portal.

**Two-stage design** (deliberate: production will extract from Cloudways instead,
while the import half stays identical):
1. `main/tools/extract_wordpress.py` — runs on the HOST (needs `srv-gw db-query`,
   read-only + audit-logged). Reads users/docs/attachments/board, copies every
   referenced file, writes `data/import/<site>/{manifest.json,files/}`.
   Per-site `wpcf-doc-type` -> category label maps live here (Sands 7 / Shore
   Club 4 / Palms 5, from the audit).
2. `manage.py import_wordpress --manifest ... [--dry-run] [--import-board]` —
   Django command, **idempotent** (keyed on `Document.wp_id`), wrapped in a
   transaction; `--dry-run` rolls back and prints the **reconciliation report**
   (the data-export diff harness django.md requires).
3. `main/accounts/wp_import.py` — the shared, unit-tested mapping rules (hash
   routing, role parsing, slugs, legacy-URL -> relpath).

**Verified against REAL Shore Club data (matches the audit exactly):**
48 users (5 staff) · 42 phpass + 6 modern hashes · 4 categories (Meeting Minutes,
Insurance, Financials, Communications) · 57 docs with files, 54 published ·
**0 missing files**. In the running app: library renders 54, search "insurance"
-> 24 (audit: 24), filter financials -> 11 (audit: 11), download serves the real
file as an attachment.

**THE END-TO-END PROOF (the whole point of the project):** created a user in
WordPress staging with a known password -> extracted -> imported -> that
WordPress password **logged into Django through the real login form** (302 +
session) and the hash **auto-upgraded to Argon2**; wrong password rejected. Test
user then deleted from both systems. Owners will keep their passwords.

**Two real bugs found and fixed by doing this for real:**
- `srv-gw db-query --format json` renders SQL NULL as the **string "NULL"** —
  would have created a category literally named "NULL" and summaries reading
  "NULL". Normalised once in `db_query()`.
- Re-running the import **downgraded** an already-upgraded Argon2 hash back to
  the legacy WordPress one. Now `_should_set_password()` preserves upgrades;
  the report shows "kept upgraded pw N". Covered by a test.

**Also:** compose now mounts `./data:/data` (so `/data/import` is visible in the
container alongside `/data/documents`). Test suite: **48 green**.

**Board members remain a manual step by design** — the source is hand-authored
HTML, so candidates import **inactive** and need human review (5 for Shore Club).
Do not trust the parse; there are only 5-9 per site.

## 2026-08-25 — Phase 1 kickoff: Django foundations + WP-hash verifier (PROVEN)

**Landed (the riskiest piece is done and verified):**
- Django 5.2 skeleton scaffolded at `main/` (v8 instance shape): `config/`
  (settings/urls/wsgi/asgi), `accounts/` app with a **custom User model**
  (`AbstractUser`, migration `0001` generated), `.gitignore`, `requirements.txt`.
- **`accounts/hashers.py`** — the WordPress legacy-hash verifiers, the hardest
  migration requirement (owners keep their passwords). Two verify-only Django
  hashers registered LAST in `PASSWORD_HASHERS` (Argon2 preferred → rehash-on-login):
  - `WordPressPhpassHasher` — phpass `$P$`/`$H$` (hand-ported crypt_private + encode64).
  - `WordPressBcryptHasher` — WP 6.8 `$wp$` (bcrypt over base64(HMAC-SHA-384(pw,
    b"wp-sha384"))) AND vanilla `$2y$`.
  - Stored form: `wp_phpass$<hash>` / `wp_bcrypt$<hash>` so `identify_hasher` routes them.
- **Verified against REAL WordPress 6.8.5 output.** Generated ground-truth fixtures
  via `wp eval-file` on the shoreclub staging container (temp file, deleted), for
  known TEST passwords. `accounts/tests/test_hashers.py` (11 SimpleTestCases) all
  green: both formats verify, wrong pw rejects, vanilla `$2y$` works, the HMAC-vs-
  plain-SHA-384 guard passes, no-trim matches WP login, rehash-on-login fires.
  Two real gotchas caught: (1) WP 6.8 pre-hash is **HMAC**-SHA-384 (static key
  `wp-sha384`), not plain SHA-384; (2) `wp_check_password` does **NOT trim** (only
  `wp_hash_password` trims) — verify must not trim.
- **Wrote `/srv/projects/standards/django.md`** (the new stack standard) with the
  three-way auth dispatch (from the professormediagroup collab), the WP-hash
  migration rules as failure-backed provenance, private-file-serving rules, and the
  staff-admin boundary. Wired it into the standards `README.md` dispatch table.

- **Private-file download path landed — the #1 security feature.** New `documents`
  app: `Category` / `Document` / `DownloadLog` models; files in `PrivateMediaStorage`
  (rooted outside the web root, `base_url=None` so `.url()` raises — no public URL
  exists); an `@login_required` download view streaming via `FileResponse` with
  `X-Robots-Tag: noindex`, `Content-Disposition: attachment`, `Cache-Control:
  private, no-store`, nosniff; logs every download (who/what/when/ip); per-user rate
  limit (429); drafts hidden from non-staff. Registered in the Django admin (staff
  CRUD + read-only download log). 6 security tests green (anon→login redirect, no
  public URL, headers, draft gating, logging, 429). Full suite: **17 tests green**.

- **DEPLOYED AND RUNNING on this server** at `hartlingowners.demoing.info`
  (behind the id-auth gate; direct container on `172.17.0.1:3150`). Compose now runs
  **Django (gunicorn+WhiteNoise) + Postgres 16 sidecar**, replacing the nginx
  scaffold. `main/Dockerfile` builds, migrates, creates the superuser (`rian`,
  password in `.app.env`), then serves. Secrets generated into **`.app.env`
  (0660, group `hartlingowners-dev`)** — `DJANGO_SECRET_KEY`, `POSTGRES_PASSWORD`,
  `DJANGO_SUPERUSER_PASSWORD`; never printed. **Postgres has NO published port**
  (private compose network only); web binds only `172.17.0.1`. Audit: 0 critical.
- **Owner-facing UI (P2 work, done early):** `base.html` + mobile-first
  `static/portal.css`; **library** view with search (title/summary/category) +
  category filter; **board** page from a new `BoardMember` model (photo served
  through the gate; `contact_email` deliberately never rendered — a test asserts
  this); branded login page; per-instance `SITE_NAME` via a context processor.
- **Live verification against the running server:** anonymous `GET /`, `/board/`,
  and `/documents/<id>/download/` all 302 → `/accounts/login/?next=...`
  (default-deny holds in production config); login page 200; hashed CSS served.
  Seeded a real document + board member in the container to exercise it.
- **Test suite: 27 green** (11 hasher + 6 download security + 6 library/board + 4
  login-flow). The login-flow tests are the end-to-end proof: a user whose stored
  password is a **real WordPress hash** logs in through the actual form and is
  transparently upgraded to Argon2.

**Gotchas found (don't re-debug these):**
- `CSRF_COOKIE_SECURE=True` means curl-over-plain-HTTP to `172.17.0.1:3150`
  CANNOT log in — curl won't store a Secure cookie on an http transport, so the
  POST 403s. This is CORRECT behaviour, not a bug. Test login via Django's test
  client with `secure=True` (see `accounts/tests/test_login_flow.py`), or through
  Caddy over real HTTPS (which also requires passing the id-auth gate).
- WhiteNoise's manifest static storage needs a collectstatic manifest; settings
  fall back to plain static storage under `test` so tests don't need one.
- `ImageField` requires Pillow (now in requirements).

**How to run it (dev):** venv at workspace root `.venv` (Django + bcrypt +
argon2-cffi + psycopg installed). Tests: `./.venv/bin/python main/manage.py test
accounts` (uses sqlite-in-memory; the hasher tests are DB-independent). App DB is
Postgres (not running locally yet).

**Next (no DigitalOcean needed for any of these):**
1. **The WordPress importer** — the biggest remaining de-risking item and fully
   doable offline: a management command reading each staging WP DB (via
   `srv-gw db-query`) → users (hashes preserved, stored as `wp_phpass$…`/
   `wp_bcrypt$…`), categories from the `wpcf-doc-type` map, documents from
   `acf-pdf`/`acf-pdf-2` attachment IDs + legacy `wpcf-pdf` URLs, board members.
   Dry-run + reconciliation report first (per django.md: build the data-export diff
   harness BEFORE schema work). Import one site's real data into the dev instance
   and confirm real owners' hashes verify.
2. Staff activity view (LoginEvent via the `user_logged_in` signal + the existing
   DownloadLog) — Joan's "who logged in and when".
3. Password-reset email wiring (needs the provider choice; console backend works
   for dev in the meantime).
4. Per-instance branding/config shape (3 instances from one codebase).

**Needs rian / DigitalOcean (parked):** DO Droplet + production deploy pipeline;
the X-Accel-Redirect download variant (validate on the real nginx); pulling The
Palms' 250-file protected corpus from the live host; production compromise check.
Small open choices (non-blocking): DO one-Droplet vs three; disk vs Spaces for
files; transactional email provider. Recommendations in the plan §7.

## 2026-08-24 — Scoping audit + rebuild plan (awaiting decisions)

**Landed:**
- Full audit of the three WordPress staging sites (hartling-thesandsowners,
  -shoreclubowners, -thepalmsowners) + their DBs, plus Cloudways/Laravel/WP-hash
  research. Method: a parallel subagent workflow (audit reports saved under the
  session scratchpad; key facts captured in `brief.md`, `.memory/`, and the plan).
- Wrote **`.logs/planning/rebuild-plan.md`** (authoritative scope + phased plan)
  and filled in **`brief.md`** (scope + stack decision). Recorded the security
  incident in `.memory/2026-08-24-sands-backdoor-and-doc-leak.md`.
- Published a review artifact ("Owner Portals Rebuild") for rian.

**Priority zero — STAGING REMEDIATED 2026-08-25 (rian approved):** backdoor
file quarantined + removed from active_plugins, 8 credential CSVs deleted,
`srv-gw security-audit` = 0 critical, signature confirmed absent from the other
two sites. **Still open: live thesandsowners.com on Cloudways** — rian to check
for the same plugin + rotate secrets. Do NOT probe the live
`?garment-prototype-blocks=` URL (forges admin login). Details in the memory file.

**Decisions received (2026-08-25):** remediate now = YES (done); admin panel =
no preference (default to hand-rolled Blade); user import = import all, prune
later. Cloudways + 3-app isolation assumed/stated.

**Stack DECIDED (2026-08-25): Django + Postgres on DigitalOcean**, three isolated
instances from one codebase. Superseded the earlier Laravel/Cloudways draft after
rian pushed back (doesn't know Laravel; just moved a client off it). Reasoning,
reached jointly with the `professormediagroup` session: this app is auth/admin-
heavy but UI-light — Django's built-in admin + auth + pluggable hasher chain fit
exactly; the house FastAPI+React stack's earned assets are UI-layer this app can't
use, and its auth model ("identity from the id-auth gate") doesn't cover ungated
client apps. Documented deviation from "no app-owned auth" (approved with the plan).
New `django.md` standards doc (with the dispatch rule + the ungated-app gap fix) is
a P1 deliverable. Plan + brief + artifact all re-cut around Django/DO on 2026-08-25.

**Awaiting from rian (4 decisions, see plan §7 / artifact §06):**
1. Remediate the Sands incident now? (recommend yes)
2. Admin panel: Filament vs hand-rolled Blade (recommend Blade)
3. Confirm Cloudways + 3-app isolation (recommend confirm)
4. Import all owners vs active-only (recommend import all, prune later)

**Next session:** on rian's answers — (a) do P0 remediation if approved;
(b) scaffold the Laravel skeleton + write `/srv/projects/standards/laravel.md`;
(c) build the WP-hash hasher with fixture tests first (highest-risk item).
