# BidBrain project status, part 16: Page switcher, Auction4Cars and Dealer Auction purchases, progress bar (27 August 2026)

Moved unchanged from CLAUDE.md on 2026-09-18. The standing rules stay in CLAUDE.md, which lists every part of this log.

  - A LIVE AUCTIONS/PURCHASES/LOST PAGE SWITCHER, A COG SETTINGS ICON
    MOVED OFF THE LEFT PANEL, A SEARCH BOX ICON, THE STICKY SECOND
    ROW, AND THE PURCHASES/DIDN'T WIN PLATFORM TOGGLES MOVED TO MATCH
    THE LIVE AUCTION PAGE, 2026-08-27, several requests in one batch
    (Mark, in sequence: "ok now copy the toggle for the auction house
    and make toggle for Live Auctions, Purchases, Lost"; "also make
    the 2nd line for the auction houses and filters sticky"; "also
    move the settings icon from the left panel to between the user
    icon and bell, make this icon a cog"; "also add a magnifying
    glass icon to the search box on the nav bar and back ground
    colour in the box as the left panel"; "make the 2nd nav toggle
    for auction house move to the same place as the home page on the
    purchases and didnt win pages, make the look the same ie logos").
    - New `render._page_switcher(active)`, the exact same `.tabs`/
      `.tab` shape as the platform toggle it was copied from, but real
      `<a>` links (Live Auctions to cockpit.html, Purchases to
      purchases.html, Lost to lost-bids.html), since these are
      genuinely different pages, not a JS state change within one.
      Reuses the same icons `_appnav`'s own sidebar already uses for
      these exact three destinations (`_APPNAV_STAR_SVG`, `_BAG_SVG`,
      `_CROSS_SVG`) so the two never disagree. Sits in the top nav bar
      on all three pages, `active` marking whichever one is current.
    - The settings link moved out of `_appnav`'s own "Admin" group
      entirely (the group itself removed too, it held nothing else)
      and into a new `render._settings_navlink()`, a plain icon only
      `.settingslink` (the same class the shortlist star already
      uses) between the notification bell and the user avatar, on
      every one of the four pages. New `_COG_SVG` (a plain 6 tooth
      cog, hand drawn in this file's own feather style, not copied
      from a real icon library, matching how every other icon here
      is built), replacing `_WRENCH_SVG` everywhere it was used;
      `_WRENCH_SVG` itself removed outright, nothing referenced it
      any more.
    - The nav bar's own search box (`#search`/`.navsearch`) gained a
      real magnifying glass (new `_SEARCH_SVG`), positioned inside a
      new `.navsearch-wrap` container (absolutely positioned SVG,
      `padding-left` on the input itself to make room for it), and
      its own background changed from plain white to `var(--sidebar-
      bg)`, matching the left panel's own background exactly (the
      same variable `.tabs` and `.appshell` already use for that
      colour) rather than a hardcoded, only approximately matching
      hex.
    - `.platform-toggle-row` (the live auction page's own auction
      house toggle plus sort/filter controls row, built earlier the
      same day) is now `position:sticky`, offset by `.controls`'s own
      real rendered height (measured live, 61px) so the two stack
      cleanly rather than overlap, with a solid `var(--bg)` background
      so cards scrolling up behind it do not show through once it is
      stuck.
    - Purchases and Didn't win's own platform filter (previously
      All/Motorway/Carwow plain text tabs sitting in the top `.controls`
      bar, with a SEPARATE single dynamic logo-or-text heading further
      down showing whichever platform was picked) is now built the
      same way as the live auction page's own toggle: the real,
      clickable tabs themselves moved down into that second row, each
      platform button showing its own real logo (`{{MW_LOGO}}`/
      `{{CW_LOGO}}`, the same tokens these pages already had available)
      rather than plain text, with the live car count sitting right
      after it exactly like the live auction page's own `#platcount`.
      The old `updatePlatHeading()` (swapped a single `#platlogo`
      element between a logo image and plain "All" text depending on
      `currentTab`) is gone, replaced by a much smaller
      `updatePlatCount()` that only ever updates the count, since the
      toggle buttons themselves now show which platform is active via
      their own `.tab.on` state, no separate indicator needed.
    - A REAL BUG FOUND AND FIXED while wiring the page switcher in
      alongside these existing platform tabs, not assumed safe just
      because both features were built separately: purchases.html's
      and lost-bids.html's own `setTab(btn)` used a bare `document.
      querySelectorAll('.tab')`, matching EVERY element with class
      "tab" on the page, not scoped to the platform tabs' own `#tabs`
      container. Once the page switcher's own links also carried
      class `.tab` (reusing the same visual shape on purpose), clicking
      a platform tab would incorrectly clear the page switcher's own
      "on" highlight too (`t === btn` false for it, `classList.
      toggle('on', false)` firing anyway), confirmed live before the
      fix: clicking Motorway on the purchases page cleared "Purchases"
      itself out of its own active state in the page switcher above
      it. Fixed by scoping to `#tabs .tab` (the same fix the live
      auction page's own `setTab` already carried from earlier the
      same day), verified live afterward that clicking Motorway then
      Carwow leaves the page switcher's own active page correctly
      untouched throughout.
    - A REAL STYLING GAP FOUND AND FIXED THE SAME WAY, caught by
      actually reading the live computed style rather than trusting
      the screenshot: the live auction page's own new `#platcount`
      pill (`class="setcount platform-toggle-count"`) never actually
      got the rounded pill background it was meant to have, since the
      only existing `.setcount` rule anywhere in this file is scoped
      `.section .setcount` (inside the old `<h1 class="section">`
      heading), and the new count sits outside any `.section` element
      now. `.platform-toggle-count` gained its own complete pill
      styling (background, padding, border radius, weight) rather
      than relying on the scoped rule, applied on the live auction
      page and mirrored for the same new pill on purchases and Didn't
      win.
    - Verified live end to end throughout, not just the unit tests
      (none of this has dedicated coverage, matching every other
      pure layout change on these four pages): the page switcher's
      three real links confirmed on the live auction page, purchases
      and Didn't win, each correctly marking itself "on" on its own
      page and navigating cleanly to the other two; the settings cog
      confirmed present between the bell and the user avatar on all
      four pages and no longer in the left sidebar at all; the search
      box's real icon and matching `#e9edeb` background confirmed via
      `getComputedStyle` against the real `--sidebar-bg` value; the
      sticky second row confirmed genuinely pinned below the top nav
      bar on scroll (via a fresh tab, working around this session's
      own recurring Browser pane staleness on stale tabs, see below);
      purchases' and Didn't win's own new logo toggles confirmed to
      genuinely filter (Motorway narrowed purchases from 50 to 29 real
      rows, all logos load as real, non broken images) with the page
      switcher bug fix proven not to regress. 556 tests pass (this
      whole batch is render only markup, CSS and JS, no gate or
      pricing logic touched, no dedicated new tests).
    - A TOOLING NOTE worth remembering for a future session: this
      Browser pane's own JS execution and scroll gestures repeatedly
      returned stale, impossible readings (`window.innerWidth: 0`,
      wildly wrong element heights, a blank screenshot after a scroll)
      on tabs that had been idle for a few tool calls, always resolved
      by opening a genuinely fresh tab and re-running the same check,
      never by retrying in place. Also caught mid session: `computer`
      screenshot always captures whichever tab is currently fronted,
      not whichever tabId was last passed to `navigate`, `tabs_select`
      is required first when driving a background tab.

  - DIDN'T WIN PAGE MADE FULL WIDTH RESPONSIVE, 2026-08-27 (Mark: "make
    the didnt win page full width responsive"). Unlike purchases.html
    (widened to a 3560px cap back on 2026-08-24, "TABLE STILL SCROLLED
    ON A WIDE MONITOR") and the live auction cockpit's own `.layout`
    (the same cap), lost-bids.html's own `.wrap` had never had this fix
    applied, still capped at the original 1560px, sitting narrow with
    wasted space either side on anything wider than a laptop. Simple
    fix, the same one already proven on the other two pages: `.wrap`'s
    own `max-width` raised from 1560px to 3560px. Checked first whether
    anything else purchases.html had also needed at the time (capped
    thumbnail sizes, trimmed cell padding) was missing here too, it was
    not: lost-bids.html already carries the same `.tablewrap { overflow-
    x:auto; ... }` fallback and an equivalent 760px mobile block, and
    its own columns were already consolidated behind hover popovers
    (Bias bid, Retail est.) the same way purchases.html's own were, so
    the width cap alone was the real gap.
    Verified live end to end: at a real 1920px viewport `.wrap` widened
    from a hard 1560px ceiling to 1657px, `document.body.scrollWidth`
    stayed within the viewport (no page level horizontal scroll), and a
    screenshot confirmed the table genuinely spreads across the wider
    screen with a View link column now visible that a fixed narrow
    width had been crowding out; re-checked at the pane's normal laptop
    width afterward and confirmed nothing regressed there, matching
    purchases.html's own identical before/after shape. 556 tests pass
    (render only, no gate or pricing logic touched, no dedicated new
    tests, matching the established pattern for this kind of pure
    layout change).

  - NAV BAR TO SECOND ROW SPACING MADE CONSISTENT ACROSS ALL THREE
    PAGES, 2026-08-27 (Mark: "make sure all the spacing between the
    top nav for live auction etc the auction houses is the same
    accross all the pages, currently live auctions is more than the
    purchases and lost, make them all the same as purchases spacing").
    Measured live rather than guessed: purchases.html and lost-bids.
    html both sit their own toggle row exactly 20px below `.controls`
    (`.wrap`'s own top padding, nothing else in the way), while the
    live auction cockpit's own `.platform-toggle-row` sat 32px below
    it, a stray `padding-top:12px` on top of the same 20px `.layout`
    padding. That extra padding was added the same day the row was
    made sticky, incidental spacing left over from that change, never
    something anyone had actually asked for. Removed outright, the
    row's own `border-bottom` still gives it a clean edge when stuck
    with no gap needed above it. Verified live end to end (a real
    1280px viewport, not guessed): the gap from `.controls`'s own
    bottom to the real toggle buttons read 32px before the fix, 20px
    after, exactly matching purchases.html's own measured 20px. 556
    tests pass (render only CSS, no gate or pricing logic touched, no
    dedicated new tests, matching the established pattern for this
    kind of pure layout change).

  - A REAL DEALERWAY LOGO CAPTURED AND WIRED IN, 2026-08-27 (Mark: "heres
    the dealerway icon to use all all toggles", supplying a real JPG).
    DealerWay had no captured logo anywhere in this project since it was
    added 2026-08-24 (its own wordmark on the real site is an inline SVG
    component, harder to lift live than the others, so every logo
    dependent spot fell back to plain text). Getting the actual pasted
    image out of the chat needed two tries, both logged honestly rather
    than silently retried: the first attempt tried the same clipboard
    trick already proven for the Carwow logo (`osascript -e 'the
    clipboard as «class PNGf»'`), but the system clipboard at that
    moment held a generic macOS "JPG file" placeholder icon, not the
    real image, caught by actually opening the captured PNG before
    trusting it rather than assuming the trick worked. Mark then saved
    the real file into assets/ by hand ("Dealer Way Logo.jpg"), a clean
    2460x936 JPG on a solid white background.
    - Processed with PIL (no numpy available, plain pixel loops instead):
      chroma keyed every near white pixel (r,g,b > 245) to alpha 0, real
      pixel editing not a CSS trick, same basis RD_LOGO's own earlier
      chroma key used; cropped tight to the real content's bounding box;
      then downscaled from the tight crop's own huge 1618x255 to a plain
      320px wide icon size, matching the other platform logos (234 to
      304px wide), which also fixed a real bloat problem, the untouched
      full size PNG came out at 170KB, a single icon on every page load,
      resized down to a normal 19KB. Verified the chroma key genuinely
      left no white fringe by compositing the transparent PNG onto a
      mid grey background and inspecting the result before trusting it.
    - Two real assets saved: dw_logo.datauri (dark ink on transparent,
      for the section heading and the platform toggle, same basis as
      Carwow and Dealer Auction's own plain wordmarks) and dw_logo_
      white.datauri (the identical shape recoloured solid white, alpha
      channel untouched, for the dark "View on DealerWay" CTA button,
      same basis as Motorway/Carwow/Dealer Auction's own white variants).
      New DW_LOGO/DW_LOGO_WHITE constants (bidbrain/render.py), wired
      into `_SECTION_LOGO` (the toggle bar and section headings) and the
      card's own `_plat_logo` dict (the CTA button), plus a new `.platlogo
      .dealerway { width:83px; height:13px; ... }` CSS rule sized to the
      logo's own real 6.4:1 aspect ratio, matching the pattern every
      other platform's own CTA logo already follows. The stale "No
      captured DealerWay logo yet" comment above `_SECTION_LOGO` was
      removed, no longer true.
    - The original JPG (assets/"Dealer Way Logo.jpg") was deleted once
      the real derivatives existed, matching this project's own standing
      convention (no raw source images left lying around in assets/,
      confirmed live: RD_LOGO's own original "RD Logo.png"/"RD Logo
      Smile.png" are not there either, only the processed _src.png and
      .datauri files ever get kept).
    - Verified live end to end, not just visually: the platform toggle's
      own DealerWay button now renders a real, fully loaded image
      (`naturalWidth: 320`, matching the resized asset exactly, not a
      broken image); a real DealerWay card in the current shortlist
      (temporarily forced visible past the price ceiling and Ends
      within filters to inspect it, reverted by simply reloading, no
      filter state was actually changed) shows the real white logo on
      its own "View on dealerway" CTA button, confirmed both via
      `getComputedStyle`'s own `backgroundImage`/width/height and by
      eye in a screenshot. 556 tests pass (render only, a new binary
      asset plus a straightforward dict/CSS wiring, no gate or pricing
      logic touched, no dedicated new tests, matching the established
      pattern for adding a captured platform logo).

  - DEALERKIT ROW STATUS BECAME A COLOURED FRACTION BADGE, 2026-08-27
    (Mark, on the purchases page, with a screenshot of DealerKit's own
    job progress badge: "change the dealer kit status icon from a tick
    to like the icon found in dealer kit for job... shows a faction of
    how many items in total there is that need to be completed and how
    many are outstanding, use the traffic light system from red to
    green eg 1/10 (red) to 10/10 (green)"). The old `dkIconSvg(state)`
    (a plain grey ring for none, a filled orange tick for some, filled
    green for all, three coarse states) is gone, replaced by `dkFraction
    Badge(done, total)`: a small coloured pill, a partial progress ring
    (a real SVG circle with `stroke-dasharray` set to `done/total`, not
    a static icon) plus the plain "N/M" text, its own background colour
    a genuine red to amber to green interpolation (`dkTrafficColor`,
    plain RGB lerp in two halves, red to amber for the first half of the
    ratio, amber to green for the second, since reproducing this page's
    own oklch brand tokens in JS was not worth it for one small badge;
    amber reuses the exact #d39e00 already used elsewhere on this same
    page for an "in progress" state).
    - The real total is not always the same number: when a genuine
      DealerKit check has actually run and found the car (`dc.vehicle_
      id`), the badge uses the full real checklist (10 items, 11 when
      Indemnity applies to this purchase, the exact `doneVals` array
      already built for the old `dkState` computation, untouched);
      when a check ran and found nothing at all on DealerKit, the badge
      shows 0 against that same full total (confirmed genuinely missing,
      not just unchecked); when no real check has ever run for this row
      yet, the badge honestly falls back to BidBrain's own smaller push
      tracking list (5 items: price, photos, documents, retail, due in
      date), since the fuller DealerKit picture is not known yet, never
      guessed at. `dkState` itself (none/some/all) is untouched and
      still computed the same way in all three branches, kept since
      nothing depends on removing it and it costs nothing to leave.
    - The individual per item tick/ring icons INSIDE the popover
      (`dkTickSvg`/`dkRingSvg`, a separate, already existing mechanism
      showing each real checklist line as present/absent/unknown) were
      deliberately left untouched, this change is scoped to the row's
      own single master status badge only, matching what was actually
      asked; a stale comment claiming the popover's own ticks "read
      exactly like the row's own master icon" was corrected since that
      is no longer literally true now the master is a badge, not a
      circle.
    - Verified live end to end against the real running server: real
      badges read "10/10"/"11/11" (solid green), "9/11"/"9/10"/"10/11"
      (green shading toward amber), "6/10" (amber) and "4/10" (amber
      leaning red) across real rows, all colours confirmed via `style.
      background` matching the intended red to green gradient rather
      than eyeballed alone; clicking a badge still correctly opens its
      own popover exactly as the old icon did (`.show` class toggles),
      confirming the click behaviour survived the swap. 556 tests pass
      (render only, no gate or pricing logic touched, no dedicated new
      tests, matching the established pattern for this kind of pure
      display change).

  - AUCTION4CARS PURCHASE INFO ADDED TO BIDBRAIN, 2026-08-27 (Mark: "i
    now want to add a4c purchase info in to BB"). Real reconnaissance
    first, same discipline as every other reader in this project. The
    saved Auction4Cars session had died (even the known good stock URL
    bounced to the login page), a fresh `python3 login.py auction4cars
    manual` window was opened, Mark logged in, session saved fresh.
    - Found the real source live: My Account > "Won" only ever covers
      the last 3 months and reads 0 on this account (nothing currently
      mid process); "History" (`/DealerAdmin/MyAccount/History`, "My
      Purchase History... over the last 13 months") is the real,
      complete record and holds 2 genuine purchases (a Nissan Qashqai
      and a Renault Captur), the same "wider, completed record, not the
      narrower in-flight one" choice already made for Motorway
      (Purchases > Complete) and Carwow (the won screen's own widened
      90 day window). Like a live Auction4Cars listing, the History
      list page carries no registration or mileage at all, only make,
      model, derivative, price and date; both live only on each car's
      own detail page (`/VehicleAuction/AuctionPage/{id}`), confirmed
      live that a WON auction's own detail page still renders fully,
      "You won the auction!", and uses the exact same `car-detail-
      title`/`car-detail-content` markup a live listing's detail page
      already uses, so the existing `_detail_fields()` parser (built
      for `enrich_from_detail`) was reused directly rather than a
      second copy. Also noted in passing but not built: a real "View
      Invoice" link on the detail page (a likely richer payment
      breakdown source, unexplored) and a real "Lost" screen
      (`/DealerAdmin/MyAccount/Lost`, could one day feed the Didn't Win
      page's own lost bids table, which currently has no Auction4Cars
      reader at all), both flagged as real, separate follow up work,
      not attempted here.
    - New `auction4cars.parse_history(page_html)` (the list page, pure
      and testable, auction id/name/price/date, no browser needed) and
      `read_purchase_history(playwright, headless=True)` (one continuous
      session: reads the list, then visits every entry's own detail page
      in turn for its real reg and mileage, one bad detail page skipped
      rather than failing the whole read, its own reg simply stays
      blank). New `_parse_history_date`, the platform's own real date
      format ("28 January 2026", a full month name, never abbreviated
      the way Motorway's own purchases screen is), never guessed on an
      unrecognised shape. The purchase PRICE used is the History page's
      own "Purchase Price" figure, confirmed live to already include
      buyer's fees (a real auction's own detail page showed a lower
      "Final Price", the raw winning bid before fees, £4,911 against
      History's own £5,101), the same "what was actually paid" basis
      Motorway's and Carwow's own purchase price fields already use.
    - Wired into `purchases_run.py`'s `read_all()` (a third reader
      alongside Motorway and Carwow, `platforms` default widened to all
      three, a new `--auction4cars` CLI flag mirroring `--motorway`/
      `--carwow`), the Cazana retail estimate lookup (`need_cazana`,
      needs no extra page visit, the new reader already captures
      mileage on the same detail page visit that reads the reg), and
      the accounts email (`compose()`'s own platform loop). Deliberately
      NOT built: any payment breakdown, photos, documents, V5,
      collection date tracking or DealerKit push for this platform, only
      the base capture (reg, name, price, mileage, bought date), the
      exact same shape Carwow's own purchases started at before those
      were layered on in later, separate sessions; `refresh_purchase`'s
      own existing "no re read for purchases yet" message and
      `push_dealerkit`'s own "not sourced for this platform yet" note
      both already read correctly for a third platform with no special
      casing needed, and a stale comment claiming neither fed the table
      "at all today" was corrected since Auction4Cars now genuinely
      does.
    - `render.py`'s purchases page platform toggle (built earlier the
      same day) gained a real fourth button, Auction4Cars, using the
      same real `A4C_LOGO` asset the live auction page already has;
      Dealer Auction and DealerWay still have no purchases reader at
      all so the toggle stops at three real platforms, not the live
      auction page's own five. No other page logic needed touching:
      the platform filter (`r.platform === currentTab`), the Auc Status
      tracker, `stillDueIn`, and the CompMatch Bias bid computation
      were all ALREADY written generically enough (an unset `r.status`
      or `r.mileage` degrades to an honest "Not read yet"/no suggestion
      rather than breaking or guessing) to handle a genuinely new
      platform with zero changes, confirmed by reading through each one
      rather than assumed safe.
    - New pure tests against a synthetic fixture (`data/fixtures/
      auction4cars_history_sample.html`, never a real captured business
      record, same convention as every other fixture here): the list
      parser's own real fields, the full month name date parser
      including two genuinely unrecognised shapes correctly returning
      "" rather than a guess. 565 tests pass.
    - Verified live end to end, not just against the fixture: a real
      `python3 purchases_run.py --baseline --auction4cars` run (baseline
      since these are historical buys, not today's new ones, so nothing
      was emailed) stored both real purchases, `db.list_purchases()`
      confirmed via a direct read afterward showing the real reg,
      price, mileage and a genuine Cazana derived retail estimate
      (£7,995, £6,995) for each; the live purchases page (restarted
      com.bidbrain.serve) showed both rows correctly under a real,
      fully loaded Auction4Cars toggle button (once the page's own
      existing June date filter, unrelated to this change, was cleared
      to reach January's real dates), with the Auc Status tracker
      correctly reading Bid done, Documents and Delivery honestly "Not
      read yet" rather than guessed; the All tab's own count read 109
      (107 existing Motorway/Carwow rows plus the 2 new ones), the
      right arithmetic.

  - DEALER AUCTION PURCHASE INFO ADDED TO BIDBRAIN, 2026-08-27 (Mark, the
    same session: "ok lets do DA now"). The saved session had also
    expired here: even the known good stock URL bounced through a real
    OAuth redirect straight to a Cloudflare block page (this platform's
    login sits behind Cloudflare bot management, see auto_login_
    dealerauction.py's own docstring), confirmed genuinely expired (not
    just a Cloudflare fluke) by retrying the same probe with a real
    desktop UA and landing on the real Email/Password login form. A
    fresh `python3 login.py dealerauction manual` window, Mark logged in
    (this platform can occasionally ask for an SMS code, not needed this
    time), session saved fresh.
    - Found the real source live: Dashboard > "Invoice history", a
      genuine itemised transaction ledger with All/Purchases/Sales
      filter tabs (explicitly selected Purchases rather than relied on
      the "All" default matching it, confirmed live it does NOT always
      match past page 1, see below). By far the richest of the three
      platforms' own purchase records built this session: registration,
      full vehicle name, a COMPLETE real fee breakdown (Vehicle price,
      Fee, Tax, Total, arithmetic proven to sum exactly on every real
      row checked) and a real advert id are all already on the list
      itself, no detail page visit needed at all, unlike Auction4Cars.
      Newest first, a real pagination widget (analyticslabel=
      "paginator-next", the exact same button `dealerauction.py`'s own
      stock list reader already drives) showed 12 real pages of 10 rows
      each on this account.
    - New `dealerauction.parse_invoice_history(page_html)` (pure,
      testable) and `read_purchase_invoices(playwright, headless=True,
      max_pages=2)` (opens the real screen, clicks the Purchases tab
      explicitly, paginates). A REAL BUG FOUND AND FIXED while building
      the parser, not assumed correct from the first version: the
      established "find the first `<td>...</td>` after a marker" trick
      already used elsewhere in this project matched the WRONG column
      here, the Status column's own `<div class="c-pill">Complete</div>`
      plus several Angular comment placeholders looked like real content
      to that naive first-td match, so the Description column (reg,
      name, advert link) was never actually reached, silently returning
      0 rows. Caught immediately by testing against the real captured
      page rather than trusting the code, not shipped blind. Fixed by
      splitting each row on every real `</td><td>` boundary instead,
      addressing each real column by its own fixed position, immune to
      that same confusion; verified this new approach against the same
      real page afterward, all 10 rows parsed correctly with the full
      fee breakdown arithmetically consistent on every one. A second,
      smaller bug caught the same way before it ever shipped: the new
      money parser was first written as a second function literally
      named `_money`, silently shadowing an ALREADY EXISTING `_money` in
      this same file (whole pounds only, used by the stock listing
      parser), which would have quietly broken it the moment this file
      was next imported; renamed to `_pence_money` (real invoice totals
      carry pence, £212.50, the existing one never needed to).
    - Vehicle price/Fee/Tax are stored under the same generic
      winning_bid/motorway_fee/vat_total column names Carwow's own
      purchases already reuse (no transport_fee or protect_fee
      equivalent shown on this platform), so the purchases page's own
      existing Winning bid popover shows a genuine full fee breakdown
      for Dealer Auction too, for free, unlike Auction4Cars which
      deliberately stopped at the base capture. No mileage is on this
      list at all, so the Cazana retail estimate lookup naturally never
      fires for these rows (needs `r.mileage`, never guessed), no code
      change needed there.
    - A REAL, SEPARATE BUG FOUND while wiring this into the purchases
      page's own Auc Status tracker, caught by reasoning through the
      existing code rather than assumed safe: the tracker's own ternary
      only ever branched on `r.platform === 'Motorway'`, silently
      funnelling anything else, Dealer Auction included, through
      CARWOW'S OWN status vocabulary. Dealer Auction's real status here
      is just "Complete", which is not `'quote_accepted'`, so Documents
      would have silently read done (a real green tick that was never a
      real signal, just a coincidental non-match against an unrelated
      platform's own vocabulary) while Delivery read not yet (equally
      not a real signal). Fixed by adding an explicit `r.platform ===
      'Dealer Auction'` check to both stages, reading honestly "Not
      read yet" for each, same treatment an unrecognised platform
      already got; verified live the popover now reads exactly that for
      a real row, not a misleading green or orange.
    - Wired into `purchases_run.py`'s `read_all()` (a fourth reader,
      `platforms` default widened to all four, a new `--dealerauction`
      CLI flag), and the accounts email (`compose()`'s own platform
      loop). Deliberately still no photos, documents, V5 or DealerKit
      push, matching Auction4Cars' own same day scope boundary.
      `render.py`'s purchases page toggle gained a real fourth button
      using the live auction page's own `DA_LOGO` asset; only DealerWay
      still has no purchases reader at all.
    - New pure tests against a synthetic fixture (`data/fixtures/
      dealerauction_invoice_history_sample.html`, never a real captured
      business record): the parser's own real fields, the fee breakdown
      arithmetic, the 2 digit year date parser including two genuinely
      unrecognised shapes correctly returning "" rather than a guess.
      575 tests pass.
    - Verified live end to end, not just against the fixture: a real
      `python3 purchases_run.py --baseline --dealerauction` run stored
      all 15 real purchases currently on the account (2 real pages read,
      10 then 5, confirmed live this is a genuine difference in the
      Purchases-filtered result past page 1, not a bug, proving the
      earlier decision to click the Purchases tab explicitly rather than
      rely on "All" matching it was a real, necessary precaution); the
      live purchases page showed all 15 rows correctly under a real,
      fully loaded Dealer Auction toggle button, the Auc Status popover
      correctly read "Not read yet" for Documents and Delivery on a real
      row (not the misleading pre-fix reading), and the Winning bid
      popover showed the real, complete fee breakdown (Fee £212.50, VAT
      £42.50, Total paid £7,755, Transport correctly a dash, never
      guessed); the All tab's own count read 124 (109 existing rows plus
      the 15 new ones), the right arithmetic.
  - PLATFORM COLUMN GIVEN REAL LOGOS, PLUS A LINK BACK TO THE ORIGINAL
    LISTING, 2026-08-27 (Mark: "under the platform colume use the auction
    house logos", then, moments later: "theres a view advert link in the
    description to see the original listing with the images"). Both on
    the purchases page only, matching how narrowly every other request
    this session was scoped (lost bids already carries its own separate
    "View" link on `listing_url`, left untouched, and currently only ever
    shows Motorway data anyway).
    - The Platform column's old coloured text pill
      (`<span class="plat motorway">Motorway</span>`, real CSS colours
      only ever existing for Motorway and Carwow, Auction4Cars and Dealer
      Auction always fell back to a plain grey pill) is replaced by a new
      `platCell(platform)` JS helper, reusing the exact same real logo
      assets and `{{MW_LOGO}}`/`{{CW_LOGO}}`/`{{A4C_LOGO}}`/`{{DA_LOGO}}`
      tokens the platform toggle bar built minutes earlier the same day
      already has wired into this page's own `.replace()` chain, via a new
      `PLAT_LOGOS` JS object. A platform with no captured logo (DealerWay
      has none, and does not feed this table yet anyway) still falls back
      to the old plain text pill, the same graceful-fallback convention
      used everywhere else logos appear in this project. New scoped CSS
      (`.plat-logo { height:18px; }`, `.plat-logo.dealerauction { height:
      19px; }`, matching the toggle bar's own small sizing, a table row
      has no more room than that does); the pre-existing `.section-logo.
      auction4cars { border-radius:4px; }` rule (its own baked-in
      background) still applies unchanged since the image carries both
      classes.
    - The listing link: Dealer Auction and Auction4Cars purchases have no
      captured photo gallery of their own (unlike Motorway and Carwow,
      which both read a real gallery off their own purchase pages), so
      the only way to actually see a car's photos for those two platforms
      is the real "View advert" link Dealer Auction's own captured
      description text already carries (and the equivalent detail page
      link Auction4Cars' own reader already resolves), both already
      stored as `listing_url` on every purchase row, just never surfaced
      anywhere on this page. A small `.vlink` external-link icon (reusing
      `_EXTERNAL_LINK_SVG`, the same arrow already used for the cockpit's
      own "View on Motorway/Carwow" CTA, wired in via a new
      `{{EXT_LINK_SVG}}` token) now sits right after the Vehicle name on
      every row that has a `listing_url`, opening the real original
      listing in a new tab, whichever platform it came from.
    - A real Python escaping bug caught before it ever reached a browser,
      not live: `platCell`'s own class-building regex
      (`.replace(/\s+/g, '')`, turning "Dealer Auction" into
      "dealerauction" to match the existing CSS class) was first written
      with a single backslash inside this page's own plain (non-raw)
      triple-quoted Python string, which Python's own string literal
      parser treats as an unrecognised escape sequence, correctly still
      working today but throwing a `SyntaxWarning` and silently relying
      on undefined future behaviour. Caught by running `python3 -c
      "from bidbrain.render import render_purchases; render_purchases()"`
      before ever loading a browser and reading the warning, fixed to
      `\\s+` (a real double backslash in the Python source, so the
      browser genuinely receives a single `\s+` in its own JS).
    - Verified live end to end against the real running server, not just
      the unit tests: 63 real rows all carry a genuine, fully loaded logo
      image (`naturalWidth` nonzero, confirmed for Motorway, Carwow and
      Dealer Auction, no Auction4Cars purchase currently on file to
      check against but the same code path, zero fallback text pills
      anywhere on the page); every one of the 63 rows' own `.vlink`
      correctly carries a real listing URL (the first sampled, a real
      Carwow listing, opened to the genuine dealers.carwow.co.uk page);
      the table's own real width (1,419px) comfortably fits the browser
      pane with no horizontal scroll at this window size, the earlier
      screenshot's own clipped-looking Carwow logo was just the pane
      being narrower than the real table, not a layout regression. 575
      tests pass (this page has no dedicated automated test coverage
      beyond the pre-existing thumbnail-fallback check, verified live
      only, matching this page's own established pattern).
  - DEALER AUCTION PURCHASES GAINED REAL PHOTOS, 2026-08-27 (Mark: "still
    no images in the DA cars", straight after the platform logo work above
    shipped with a genuine gap left open, "Dealer Auction and Auction4Cars
    purchases have no captured photo gallery of their own"). Checked live
    before building anything: the Invoice history list page itself carries
    no `<img>` tag anywhere at all (confirmed against the real captured
    fixture and a fresh grep), so a photo was never going to come from that
    screen. The won advert's own page (the exact same address every row's
    own `listing_url` already points at, `#/advert/<id>`, a client side
    route) does carry a real, full photo set: `class="c-fpa-image-viewer__
    image" alt="Main image N"`, found live against a real captured advert
    (25 real photos). The first dozen or so load at full `preview.jpeg`
    quality straight into `src`; the rest stay lazy (a low res `thumbnail.
    jpeg` in `src`) until scrolled into view, but always carry the real
    preview quality url in a `data-full` attribute even before that, so the
    new `_parse_advert_gallery(page_html)` (bidbrain/readers/dealerauction.
    py) always prefers `data-full` over `src`, verified against the real
    captured page it correctly upgrades every lazy entry. Unlike Motorway
    and Carwow's own imgix urls, Dealer Auction's CDN (da-media.prod.
    atcdn.co.uk) carries no signed expiry token at all (checked live, a
    plain permanent path, confirmed still reachable with a bare `curl`, no
    session needed), so these are stored directly as plain urls in `photo_
    urls`, no local download dance needed the way Motorway and Carwow's
    own signed, hour-lived urls require.
    - New `read_purchase_gallery(page, advert_id)`, one extra page visit
      per purchase (the same real 4s wait the live reconnaissance for this
      needed, the gallery only finishes rendering a moment after the SPA
      route loads), returning `[]` rather than guessing on a listing that
      has since come down. Wired into `purchases_run.py`'s `read_all()` as
      a new Dealer Auction block, mirroring the exact shape of the
      existing Motorway and Carwow blocks (one continuous session for
      every purchase with a `listing_url`, one bad advert skipped rather
      than failing the whole batch, `photo_urls` json encoded the same
      way every other platform's own gallery already is).
    - Verified live end to end, not just against a synthetic fixture: a
      real `python3 purchases_run.py --dealerauction --dry-run` run read
      15 of 15 real galleries (16 to 68 photos each, checked directly
      against the database afterward), the live purchases page's own
      Photo column now shows a genuine, fully loaded thumbnail
      (`naturalWidth: 800`) for every one of the 13 visible Dealer Auction
      rows (Auction4Cars still shows none, since it has no reader for
      this yet either, a real, separate, still open gap), and clicking a
      thumbnail opens the existing shared gallery modal correctly showing
      "1 / 25" with real navigation, no code change needed there at all
      since it already accepted whatever `photo_urls` a row carries. 4 new
      tests against a new synthetic fixture (`data/fixtures/
      dealerauction_advert_gallery_sample.html`, never a real captured
      business record): the real image order, the `data-full` over lazy
      `src` preference, and a page with no gallery at all correctly
      returning nothing rather than a guess. 579 tests pass.
  - PURCHASES AND DIDN'T WIN REMOVED FROM THE LEFT NAV, 2026-08-27 (Mark:
    "remove the purchases and didnt win from the left nav"). With the same
    three destinations (Live Auctions/Purchases/Lost) already living as
    their own page switcher in the top nav bar on all three pages
    (_page_switcher, built earlier the same day), the nested Purchases and
    Didn't Win links under BidBrain in the left `_appnav` sidebar, plus the
    down arrow that used to collapse them, had become a genuine duplicate
    rather than a second real path, exactly what this removes.
    - `_appnav()`'s `item()` helper lost its `sub` parameter and the two
      nested `item("purchases", ...)`/`item("lostbids", ...)` calls (and
      their wrapping `<div id="appnav-subs">`) are gone outright, along
      with the `_appnav_chev` button that used to sit inside the BidBrain
      link to collapse them. BidBrain still picks up its existing lighter
      "section-active" tint whenever `active` is purchases or lostbids
      (untouched), so the left nav still shows which top level area you
      are in even though the exact page is now only ever reached from the
      top switcher.
    - Everything that only existed to support the now gone nested links
      and chevron was removed too, not left as dead code: the
      `toggleAppnavGroup` JS function (present once per page, 4 near
      identical copies, one per render function's own template) and the
      `.appnav-chev`/`.appnav-subs[hidden]`/`.appshell.nav-collapsed
      .appnav-chev`/`.appnav-item.sub`/`.appshell.nav-collapsed .appnav-
      item.sub` CSS rules, also 4 copies. Three of the four templates
      (purchases, lost bids, settings) turned out to have byte identical
      JS, letting one `replace_all` handle all three at once; the CSS
      block was byte identical for purchases and lost bids but settings'
      own copy was missing one comment line the other two carried, caught
      by the `replace_all` only matching 2 of the intended 3 occurrences
      rather than assumed successful, fixed with a separate edit for that
      one. The cockpit's own copies (an f-string template, `{{`/`}}` not
      `{`/`}`) needed their own two edits to match.
    - Verified live end to end against the real running server: the left
      nav on both the cockpit and purchases pages now lists exactly
      Dashboard/Stock/BidBrain/Customers/Reports, no Purchases or Didn't
      Win item and no `.appnav-chev`/`#appnav-subs` element anywhere in
      the DOM; the top nav's own Live Auctions/Purchases/Lost switcher
      still reaches both pages exactly as before, untouched by this
      change. All four pages' own generated `<script>` blocks re checked
      with `node --check` (clean, matching this project's own established
      verification step for a render.py change) and none carry a leftover
      `{{TOKEN}}`. 579 tests pass (render only, no gate or pricing logic
      touched, no dedicated new tests, matching this area's established
      pattern for a pure layout change).

  - PURCHASES PAGE: A REAL PROGRESS BAR, AND SORTABLE COLUMNS, 2026-08-27
    (Steven: "on the purchases page when you ask to check a DK record or
    check motorcheck or run the sync, there is no animation, or progress
    bar or anything to tell you its running or what point its at. it just
    brings up text, i keep thinking its timed out or something"). Asked
    to think it through and mock it up first, without writing code;
    published an interactive mockup (two simulate-only demos: a shared
    progress bar with a genuinely stuck-run state, and clickable sortable
    headers) matched against the real code first, not guessed at. The
    first attempt sent via SendUserFile rendered as a static, non
    interactive snapshot on Steven's end ("none of the clicking does
    anything"); republished through the real Artifact tool instead, which
    actually executes the page's JS, confirmed working before handing it
    back. Once approved, built for real.
    - The real cause investigated before building anything: the server
      already writes live progress (phase, message, and for the bulk
      DealerKit check, a real done/total count) to the same
      run_progress.json the cockpit's own run bar already reads, and
      this page's five poll loops (Sync now, Check all DealerKit
      records, per row Check DK record, per row push, per row Check
      Motorway/Carwow) already fetched it every 3 seconds and threw it
      away, only ever checking it once at the very end for an error. The
      information was already there, just never shown while an action
      was actually running, which is the entire reason it looked timed
      out rather than genuinely slow.
    - `bidbrain.render.render_purchases()` gained a shared `.runbar`
      (mirroring the cockpit's own `.runbar/.runtrack/.runfill`, so it
      reads as the same feature rather than a second design), painted by
      a shared `paintRunbar(s, active)` and driven by one generic
      `pollProgress(runningKey, onFinish)` all five poll loops now call
      into instead of duplicating their own setTimeout/fetch/recurse
      logic; each poll function's own body shrank to just its own
      `onFinish` callback (button reset, `loadPurchases()`, its own
      error wording), the real per action behaviour untouched. The one
      genuinely new thing: `paintRunbar` compares the progress file's own
      timestamp against the SERVER's clock (`s.now`, already returned by
      `/api/run-status`), not the browser's, so a laptop going to sleep
      mid check cannot fake a stuck run or hide a real one; past 90
      seconds with no update the bar turns amber and says plainly "no
      update for Ns", the actual answer to "is this stuck or just slow"
      rather than a spinner that could mean either. `showRunbar(msg)`
      paints an immediate first frame the moment an action starts so the
      bar is never blank for the first 3 seconds waiting on the first
      poll. Sync now's own button also finally uses the `.spinning` CSS
      that had sat dead on this page since it was first styled (wired
      only on the cockpit's own run button until now). A page reload
      mid check now also restores this correctly for all four passes
      that can be mid flight (previously only Sync and the bulk
      DealerKit check were checked on load, the per row push and re
      read were not).
    - Column sorting (`SORT_KEYS`, `compareRows`, `onSortHeaderClick`,
      `thLabel`): click a header to sort by it, click again to reverse,
      an arrow on the active column. One key function per sortable
      column returning the exact value that column visibly displays, a
      numeric column sorting numerically rather than as text (so £9,000
      never sorts before £800), and a row missing the value entirely
      always sorting to the very end whichever direction, never looking
      like the smallest real value. `render()` already rebuilds the
      whole table including its own `<thead>` on every call, so the
      active column's own class and arrow are baked into that string
      fresh each render rather than patched onto existing DOM.
    - A real bug found live, not guessed: the first version of the
      Winning bid sort key fell back to `total_price`/`price` when
      `winning_bid` was null, matching the Margin calculation elsewhere
      on this exact page, but the COLUMN ITSELF only ever displays
      `pounds(r.winning_bid)` with no such fallback, so sorted rows put
      real numbers (borrowed from a field the column never shows)
      scattered among the "–" cells rather than at the end, looking
      broken rather than sorted. Caught by sorting the real 213 row
      purchases list and checking the actual displayed values, not just
      that the function ran. Fixed to `r.winning_bid` alone, matching
      the column exactly; verified live afterward with real data showing
      a genuinely ascending run of real bids (£2,100 to £4,651 first)
      followed by every null row at the end.
    - A second real bug found building this, unrelated to the two asks
      but in the exact code being touched: `thLabel`'s own JS string
      used backslash-escaped double quotes (`\"`) intending them to
      survive into the rendered HTML, but `render_purchases()`'s whole
      template is a plain (non-raw) Python triple-quoted string, so
      Python's own parser silently collapsed every `\"` to a bare `"`
      before the JS was ever written out, breaking the generated
      `onclick` attribute (caught by `node --check` on the real rendered
      output, not the Python source, which looked fine). Fixed the
      established way this file already handles a literal quote needing
      to survive into JS output elsewhere (`&#39;`, an HTML entity,
      never a backslash escape, which sidesteps Python's own string
      parsing entirely since there is no backslash involved).
    - Verified live end to end against the real 213 row purchases table:
      sorting Winning bid ascending correctly ran £2,100 up to real
      values with every null pushed to the end; sorting Reg ascending
      then descending correctly reversed and flipped the header's own
      arrow; `paintRunbar` proven directly against synthetic progress
      objects for all five real states (an immediate `showRunbar` frame,
      a determinate 5/20 fill, a genuinely stale 95 second gap turning
      amber with the live counter, an error that stays visible once
      finished, and a clean finish that hides the bar), without needing
      to trigger a real multi minute DealerKit check just to prove the
      painting logic. 543 tests pass (this page has no dedicated Python
      test coverage, verified live only, matching its established
      pattern; the count includes the unrelated hide-note-learning
      change committed alongside this work, see the entry above).

  - THE RUN BAR ABOVE WAS SHOWING A STALE, UNRELATED MESSAGE, FOUND AND
    FIXED THE SAME DAY (Steven, live, a real screenshot: "the screen
    shows this now when ive tried to sync, its not clear what is
    happening to me", the bar reading "Updated. 216 shortlisted, 14
    held so far." with a 42+ minute old "no update" warning while
    "Checking Motorway and Carwow..." sat right next to it). The real
    cause: `purchases_run.py`'s own actual sync logic (`read_all()`/
    `main()`, the function `/api/run-purchases` runs) had NEVER written
    to `run_progress.json` at all, unlike `push_dealerkit`/
    `check_dealerkit`/`refresh_purchase` in the exact same file, which
    all already did. The new `paintRunbar` built earlier the same day
    correctly showed `s.progress` while `purchases_running` was true,
    but with nothing from the sync itself ever writing to that shared
    file, it just kept displaying whatever the LAST unrelated pass (a
    daily cockpit run, from its own `_write_pages`) had left behind,
    stale, dressed up as if it were live.
    - Added real `_progress()` calls throughout the actual real stages
      of a sync, matching the exact style already used elsewhere in
      this file: reading Motorway, reading Carwow, reading Motorway's
      cancelled list, then a live done/total count through both the
      Motorway and Carwow per purchase payment breakdown loops (the
      slowest part, one to several page visits each, previously
      completely silent) and the Cazana retail lookup loop, ending on a
      real "done" message once `db.record_purchases` finishes.
    - While investigating this live, found the user's own original
      sync was not merely slow, it was genuinely STUCK: the real
      subprocess had been alive 47 minutes but shown only 2 seconds of
      real CPU time (`ps aux`), the same "genuinely unresponsive
      headless Chromium" category already on record elsewhere in this
      file, and it was running the OLD, pre fix code regardless (a
      Python subprocess does not hot reload). Stopped cleanly via the
      existing `/api/stop-run` endpoint (safe, purchases_run.py only
      writes to the database at the very end of a successful run, so
      nothing was lost) and a fresh sync started with the fix in place.
    - Verified live: a real fresh sync immediately showed "Reading
      payment details for 25 Motorway purchases..." then genuinely
      ticked through real per purchase messages with real
      registrations ("...purchase 1 of 25 (AD68AVB)...", "...2 of 25
      (WV70GNN)...") every few seconds, watched via a Monitor rather
      than guessed complete, confirming the bar now reflects the
      sync's own real progress rather than a stale leftover from
      whatever last happened to write that shared file. 543 tests pass.
