ó
    &±jŠ� ã                   ó   • S r SSKrSSKrSSKrSSKrSSKrSSKJrJr  S r	1 Skr
S rS r\R                  R                  \R                  R                  \R                  R                  \R                  R!                  \5      5      5      S	5      rS
rSrS r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r\" S5      r S\!4S jr"S r#S r$S r%S\!4S jr&S\!4S jr'S\!4S jr(S \!S!\!S\!4S" jr)SáS \!S!\!S\!4S# jjr*SâS\!4S$ jjr+SãS\!4S& jjr,S'r-S(r.S)r/S*r0S+S,S-S.S/S0S1SSS2S%04S3S4S5S6S7S8S9/r1  SäS: jr2S\!4S; jr3S\!4S< jr4S=r5S>r6S? r7S@ r8SåSA jr9SB r:SC r;SD r	SE r</ SFQr=/ SGQSHSI/SH/SISJ// SKQ/ SLQSM.r>/ SNQr?SO r@SP rASQ rBSR rCSåSS jrDSæST jrESU rFSV rGSW rHSXrISYrJSZrKS[rLS\rMS]rNS^rOS_rPS` rQSãSa jrR/ SbQrSSçSc jrTSd rU\R¬                  " Se5      rW\R¬                  " Sf5      rX\R¬                  " Sg5      rYSh rZSi r[Sjr\Skr]Slr^Smr_Snr`SoraSprbSqrcSr rd\d" Ss5      \d" St5      \d" Su5      \d" Sv5      \d" Sw5      \d" Sx5      \d" Sy5      \d" Sz5      \d" S{5      \d" S|5      S}.
reS~rfS rgSèS€ jrhSçS� jriS‚ rjSƒ rkSçS\!4S„ jjrlSçS\!4S… jjrmSçS\!4S† jjrnS‡ roS\!4Sˆ jrpS‰SŠS‹SŒS�SŽS�S�S‘S’S“S”.rq/ S•QrrS–S–S–S—S—S–S–S–S˜.rsS™1rtSšS›SœS�SžSŸS S¡S¢S¡S£S¤.ruS¥S¦S§S¨S©.SªS«S§S¬S©.S­S®S­S¯S©.S°S±S²S³S©.S´SµS™S¶S©.S·S¸S™S¹S©./rvSº\!S\w4S» jrxS\!4S¼ jryS\!4S½ jrzS\!4S¾ jr{S¿r|SÀr}SéS\!4SÁ jjr~S\!4SÂ jrSÃr€S\!4SÄ jr�S\!4SÅ jr‚\\\\\SF.rƒSÆ r„SçSÇ\!S\!4SÈ jjr…SÉ\€SÊSË4SÌ\MSÍSÎ4SÏ\OSÐSÑ4SÒ\NSÓSÔ4/r†SÕ r‡SêSÖ jrˆSçS× jr‰SåS\!4SØ jjrŠS\!4SÙ jr‹  SëSÚ jrŒSåSÛ jr�SìSÜ jrŽSÝ r�SíSÞ jr�SáSß jr‘         SîS\!4Sà jjr’g)ïaØ  
Render the cockpit web page from a set of assessments.

The card is built around what Steven scans for, in order: the governing value
first, then the recommended maximum bid, then CAP Clean, then which platform the
car is on. The condition grade sits as a coloured circle on the photo. Below
that hero block sit the supporting figures and a link to bid.

House style: no hyphens and no dashes in any text the app writes. Manufacturer
trim names keep their real spelling.
é    Né   )ÚpoundsÚDEFAULT_SETTINGSc                 óN  • U R                   =(       d    Sn[        R                  " SU5      nSnS H)  nUR                  5       UR                  5       ;   d  M'  Un  O   SR	                  S U(       a  UR                  S5      OSU4 5       5      nU=(       d    U(       a  USS $ S	$ )
zŽA short, readable engine for the review card: litres and fuel, since the
model and trim (which often carries the family name) is in the title.Ú z
\b\d\.\d\b)ÚPetrolÚDieselÚHybridÚElectricÚ c              3   ó6   #   • U  H  o(       d  M  Uv •  M     g 7f©N© ©Ú.0Úxs     Ú0/Users/stevendouglas/BidBrain/bidbrain/render.pyÚ	<genexpr>Ú _engine_label.<locals>.<genexpr>   s   é € ÐOÒ I˜1ËQ—Q‘QÒ Iùó   ‚
�	r   Né   únot read)ÚengineÚreÚsearchÚlowerÚjoinÚgroup)ÚcarÚengÚlitresÚfuelÚfuÚlabels         r   Ú_engine_labelr%      sŒ   € ð �*‰*×
˜€CÜ�YŠY�} cÓ*€FØ€DÛ8ˆØ�8‰8‹:˜Ÿ™›Õ$ØˆDÙñ 9ð �H‰HÑO¶F §¡¨a¤ÀÀDÑ IÓOÓO€EØ×5¦�S˜˜"�XÐ5¨*Ð5ó    >   úc4 grandúgrand c4úsanta feúrange roverúland cruiserc                 óÜ  • U=(       d    SR                  5       nU(       d  gU =(       d    SR                  5       R                  5       nUR                  5       nUS   n[        U5      S:”  a"  SR	                  USS 5      R                  5       OSnU[
        ;   a  SR	                  USS 5      $ US:X  ao  [        R                  " SU[        R                  5      (       a  UR                  5       $ [        R                  " S	U5      nU(       a  UR                  S5       S
3$ U$ US;   a¤  [        R                  " SU[        R                  5      nU(       a"  UR                  S5      R                  5        S3$ [        R                  " SU5      nU(       a5  UR                  S5      R                  5       n[        U5      S:X  a  U S3$ U$ U$ US:X  a  [        S U 5       5      (       a  gU$ )aP  A clean base model for grouping and filtering (the Model dropdown),
stripping the derivative and engine text baked into the model field by
Motorway's CSV (for example "A 200 Sport Executive Auto" or "116D M
Sport Auto" should filter as "A Class" and "1 Series", not as their full
derivative string). Display only, never touches pricing.r   r   r   r   Né   Úbmwz^X\dz
^(\d)\d{2}z Series)Úmercedeszmercedes-benzúmercedes benzz^([A-Za-z]{1,3})[\s-]?Class\bz Classz^([A-Za-z]{1,3})\s?-?\dÚ
volkswagenc              3   ód   #   • U  H&  oR                  S 5      R                  5       S:H  v •  M(     g7f)Ú!ÚupN©Ústripr   )r   Úts     r   r   Ú _model_family.<locals>.<genexpr>I   s$   é € Ð%SÊFÀq§g¡g¨c£l×&8Ñ&8Ó&:¸dÖ&BÊFùs   ‚.0zup!)r6   r   ÚsplitÚlenr   Ú_TWO_WORD_MODELSr   ÚmatchÚIÚupperr   Úany)	ÚmakeÚmodelÚmake_lÚtokensÚfirstÚtwoÚmÚm2Úletterss	            r   Ú_model_familyrI   %   s™  € ð �[�b×ÑÓ!€EÞØØ�j�b×ÑÓ!×'Ñ'Ó)€FØ�[‰[‹]€FØ�1‰I€EÜ*-¨f«+¸«/ˆ#�(‰(�6˜"˜1�:Ó
×
$Ñ
$Ô
&¸r€CØ
ÔÓØ�x‰x˜˜r ˜
Ó#Ð#Ø�ƒÜ�8Š8�G˜U¤B§D¡D×)Ñ)Ø—;‘;“=Ð Ü�HŠH�] EÓ*ˆÞØ—g‘g˜a“j�\ Ð)Ð)ØˆØÐ?Ó?ô �XŠXÐ6¸¼r¿t¹tÓDˆÞØ—h‘h˜q“k×'Ñ'Ó)Ð*¨&Ð1Ð1Ü�HŠHÐ/°Ó7ˆÞØ—g‘g˜a“j×&Ñ&Ó(ˆGÜ),¨W«¸Ó):�g�Y˜fÐ%ÐGÀÐGØˆØ�Ó¤#Ñ%SÉFÓ%S×"SÑ"SØØ€Lr&   c                 ó  • 0 nU  H‚  nU=(       d    SR                  5       nU(       d  M%  [        R                  " SSU5      R                  5       R                  5       nUR	                  U[        5       5      R                  U5        M„     0 nUR                  5        He  u  p5US;   a  SnOJU Vs/ s H  owUR                  5       :w  d  M  UPM     nnU(       a  [        U5      S   O[        U5      S   nU H  nXdU'   M	     Mg     U$ s  snf )al  Collapse the case and hyphen spelling variants platforms use for the
same make (CITROEN vs Citroen, KIA vs Kia, Mercedes vs Mercedes-Benz vs
Mercedes-benz) into one canonical display label, keyed by every raw
spelling seen. Without this the Make filter split one real make into
several dropdown entries, and picking one of them (say "Mercedes") missed
that make's own cars read from a source that used a different spelling
(say "Mercedes-Benz"), which is what made A Class and C Class look like
they were not scoped to Mercedes at all. Filter and display only, never
touches car.make itself or anything pricing.py reads.r   z[\s-]+r   )r/   r0   zMercedes-Benzr   )
r6   r   Úsubr   Ú
setdefaultÚsetÚaddÚitemsr>   Úsorted)	ÚmakesÚgroupsrF   ÚkeyÚcanonÚvariantsr$   ÚvÚ
not_shoutys	            r   Ú_canon_make_maprX   N   sì   € ð €FÛˆØ�W�"�O‰OÓˆÞÙÜ�fŠf�Y  QÓ'×-Ñ-Ó/×5Ñ5Ó7ˆØ×Ñ˜#œs›uÓ%×)Ñ)¨!Ö,ñ ð €EØŸ™ž‰ˆØÐ/Ó/Ø#‰Eñ &.Ó@¢X °a·g±g³i±Ÿ!¡XˆJÐ@Þ-7”F˜:Ó& qÒ)¼VÀHÓ=MÈaÑ=PˆEÛˆAØ�!‹Hó ñ (ð €Lùò	 As   Â/DÃ
DÚassetsaÍ  
        <div class="hide-row"><button class="hidelink" onclick="openHide(this)">Hide this car</button></div>
        <div class="hidebox" hidden>
          <input class="hidereason" type="text" placeholder="Why would you not buy this car">
          <div class="hidebtns">
            <button class="confirm" onclick="confirmHide(this)">Send</button>
            <button class="cancel" onclick="cancelHide(this)">Cancel</button>
          </div>
        </div>a�  
    async function post(url, body){
      const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
      if(!r.ok) throw new Error('save failed');
      return r.json();
    }
    function hideCardData(el){ const c=el.closest('.card'); return {
      reg:c.dataset.reg, make:c.dataset.make, model:c.dataset.model, name:c.dataset.name,
      max_bid: c.dataset.maxbid?+c.dataset.maxbid:null, reserve: c.dataset.reserve?+c.dataset.reserve:null }; }
    function openHide(btn){ const box=btn.closest('.card').querySelector('.hidebox'); box.hidden=false; const i=box.querySelector('.hidereason'); if(i) i.focus(); }
    function cancelHide(btn){ btn.closest('.hidebox').hidden = true; }
    async function confirmHide(btn){
      const reason = btn.closest('.hidebox').querySelector('.hidereason').value.trim();
      try {
        await post('/api/hide', {...hideCardData(btn), reason});
        btn.closest('.card').classList.add('gone');
        if (typeof applyView === 'function') applyView();
      } catch(e){ flash('Could not save. Open the page at its web address, not from a file.', false); }
    }c                 óò   • [         R                  R                  [        U 5      n [	        USS9 nUR                  5       R                  5       sSSS5        $ ! , (       d  f       g= f! [         a     gf = f)zƒRead a logo as a data URI so it embeds straight into the page. Returns ''
if the asset is missing, and the pill falls back to text.zutf-8)ÚencodingNr   )ÚosÚpathr   Ú_ASSETSÚopenÚreadr6   Ú	Exception)Únamer]   Úfs      r   Ú_logord   ™   sV   € ô �7‰7�<‰<œ Ó&€DðÜ�$ Ò)¨QØ—6‘6“8—>‘>Ó#÷ *×)×)ûäó Ùðús.   ¦
A) °AÁ	A) Á
A&Á"A) Á&A) Á)
A6Á5A6zmw_logo.dataurizcw_logo.datauriza4c_logo.dataurizmw_logo_white.dataurizcw_logo_white.dataurizda_logo.dataurizda_logo_white.dataurizdw_logo.dataurizdw_logo_white.dataurizrd_logo.dataurizrd_smile.datauriÚreturnc                 óZ   • [         R                  " [        U b
  U 5      5      $ S5      5      $ ©Nr   )ÚhtmlÚescapeÚstr)Úvalues    r   Ú_escrl   Ñ   s&   € Ü�;Š;”s EÑ$5˜5Ó>Ó?Ð?¸2Ó>Ó?Ð?r&   c                  ó   • g)ax  The hardened element access vocabulary (v3.4), emitted first in every
page's own script. One convention everywhere: an id that should exist but
does not gets one console warning, then a read degrades to blank and a
write to a no op, so a single missing element can never abort a whole
page script again (the failure class proven live in the 2026-08-28
audit). Only function declarations, so everything hoists, immune to the
top level ordering traps this file has hit before. Returns plain single
brace JS: the f-string cockpit injects it by expression, the plain
string pages by the {{GUARD_JS}} token, either way it lands verbatim.a¦  
    function bbEl(id){
      var el = document.getElementById(id);
      if(!el){
        bbEl._w = bbEl._w || {};
        if(!bbEl._w[id]){
          bbEl._w[id] = true;
          console.warn('BidBrain: expected element #' + id + ' is missing. Its control is inert on this page.');
        }
      }
      return el;
    }
    function bbVal(id){ var el = bbEl(id); return el ? el.value : ''; }
    function bbChecked(id){ var el = bbEl(id); return !!(el && el.checked); }
    function bbSetChecked(id, on){ var el = bbEl(id); if(el) el.checked = !!on; }
    function bbSetVal(id, v){ var el = bbEl(id); if(el) el.value = v; }
    function bbSetText(id, t){ var el = bbEl(id); if(el) el.textContent = t; }
    function bbSetHTML(id, h){ var el = bbEl(id); if(el) el.innerHTML = h; }
    function bbEsc(v){
  return String(v == null ? '' : v)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function bbPageNotice(msg){
      // A persistent amber banner for a load bearing failure that must never
      // pass silently (golden rule 5). Unlike flash() this does not auto
      // hide, a standing data correctness warning should stand.
      console.error('BidBrain: ' + msg);
      var host = document.querySelector('.insetpanel') || document.body;
      if(!host) return;
      var div = document.createElement('div');
      div.className = 'notice';
      div.style.cssText = 'background:#fdf3e2;color:#8a5a00;border:1px solid #f0d9ae;' +
        'border-radius:10px;padding:10px 14px;margin:10px 14px;font-size:13px;font-weight:600;';
      div.textContent = msg;
      host.insertBefore(div, host.firstChild);
    }
r   r   r&   r   Ú	_guard_jsrn   Õ   s   € ð%r&   c                 ó|   • [         R                  " SU 5      nU(       a  [        SUR                  S5      -   5      eU $ )zýTripwire for the unreplaced template token bug class: a {{TOKEN}}
surviving into a rendered page means a .replace was missed and the
literal token would ship as page text, which has genuinely happened
before. Loud at render time, never silent on screen.z\{\{[A-Z_]+(_[A-Z]+)*\}\}z,unreplaced template token in rendered page: r   )r   r   ÚAssertionErrorr   )ÚpageÚleakeds     r   Ú_finishrs     s<   € ô
 �YŠYÐ3°TÓ:€FÞÜØ:¸V¿\¹\È!»_ÑLóNð 	Nà€Kr&   c                 óJ   • U R                  S[        [        -   [        -   5      $ )z´Substitutes the one shared sync light block into a style string.
Plain replace, not an f-string: both consumers are large plain string
templates whose own braces must stay literal.ú{SYNC_LIGHT_CSS})ÚreplaceÚSYNC_LIGHT_CSSÚRUN_KIT_CSSÚRUN_MODAL_CSS)Úcsss    r   Ú
_SL_SPLICEr{   !  s    € ð �;‰;Ð)¬>¼KÑ+GÌ-Ñ+WÓXÐXr&   c                  ó   • [        S5      $ )a.  CSS for the More drawer, its button and the filter chips row. Ported
from the approved mockup with one deliberate fix: the mockup's selected
rail row referenced an undefined --tint variable (so the tint never
actually painted there); here it is a real color-mix of the dealership's
own themable --icon.uÍL  
  /* ---- shared control frame ----
     One scale, every value taken from a pattern the app already used, so new
     controls cannot drift the way they did in v3.4 (Steven 2026-08-30: parts
     of it "look aesthetically different from the rest of the apps theme").
     Row controls take their radius from .tabs (11px), panel level buttons
     from .save and .bellbtn (9px), inputs from the settings page (8px),
     cards from .card (12px) and floating panels from .bellpanel (14px). */
  :root {
    --fr-h-control:34px;          /* every control on a head bar row */
    --fr-radius-control:11px;     /* matches .tabs */
    --fr-radius-btn:9px;          /* matches .save, .bellbtn */
    --fr-radius-input:8px;        /* matches the settings page inputs */
    --fr-radius-card:12px;        /* matches .card */
    --fr-radius-panel:14px;       /* matches .bellpanel */
    --fr-fs-small:11px;           /* counts, meta glyphs, uppercase labels */
    --fr-fs-note:12.5px;          /* matches .note */
    --fr-fs-body:13px;
    --fr-fs-input:14px;
    --fr-fs-title:15px;           /* panel headings, one step under .card h2 */
    --fr-gap-field:10px;          /* matches .field */
    --fr-shadow-panel:0 10px 28px oklch(0.279 0.0299 260.05 / .16);  /* .bellpanel */
  }
  /* Every control on a head bar row is one height and one radius. Before
     this the row carried 34, 39 and 41px side by side, and four radii. */
  .platform-toggle-row .tabs, .section-head .tabs {
    padding:3px; min-height:var(--fr-h-control); box-sizing:border-box; align-items:center; }
  .platform-toggle-row .tab, .section-head .tab {
    padding:0 12px; display:flex; align-items:center; justify-content:center; }
  /* Below 860px these rows (the page switcher, the platform tabs, up to six
     of them with real logos, none of it wrapping) ran wider than the real
     screen and pushed the WHOLE PAGE into horizontal scroll (found in
     review, 2026-08-30, measured live: a real 375px phone read 643px of
     scrollWidth, and three cockpit platform tabs plus "Lost" sat off the
     right edge with no sign there was more). A page that scrolls sideways
     is exactly what the 2026-08-29 hardening pass set out to stop.
     Each row now scrolls inside ITSELF instead, so nothing forces the page
     wider than the viewport: min-width:0 lets a flex row actually shrink
     below its content's natural width (a flex item defaults to refusing
     to), and overflow-x:auto plus flex-wrap:nowrap means you swipe the
     strip rather than see all of it at once, no worse than today, since
     you cannot see all six at once today either, you just have to scroll
     the whole page to reach the rest. The scrollbar is hidden, this is a
     touch gesture, not a desktop control. */
  @media (max-width:860px) {
    .controls-actions, .platform-toggle-row, .section-head { min-width:0; }
    .tabs.pageswitcher, .tabs.platform-toggle {
      max-width:100%; overflow-x:auto; flex-wrap:nowrap;
      scrollbar-width:none; -ms-overflow-style:none; }
    .tabs.pageswitcher::-webkit-scrollbar, .tabs.platform-toggle::-webkit-scrollbar { display:none; }
  }
  /* ---- the More drawer and filter chips ---- */
  .morescrim { position:fixed; inset:0; background:transparent; z-index:64; }
  .morescrim[hidden] { display:none; }
  .moredrawer { position:fixed; top:0; right:0; bottom:0; background:#fff; z-index:65;
    box-shadow:-14px 0 44px oklch(0.279 0.0299 260.05 / .24); display:flex; flex-direction:column;
    width:300px; max-width:96vw; transition:width .16s ease; }
  .moredrawer[hidden] { display:none; }
  .moredrawer.expanded { width:660px; }
  .moredr-head { padding:14px 16px 10px; flex:none; }
  .moredr-top { display:flex; align-items:center; gap:8px; }
  .moredr-top b { font-size:var(--fr-fs-title); }
  .moredr-back { width:26px; height:26px; border-radius:var(--fr-radius-input); border:1px solid var(--line);
    background:#fff; color:var(--soft); cursor:pointer; font-size:var(--fr-fs-small); margin-right:2px; }
  .moredr-back:hover { background:var(--sidebar-bg); }
  .moredr-back[hidden] { display:none; }
  .moredr-close { margin-left:auto; width:28px; height:28px; border-radius:var(--fr-radius-input);
    border:1px solid var(--line); background:#fff; color:var(--soft); cursor:pointer; }
  .moresplit { flex:1; min-height:0; display:flex; }
  .moredrawer:not(.expanded) .moresplit { flex-direction:column; overflow-y:auto; padding:2px 8px 14px; }
  .morerail { flex:none; overflow-y:auto; }
  .moredrawer:not(.expanded) .morerail { width:100%; padding:0; }
  .moredrawer.expanded .morerail { width:210px; border-right:1px solid var(--line); padding:8px; }
  .morerow { display:flex; width:100%; align-items:center; gap:8px; border:none; background:none;
    font:inherit; font-size:var(--fr-fs-body); font-weight:700; color:var(--ink); padding:11px 10px;
    border-radius:var(--fr-radius-btn); cursor:pointer; text-align:left; }
  .morerow:hover { background:var(--sidebar-bg); }
  .morerow.on { background:color-mix(in srgb, var(--icon) 10%, transparent); color:var(--icon); }
  .morerow .car { margin-left:auto; color:var(--soft); font-size:var(--fr-fs-small); }
  .morerow.on .car { color:var(--icon); }
  .morerow.plain { font-weight:600; color:var(--soft); font-size:var(--fr-fs-note); }
  .morebadge { background:var(--icon); color:#fff; border-radius:999px; font-size:var(--fr-fs-small);
    font-weight:800; padding:1px 8px; }
  .morebadge[hidden] { display:none; }
  .morepanel { flex:1; overflow-y:auto; padding:10px 16px 16px; }
  .morepanel[hidden] { display:none; }
  .moredr-sep { height:1px; background:var(--line); margin:6px 4px; }
  .moredr-foot { padding:9px 16px; border-top:1px solid var(--line); font-size:var(--fr-fs-note);
    font-weight:800; color:var(--soft); background:var(--sidebar-bg); flex:none; }
  /* Same recipe as .viewchip (2026-08-30: "style the more control button
     across all pages to look like the views control with the dropdown
     arrow to the right spaced correctly"), height, padding, border,
     weight and hover all copied across; margin-left:auto stays, the chip
     never needs it but More does on lost bids and under offer, where it
     sits bare in .section-head rather than inside a .section-controls
     wrapper. */
  .morebtn { height:var(--fr-h-control); padding:0 12px; border-radius:var(--fr-radius-control);
    border:1px solid var(--line); background:#fff; color:var(--ink); font:inherit;
    font-size:var(--fr-fs-body); cursor:pointer; display:flex; align-items:center; gap:8px;
    font-weight:700; margin-left:auto; }
  .morebtn:hover { border-color:var(--icon); }
  .morebtn.has { border-color:var(--icon); color:var(--icon); }
  /* ---- shared by every page the frame's own helpers render into. These
     lived in the cockpit's private <style> until 2026-08-30, which left the
     purchases page rendering four of them as raw browser controls. ---- */
  .morepanel .filtersel { width:100%; font-size:var(--fr-fs-input); padding:8px 9px;
    border:1px solid var(--line); border-radius:var(--fr-radius-input); background:#fff; }
  .stopbtn { font-size:var(--fr-fs-note); font-weight:700; color:#fff; background:var(--no); border:none;
    border-radius:var(--fr-radius-input); padding:4px 12px; cursor:pointer; }
  /* ---- the views control: one chip holding every view you made, and its
     own menu. Built to the mockup Steven signed off on 2026-08-30, so the
     sizes here are that design's, expressed in the frame's own tokens
     wherever one already matched (34px control, 14px panel, 13px body,
     11px uppercase label all landed on the nose). The first build reused
     the old sidebar list's classes and came out cramped and link-ish,
     which is not what a menu should feel like. ---- */
  .viewwrap { position:relative; }
  .viewchip { display:flex; align-items:center; gap:8px; height:var(--fr-h-control);
    padding:0 12px; border:1px solid var(--line); border-radius:var(--fr-radius-control);
    background:#fff; color:var(--ink); font:inherit; font-size:var(--fr-fs-body);
    font-weight:700; cursor:pointer; }
  .viewchip:hover { border-color:var(--icon); }
  .viewchip.custom { border-color:var(--icon); color:var(--icon);
    background:color-mix(in srgb, var(--icon) 7%, #fff); }
  .viewchip .n { background:var(--sidebar-bg); color:var(--soft); border-radius:999px;
    padding:1px 8px; font-size:var(--fr-fs-small); font-weight:700;
    font-variant-numeric:tabular-nums; }
  .viewchip.custom .n { background:color-mix(in srgb, var(--icon) 14%, #fff); color:var(--icon); }
  .viewchip .car, .morebtn .car { display:flex; color:var(--soft); }
  .viewmenu { position:absolute; top:calc(100% + 8px); left:0; z-index:40; width:330px;
    background:#fff; border:1px solid var(--line); border-radius:var(--fr-radius-panel);
    padding:8px; box-shadow:var(--fr-shadow-panel); }
  .viewmenu[hidden] { display:none; }
  .viewsmenu-head { font-size:var(--fr-fs-small); font-weight:800; letter-spacing:.05em;
    text-transform:uppercase; color:var(--soft); padding:7px 9px 5px; margin:0; }
  /* The whole row is the control, not a text link inside one: it gets the
     padding, the radius and the hover, so it reads and taps like a menu
     item. The two mini buttons sit on top of it and stop the click. */
  .viewrow { display:flex; align-items:center; gap:8px; width:100%; padding:8px 9px;
    border-radius:var(--fr-radius-btn); cursor:pointer; color:var(--ink); }
  .viewrow:hover { background:var(--sidebar-bg); }
  .viewrow.on { background:color-mix(in srgb, var(--icon) 10%, transparent); color:var(--icon); }
  .viewname-btn { flex:1 1 0; min-width:0; text-align:left; background:none; border:none;
    font:inherit; font-size:var(--fr-fs-body); font-weight:600; color:inherit; cursor:pointer;
    padding:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .viewn { flex:none; font-size:var(--fr-fs-note); font-weight:700; color:var(--soft);
    font-variant-numeric:tabular-nums; }
  .viewrow.on .viewn { color:var(--icon); }
  /* A pin, not a star: the star means a car you want, and one glyph cannot
     carry that and "favourite view" on the same bar (2026-08-30). */
  /* Quiet, but genuinely visible. The mockup drew these at --line, which is
     a BORDER token near 93% lightness: measured on a real row it disappeared
     entirely, and a control nobody can see is not a quiet control. Halfway
     to --soft reads as subordinate to the name without vanishing. */
  .viewpin, .viewdel { width:24px; height:24px; flex:none; display:grid; place-items:center;
    border:none; background:none; border-radius:7px; cursor:pointer; padding:0;
    color:color-mix(in srgb, var(--soft) 55%, transparent); }
  .viewpin:hover, .viewdel:hover { background:var(--bg); color:var(--soft); }
  .viewpin.on { color:var(--icon); }
  .viewpin.on svg { fill:currentColor; }
  .viewdel:hover { color:var(--no); }
  .viewdiv { height:1px; background:var(--line); margin:7px 5px; }
  .viewslist { display:flex; flex-direction:column; }
  .viewsempty { margin:0; padding:9px; font-size:var(--fr-fs-note); color:var(--soft); }
  .views-save-row { display:flex; gap:6px; padding:5px; }
  .views-save-row input { width:0; flex:1 1 0; height:var(--fr-h-control);
    font:inherit; font-size:var(--fr-fs-body); padding:0 9px; color:var(--ink);
    border:1px solid var(--line); border-radius:var(--fr-radius-input); background:#fff; }
  .viewsavebtn { flex:none; height:var(--fr-h-control); padding:0 14px; border:none;
    border-radius:var(--fr-radius-btn); background:var(--icon); color:#fff;
    font:inherit; font-size:var(--fr-fs-body); font-weight:700; cursor:pointer; }
  .viewsavebtn:hover { filter:brightness(.95); }
  .views-save-row.hot { background:color-mix(in srgb, var(--icon) 9%, #fff);
    border-radius:10px; }
  .filterchips { display:flex; gap:7px; flex-wrap:wrap; padding:0 0 14px; align-items:center; }
  .filterchips[hidden] { display:none; }
  .filterchips .lbl { font-size:var(--fr-fs-small); font-weight:800; letter-spacing:.05em;
    text-transform:uppercase; color:var(--soft); }
  .fchip { display:inline-flex; align-items:center; gap:6px; background:#fff;
    border:1px solid var(--line); border-radius:999px; padding:3px 9px; font-size:var(--fr-fs-note);
    font-weight:700; cursor:pointer; }
  .fchip:hover { border-color:var(--icon); color:var(--icon); }
  .fclearall { font-size:var(--fr-fs-note); font-weight:700; color:var(--soft); background:none; border:none;
    cursor:pointer; text-decoration:underline; margin-left:auto; }
  .sortrow { display:flex; width:100%; align-items:center; gap:8px; border:none; background:none;
    font:inherit; font-size:var(--fr-fs-body); font-weight:700; color:var(--ink); padding:9px 10px;
    border-radius:var(--fr-radius-input); cursor:pointer; text-align:left; }
  .sortrow:hover { background:var(--sidebar-bg); }
  .sortrow.on { background:color-mix(in srgb, var(--icon) 10%, transparent); color:var(--icon); }
  .sortrow .rt { margin-left:auto; font-size:var(--fr-fs-note); }
  .moredr-note { font-size:var(--fr-fs-note); color:var(--soft); line-height:1.5; margin:12px 0 0; }
  .moredr-panelhead { display:flex; align-items:center; justify-content:space-between;
    gap:10px; padding:0 0 10px; margin:0 0 12px; border-bottom:1px solid var(--line); }
  .moredr-panelhead span { font-size:var(--fr-fs-small); font-weight:800; letter-spacing:.05em;
    text-transform:uppercase; color:var(--soft); }
  .moredr-reset { font:inherit; font-size:var(--fr-fs-note); font-weight:700; color:var(--soft);
    background:none; border:1px solid var(--line); border-radius:var(--fr-radius-btn);
    padding:4px 10px; cursor:pointer; }
  .moredr-reset:hover { border-color:var(--icon); color:var(--icon); }
  .moredr-field { display:flex; flex-direction:column; gap:5px;
    margin:0 0 var(--fr-gap-field); }
  .moredr-field > label { font-size:var(--fr-fs-small); font-weight:800; letter-spacing:.05em;
    text-transform:uppercase; color:var(--soft); }
  .moredr-pair { display:flex; align-items:center; gap:6px; }
  .moredr-pair input { width:0; flex:1 1 0; font:inherit; font-size:var(--fr-fs-input);
    padding:8px 9px; border:1px solid var(--line); border-radius:var(--fr-radius-input);
    background:#fff; color:var(--ink); }
  .moredr-pair input::placeholder { color:var(--soft); }
  /* Two handle range slider, built on two overlapping NATIVE range inputs so
     dragging, touch and keyboard come from the browser. The inputs themselves
     are pointer transparent and only their thumbs take pointer events, which
     is what lets BOTH handles stay grabbable while they sit on top of one
     another. */
  /* padding-top reserves the band the track and thumbs sit in, and the
     readout then flows normally underneath. A fixed height on .rng put the
     readout back on top of the track instead. */
  .rng { position:relative; padding-top:26px; }
  .rngbar { position:absolute; left:0; right:0; top:11px; height:4px;
    background:var(--sidebar-bg); border-radius:999px; }
  .rngfill { position:absolute; top:0; bottom:0; background:var(--icon);
    border-radius:999px; }
  /* An untouched slider spans the whole track, which is literally true but
     makes six idle filters look set. Neutral until it actually narrows
     something, so the ones doing work stand out. */
  .rng.idle .rngfill { background:var(--line); }
  .rng.idle .rngh::-webkit-slider-thumb { border-color:var(--soft); }
  .rng.idle .rngh::-moz-range-thumb { border-color:var(--soft); }
  .rngh { position:absolute; left:0; top:1px; width:100%; height:24px; margin:0;
    background:none; pointer-events:none; -webkit-appearance:none; appearance:none; }
  .rngh:focus { outline:none; }
  /* Whichever handle the pointer is nearest comes to the top. Without this,
     two thumbs parked on the same spot leave only the upper one grabbable, so
     pushing both to the top end makes the low handle permanently unreachable
     and Reset the only way back. Verified live before fixing. */
  .rng .rnglo { z-index:1; }
  .rng .rnghi { z-index:2; }
  .rng.lo-on-top .rnglo { z-index:3; }
  .rngh::-webkit-slider-thumb { pointer-events:auto; -webkit-appearance:none;
    width:18px; height:18px; border-radius:999px; background:#fff;
    border:2px solid var(--icon); cursor:grab; box-shadow:0 1px 3px rgba(20,28,26,.28); }
  .rngh::-moz-range-thumb { pointer-events:auto; width:18px; height:18px;
    border-radius:999px; background:#fff; border:2px solid var(--icon); cursor:grab;
    box-shadow:0 1px 3px rgba(20,28,26,.28); }
  .rngh:focus-visible::-webkit-slider-thumb { box-shadow:0 0 0 3px color-mix(in srgb, var(--icon) 35%, transparent); }
  .rngh:focus-visible::-moz-range-thumb { box-shadow:0 0 0 3px color-mix(in srgb, var(--icon) 35%, transparent); }
  .rngh::-webkit-slider-runnable-track { background:none; border:none; }
  .rngh::-moz-range-track { background:none; border:none; }
  .rngout { margin:0; font-size:var(--fr-fs-note); color:var(--soft);
    font-variant-numeric:tabular-nums; }
  .moredr-field > label.moredr-check { display:flex; align-items:center; justify-content:flex-start;
    gap:9px; font-size:var(--fr-fs-input); font-weight:600; color:var(--ink);
    text-transform:none; letter-spacing:0; cursor:pointer; }
  .moredr-field > label.moredr-check input { width:17px; height:17px; flex:none; margin:0;
    accent-color:var(--icon); }

  /* ---- the sync light ----
     Steven 2026-08-31: "no matter where you are or what page you are on you
     can see a sync is happening and an animation showing its still running
     with a traffic light system for easy to understand quick glances".

     Deliberately NOT a new element. An earlier design put a labelled chip
     in the bar, but a chip carries text, so its width changes with its
     state and shoves the search box and every icon along with it (Steven:
     "it is out of line with everything else in the nav bar at certain
     points, I absolutely dont want this at all, it all needs to fit").
     The state lives inside the button that is already there, so its
     footprint is fixed at 34x34 forever and nothing in the bar can move.

     Green is --good and red is --no, defined on all five pages (--g1 and
     --g3, the grade circle tokens the cockpit's progress bar reaches for,
     exist ONLY on the cockpit, so a block written against those painted
     nothing at all on the other four â€” caught before shipping). Amber is
     #d39e00, the app's own established in-progress amber (the Auc Status
     stages, .settingslink.on, the Estimated breakdown note), already
     proven legible at 18px glyph scale â€” the progress bar's own --g3
     amber is a dark burnt orange nearly indistinguishable from the red
     at this size (2026-09-01 sweep). The spin runs at .8s, the navspin
     convention every other spinner in the app already uses.

     The colour is set on the SVG rather than the button on purpose. A pass
     in progress disables its own button, and `.controls .runbtn:disabled`
     sets color on the BUTTON at a specificity no plain state class can
     beat, so a state painted there came out disabled grey (seen live
     2026-08-31, green class on, green nowhere). A declaration on the icon
     itself beats an inherited one whatever the specificity, which settles
     it without an !important or a selector arms race.

     The rules live in SYNC_LIGHT_CSS below, ONE constant shared with
     render_settings' own hand-assembled style block, so the two can
     never drift the way a hand copy silently would. */
{SYNC_LIGHT_CSS})r{   r   r&   r   Ú
_frame_cssr}   (  s   € ô ð bó bð br&   c                  ó   • S[          S3$ )a  The head bar's own More button, badge painted by paintFrame(). Styled
to match the views chip (2026-08-30), so its own chevron reuses
_VIEW_CHEV_SVG rather than a second copy, and its CSS lives on the
shared ".viewchip .car, .morebtn .car" selector, not a rule of its own.z›<button type="button" id="morebtn" class="morebtn" onclick="openMore()" aria-haspopup="dialog">More<span class="morebadge" hidden></span><span class="car">ú</span></button>©Ú_VIEW_CHEV_SVGr   r&   r   Ú	_more_btnr‚   S  s   € ð
!ä!/Ð 0Ð0@ðBð Cr&   c                  ó   • g)zÔThe removable filter chips row under the head bar, painted live by
paintFrame() from the page's own FRAME.filters list (one list drives the
chips, the More badge and the footer count, so they can never disagree).z7<div id="filterchips" class="filterchips" hidden></div>r   r   r&   r   Ú_filter_chips_rowr„   ]  s   € ð Er&   Úprefixr$   c           	      ó>   • [        U[        SU  S3SU  S35      5      $ )z“A labelled from/to date pair for a drawer Filters panel. Ids are
{prefix}from and {prefix}to; changing either re runs the page's own
FRAME.apply().z<input type="date" id="zfrom" onchange="frameApply()">zto" onchange="frameApply()">)Ú_fieldÚ_pair)r…   r$   s     r   Ú_filter_date_ranger‰   d  s7   € ô �%œØ
! & Ð)GÐHØ
! & Ð)EÐFóHó Ið Ir&   c                 ó‚   • [        USU  SU=(       d    U  SU SU SU  SU  S[        U5       S[        U5       S	35      $ )
aŒ  The same two handle slider as _filter_range, for the pages whose rows
are built in JS rather than server rendered cards (purchases, lost bids).
Ids stay {prefix}min and {prefix}max, so every existing reader carries on
unchanged. ds names the row field the bounds are drawn from, which each
page resolves in its own rangeValues() (Steven 2026-08-30: "all changes
need made across the entire app").ú<div class="rng" data-rng="ú" data-ds="ú" data-step="ú" data-fmt="ú"><input id="úmin" type="hidden"><input id="ú˜max" type="hidden"><div class="rngbar"><div class="rngfill"></div></div><input type="range" class="rngh rnglo" oninput="onRangeSlide(this)" aria-label="ú\, at least"><input type="range" class="rngh rnghi" oninput="onRangeSlide(this)" aria-label="ú%, up to"><p class="rngout"></p></div>©r‡   rl   )r…   r$   ÚdsÚstepÚfmts        r   Ú_filter_num_ranger˜   m  so   € ô �%Ø
% f X¨[¸¿¸v¸ð GØ�V˜<¨ uð -Ø�XÐ;¸F¸8ð Dô ˜E“{�mð $ä˜E“{�mð $'ð	(ó	)ð 	)r&   c                 ó€   • SnSnU  H$  u  pEnUSU SU SU SU S3	-  nUSU SU S	3-  nM&     US
-  nSU SU S	U S3$ )z»The drawer itself. sections is a list of (key, label, panel_html);
every panel is server rendered and always present, JS only toggles
hidden. title is the collapsed (titles only) heading.r   z0<button type="button" class="morerow" data-sec="z" onclick="moreSec(&#39;z&#39;)"><span>z+</span><span class="morebadge" data-badge="z:" hidden></span><span class="car">&#10095;</span></button>z#<div class="morepanel" data-panel="z	" hidden>ú</div>z‰<div class="moredr-sep"></div><button type="button" class="morerow plain" onclick="clearAllFilters()">&#10005; Clear all filters</button>aD  <div id="morescrim" class="morescrim" hidden onclick="closeMore()"></div><div id="moredrawer" class="moredrawer" hidden><div class="moredr-head"><div class="moredr-top"><button type="button" id="moredrback" class="moredr-back" onclick="moreSec(null)" title="Back to all sections" hidden>&#10094;</button><b id="moredrtitle">z¢</b><button type="button" class="moredr-close" onclick="closeMore()" aria-label="Close">&#10005;</button></div></div><div class="moresplit"><div class="morerail">z<</div><div class="moredr-foot" id="moredrcount"></div></div>r   )ÚsectionsÚtitleÚrailÚpanelsrS   r$   Úpanels          r   Ú_more_drawerr    €  s°   € ð €DØ€FÛ%Ñˆ�EØÐCÀCÀ5ð I*Ø*-¨¨n¸U¸Gð D8Ø8;°uð =>ð?ñ 	@ˆð 	Ð7¸°u¸IÀeÀWÈFÐSÑSŠñ &ð 	ð 3ñ 4€Dð	ð
  %˜gð &8ð 9=°v¸VÀFÀ8ð Lð
	ðr&   Tc                 óX   • SnU (       a  US-  nU[         [        5       -   [        -   -  nU$ )a#  The shared frame behaviour. Inject EXACTLY ONCE per page: the top
level lets mean a second copy is a parse time SyntaxError that kills the
whole page script, not a quiet overwrite. Each page defines its own
FRAME config ({title, filters: [{ids, label(), clear()}], apply()})
BEFORE its first load call (the file's own documented TDZ trap); a
filter's label() returns the chip text (already escaped where it
carries data) or null when inactive, clear() empties its inputs, and an
optional sec names the drawer section its rail badge belongs to
(default "filters"), and an optional q marks it as one that shows as a
chip but never counts toward a badge (the search box, already visible
in the nav bar). An optional FRAME.onSection(key) is called whenever a
drawer section opens, for panels a page paints in JS. paintFrame(visibleCount) is
called at the end of every render so the chips row, the More badges and
the footer count always agree with the table. The drawer remembers the
last open section per page load (a deliberate addition over the approved
mockup, flagged and approved in the plan, so repeat filter visits are
one click). install_escape=False lets a page with more layers (the
purchases record drawer, the gallery) integrate frameEscape() into its
own Escape handler instead of installing a second one.uï+  
    // ---- shared control frame (v3.4) ----
    let moreOpen = false, moreSec_ = null, moreLastSec = null;
    function openMore(){
      const w = bbEl('morescrim'), d = bbEl('moredrawer');
      if(!w || !d) return;
      moreOpen = true;
      w.hidden = false; d.hidden = false;
      moreSecPaint(moreLastSec);
    }
    function closeMore(){
      moreOpen = false;
      const w = bbEl('morescrim'), d = bbEl('moredrawer');
      if(w) w.hidden = true;
      if(d) d.hidden = true;
    }
    function moreSec(k){ moreSecPaint(moreSec_ === k ? null : k); }
    function moreSecPaint(k){
      moreSec_ = k;
      if(k) moreLastSec = k;
      const d = bbEl('moredrawer');
      if(!d) return;
      d.classList.toggle('expanded', !!k);
      const back = bbEl('moredrback');
      if(back) back.hidden = !k;
      let title = FRAME.title || 'Filters';
      d.querySelectorAll('.morerow[data-sec]').forEach(b => {
        const on = b.dataset.sec === k;
        b.classList.toggle('on', on);
        const car = b.querySelector('.car');
        if(car) car.textContent = on ? 'â�®' : 'â�¯';
        if(on) title = b.querySelector('span').textContent;
      });
      d.querySelectorAll('.morepanel').forEach(p => { p.hidden = p.dataset.panel !== k; });
      bbSetText('moredrtitle', title);
      if(k && typeof FRAME.onSection === 'function') FRAME.onSection(k);
    }
    function frameApply(){ FRAME.apply(); }
    function activeFrameFilters(){
      return (FRAME.filters || []).map(f => ({f: f, label: f.label()})).filter(x => x.label);
    }
    function paintFrame(count){
      const act = activeFrameFilters();
      // A q flagged filter (the search box) never counts toward a badge: it
      // is already visible in the nav bar.
      const counted = act.filter(x => !x.f.q);
      const btn = bbEl('morebtn');
      if(btn){
        btn.classList.toggle('has', counted.length > 0);
        const bg = btn.querySelector('.morebadge');
        if(bg){ bg.hidden = !counted.length; bg.textContent = counted.length || ''; }
      }
      document.querySelectorAll('#moredrawer .morebadge[data-badge]').forEach(rb => {
        const n = counted.filter(x => (x.f.sec || 'filters') === rb.dataset.badge).length;
        rb.hidden = !n;
        rb.textContent = n || '';
      });
      // The chip row also drops the q filter now (found in review,
      // 2026-08-30): searching with nothing else active used to open a real
      // ~37px row reading only the search term back in quotes, shifting the
      // cards down, a strictly worse reminder than the sticky search box
      // sitting right above it, which already shows what you typed. Only a
      // GENUINE narrowing filter earns a chip; the search box speaks for
      // itself. frameChipClear below reads this SAME counted array, not the
      // fuller act, so the two can never disagree about which index is
      // which regardless of where q sits in a page's own FRAME.filters,
      // rather than relying on it happening to sit last.
      const row = bbEl('filterchips');
      if(row){
        row.hidden = !counted.length;
        if(!counted.length){
          row.innerHTML = '';
        } else {
          row.innerHTML = '<span class="lbl">Filtering</span>' +
            counted.map((x, i) => '<span class="fchip" onclick="frameChipClear(' + i + ')">' +
              x.label + ' âœ•</span>').join('') +
            '<button type="button" class="fclearall" onclick="clearAllFilters()">Clear all filters</button>';
        }
      }
      if(count != null){
        bbSetText('moredrcount', count + (count === 1 ? ' car matches right now' : ' cars match right now'));
      }
    }
    // ---- range sliders ----------------------------------------------------
    // Bounds are worked out ONCE from every card on the page, never from the
    // filtered set: bounds that move while you filter make the handles jump
    // under your hand. Rounded outward to a whole step so both ends are
    // actually reachable.
    let RNG_BOUNDS = null;
    // Pages that fetch their rows after render call this once the data has
    // landed, so the bounds are drawn from real numbers rather than an empty
    // table.
    function resetRangeBounds(){ RNG_BOUNDS = null; paintRanges(); }
    function rangeBounds(){
      if(RNG_BOUNDS) return RNG_BOUNDS;
      RNG_BOUNDS = {};
      document.querySelectorAll('.rng[data-ds]').forEach(w => {
        const ds = w.dataset.ds, step = +w.dataset.step || 1;
        // Each page says where its own numbers live: the cockpit reads them
        // off its cards, purchases and lost bids off their own row arrays.
        const nums = (typeof rangeValues === 'function' ? rangeValues(ds) : [])
                       .map(Number).filter(n => !isNaN(n));
        let lo = nums.length ? Math.min(...nums) : 0;
        let hi = nums.length ? Math.max(...nums) : 0;
        lo = Math.floor(lo / step) * step;
        hi = Math.ceil(hi / step) * step;
        if(hi <= lo) hi = lo + step;           // never a zero width track
        if(w.dataset.fmt === 'stock') { lo = 1; hi = hi + 1; }  // "fewer than N"
        RNG_BOUNDS[w.dataset.rng] = {lo: lo, hi: hi, step: step};
      });
      return RNG_BOUNDS;
    }
    function rngFmt(fmt, v){
      if(fmt === 'money') return 'Â£' + Number(v).toLocaleString('en-GB');
      if(fmt === 'miles') return Number(v).toLocaleString('en-GB') + ' mi';
      return Number(v).toLocaleString('en-GB');
    }
    // Paint every slider FROM the hidden inputs, which stay the source of
    // truth. Anything that writes those values (applyCriteria, resetFilters,
    // a chip's own clear) just calls this afterwards and the handles follow.
    function paintRanges(){
      const B = rangeBounds();
      document.querySelectorAll('.rng').forEach(w => {
        const key = w.dataset.rng, b = B[key];
        if(!b) return;
        const one = w.classList.contains('one');
        const loEl = w.querySelector('.rnglo'), hiEl = w.querySelector('.rnghi');
        const minIn = one ? null : bbEl(key + 'min');
        const maxIn = one ? bbEl(key) : bbEl(key + 'max');
        [loEl, hiEl].forEach(el => { if(el){ el.min = b.lo; el.max = b.hi; el.step = b.step; } });
        const lo = (minIn && minIn.value !== '') ? +minIn.value : b.lo;
        const hi = (maxIn && maxIn.value !== '') ? +maxIn.value : b.hi;
        if(loEl) loEl.value = lo;
        if(hiEl) hiEl.value = hi;
        const span = (b.hi - b.lo) || 1;
        const fill = w.querySelector('.rngfill');
        if(fill){
          fill.style.left = ((lo - b.lo) / span * 100) + '%';
          fill.style.right = ((b.hi - hi) / span * 100) + '%';
        }
        const active = (minIn && minIn.value !== '') || (maxIn && maxIn.value !== '');
        w.classList.toggle('idle', !active);
        const out = w.querySelector('.rngout');
        if(out){
          const f = w.dataset.fmt;
          if(f === 'stock'){
            out.textContent = (maxIn && maxIn.value !== '')
              ? (hi === 1 ? 'None in stock' : 'Fewer than ' + hi + ' in stock')
              : 'Any number in stock';
          } else {
            const atLo = !minIn || minIn.value === '', atHi = !maxIn || maxIn.value === '';
            out.textContent = (atLo && atHi) ? 'Any'
              : atLo ? 'Up to ' + rngFmt(f, hi)
              : atHi ? rngFmt(f, lo) + ' and up'
              : rngFmt(f, lo) + ' to ' + rngFmt(f, hi);
          }
        }
      });
    }
    // A handle moved. The ends mean "no limit", so parking one there writes an
    // EMPTY value rather than the number: an untouched slider has to be no
    // filter at all, or the default state stops matching DEFAULT and the chip
    // would read Custom on a fresh page with nothing set.
    function onRangeSlide(el){
      const w = el.closest('.rng'), B = rangeBounds(), b = B[w.dataset.rng];
      if(!b) return;
      const one = w.classList.contains('one');
      const loEl = w.querySelector('.rnglo'), hiEl = w.querySelector('.rnghi');
      if(!one){
        // Handles must never cross: whichever one is being dragged gives way.
        if(+loEl.value > +hiEl.value){
          if(el === loEl) loEl.value = hiEl.value; else hiEl.value = loEl.value;
        }
        bbSetVal(w.dataset.rng + 'min', +loEl.value <= b.lo ? '' : loEl.value);
        bbSetVal(w.dataset.rng + 'max', +hiEl.value >= b.hi ? '' : hiEl.value);
      } else {
        bbSetVal(w.dataset.rng, +hiEl.value >= b.hi ? '' : hiEl.value);
      }
      paintRanges();
      frameApply();
    }

    // Decided on pointerdown as well as pointermove: on a touch screen there
    // is no hover first, the press IS the first event, so the choice has to be
    // made in the same tick the drag starts.
    function rngNearest(ev){
      const w = ev.target && ev.target.closest && ev.target.closest('.rng');
      if(!w || w.classList.contains('one')) return;
      const B = rangeBounds(), b = B[w.dataset.rng];
      if(!b) return;
      const lo = w.querySelector('.rnglo'), hi = w.querySelector('.rnghi');
      if(!lo || !hi) return;
      const r = w.getBoundingClientRect();
      if(!r.width) return;
      const frac = Math.min(1, Math.max(0, (ev.clientX - r.left) / r.width));
      const v = b.lo + frac * (b.hi - b.lo);
      const dLo = Math.abs(v - (+lo.value)), dHi = Math.abs(v - (+hi.value));
      // On a tie the two thumbs are stacked, and blindly favouring one leaves
      // the other stranded: both parked at the ceiling made the low handle
      // unreachable, both at the floor did the same to the high one. So a tie
      // goes to whichever still has somewhere to go, decided by which half of
      // the track they are sitting in.
      const preferLo = (dLo !== dHi) ? (dLo < dHi)
                                     : ((+lo.value) > (b.lo + b.hi) / 2);
      w.classList.toggle('lo-on-top', preferLo);
    }
    document.addEventListener('pointerdown', rngNearest, true);
    document.addEventListener('pointermove', rngNearest, true);

    // The sliders' handles are painted from the hidden inputs, so anything
    // that clears those has to repaint or the handle sits at a stale spot
    // while the list filters correctly. Guarded: pages without sliders
    // (purchases, lost bids) do not define paintRanges.
    function frameRepaint(){ if(typeof paintRanges === 'function') paintRanges(); }
    function frameChipClear(i){
      // Must index the SAME array paintFrame just rendered chips from
      // (counted, q excluded), not the fuller act, or a click could clear
      // the wrong filter the moment q ever stopped being last.
      const counted = activeFrameFilters().filter(x => !x.f.q);
      if(counted[i]) counted[i].f.clear();
      frameRepaint();
      FRAME.apply();
    }
    function clearAllFilters(){
      (FRAME.filters || []).forEach(f => f.clear());
      frameRepaint();
      closeMore();
      FRAME.apply();
    }
    function frameEscape(){
      const vm = bbEl('viewmenu');
      if(vm && !vm.hidden && typeof closeViewMenu === 'function'){ closeViewMenu(); return true; }
      if(!moreOpen) return false;
      if(moreSec_){ moreSecPaint(null); return true; }
      closeMore();
      return true;
    }
zd    document.addEventListener('keydown', e => {
      if(e.key === 'Escape') frameEscape();
    });
)ÚSYNC_LIGHT_JSÚ_run_kit_jsÚRUN_MODAL_JS)Úinstall_escapeÚjss     r   Ú	_frame_jsr§   �  s<   € ð(m
€Bö\ Ø
ð ñ 	ˆð Œ-œ+›-Ñ
'¬,Ñ
6Ñ6€BØ€Ir&   aJ    .runbar { border:1px solid var(--line); border-radius:12px; padding:10px 14px; margin:0 0 14px;
    background:#fff; }
  .runbar[hidden] { display:none; }
  .controls .runbar { border:none; padding:0; margin:2px 0 0; background:none; flex:0 0 100%; width:100%; }
  .runbar-row { display:flex; justify-content:space-between; align-items:center; gap:10px;
    font-size:12.5px; margin-bottom:6px; }
  .runbar-right { margin-left:auto; display:flex; align-items:center; gap:10px; }
  .runmsg { font-weight:700; color:var(--ink); }
  .runpct { color:var(--soft); white-space:nowrap; font-variant-numeric:tabular-nums; }
  .runtrack { height:8px; background:var(--line); border-radius:6px; overflow:hidden; }
  .runfill { height:100%; width:0%; background:var(--good); border-radius:6px; transition:width .4s ease; }
  .runfill.indet { width:35%; animation:runbar-indet 1.1s ease-in-out infinite; }
  .runfill.stale { background:#d39e00; }
  .runfill.error { background:var(--no); width:100% !important; animation:none; }
  @keyframes runbar-indet { 0% { margin-left:-35%; } 100% { margin-left:100%; } }
  @media (prefers-reduced-motion: reduce) { .runfill.indet { animation:none; width:100%; opacity:.5; } }
  /* Toasts sit at the run bar's own level, just under the nav bar, on the
     eye line, not at the foot of the screen (Steven 2026-09-02: "i want
     them to appear at the same level as the run bar so i can see them").
     toast() measures the nav bar so the top is right on every page. */
  #flash { position:fixed; left:50%; top:74px; transform:translateX(-50%); background:var(--ink); color:#fff;
    padding:11px 16px; border-radius:10px; font-size:14px; box-shadow:0 4px 14px rgba(0,0,0,.25); display:none;
    z-index:80; max-width:min(92vw, 560px); text-align:center; }
  #flash.ok { background:var(--good); }
  #flash.bad { background:var(--no); }
aã:  
    // ---- the run kit (2026-09-02) ----
    // Every run on every page starts through startRun and is watched by
    // watchRun; both paint the one #runbar and report through the one
    // toast. RUNS is the server's registry (bidbrain.runs): label, the
    // question to ask first, and whether it is one car's own job.
    const RUNS = __RUNS_JSON__;
    const RUN_ACTIVE_PHASES = __ACTIVE_PHASES__;
    const RUN_KEYS = Object.keys(RUNS);
    function toast(msg, ok){
      const f = document.getElementById('flash');
      if(!f) return;
      f.textContent = msg;
      f.className = ok === true ? 'ok' : (ok === false ? 'bad' : '');
      // Just under whatever is pinned to the top of the page (the nav bar
      // and, where there is one, the auction house row), measured as the
      // lowest edge of the real sticky rows rather than by class name, so
      // it never covers the controls and sits where the eye already is.
      let edge = 60;
      document.querySelectorAll('.controls, .platform-toggle-row').forEach(el => {
        const pos = getComputedStyle(el).position;
        const r = el.getBoundingClientRect();
        if((pos === 'sticky' || pos === 'fixed') && r.width > 0 && r.top < 200) edge = Math.max(edge, r.bottom);
      });
      f.style.top = Math.round(edge + 12) + 'px';
      f.style.display = 'block';
      clearTimeout(f._t);
      f._t = setTimeout(() => { f.style.display = 'none'; }, 5000);
    }
    // The old name, kept so every existing call still lands on the one toast.
    function flash(msg, ok){ toast(msg, ok); }
    function anyRunning(s){ return RUN_KEYS.some(k => s[k + '_running']) || !!s.login_running; }
    // Lock every control tied to a run while it is going. data-run-off marks
    // a button switched off in Settings, which stays disabled afterwards.
    function runLock(key, on){
      document.querySelectorAll('[data-run="' + key + '"]').forEach(b => {
        if(on){ b.dataset.runLocked = '1'; b.disabled = true; }
        else if(b.dataset.runLocked){ delete b.dataset.runLocked; b.disabled = !!b.dataset.runOff; }
      });
    }
    function showRunbar(msg){
      const bar = document.getElementById('runbar');
      if(!bar) return;
      bar.hidden = false;
      const m = document.getElementById('runmsg'), pc = document.getElementById('runpct'), f = document.getElementById('runfill');
      if(m) m.textContent = msg || 'Starting...';
      if(pc) pc.textContent = '';
      if(f){ f.className = 'runfill indet'; f.style.width = ''; }
      if(typeof syncControlsHeight === 'function') syncControlsHeight();
    }
    // Paint the bar from one /api/run-status reply. Returns whether a run
    // is genuinely live. A launchd run this server never spawned has no
    // flag, only its phase, so a phase counts while it is fresh (under ten
    // minutes with no update, past that it is dead, not slow); the
    // "starting" stamp the server primes counts only while a flag is
    // alive, since a pass that exits before its first own write orphans
    // it. A finished error stays up for ten minutes; the light and the
    // bell own it after that. A finished success stays up for 8s too
    // (2026-09-21), a real result deserves more than a toast that is
    // already gone by the time anyone looks over.
    function renderProgress(s){
      const bar = document.getElementById('runbar');
      if(!bar) return false;
      const p = s.progress;
      const pAge = (p && p.ts) ? Math.max(0, (s.now || 0) - p.ts) : 0;
      const anyProc = anyRunning(s);
      const phaseAlive = !!p && pAge < 600 &&
        (RUN_ACTIVE_PHASES.includes(p.phase) ||
         (p.phase === 'starting' && anyProc) ||
         (p.phase === 'done' && p.partial));
      const active = anyProc || phaseAlive;
      const m = document.getElementById('runmsg'), pc = document.getElementById('runpct'), f = document.getElementById('runfill');
      if(p && p.phase === 'error' && pAge < 600 && !anyProc){
        bar.hidden = false;
        if(m) m.textContent = p.message || 'Run failed';
        if(pc) pc.textContent = '';
        if(f){ f.className = 'runfill error'; f.style.width = ''; }
        if(typeof syncControlsHeight === 'function') syncControlsHeight();
        return false;
      }
      // A finished run used to vanish the instant it stopped being active,
      // leaving only the one toast (itself gone after 5s) to say what
      // happened (Mark 2026-09-21: "the status bar disappears, this
      // causes me to not know whats happening", caught live, reproduced
      // on Didn't win full history: the bar hid mid page 18 the same tick
      // the job finished, no persistent trace of the real result, "425
      // read, 1 new", anywhere on the page). An error already stays up
      // for ten minutes for exactly this reason; a plain success gets the
      // same treatment now, just briefly, long enough to be seen even if
      // the toast itself is missed.
      if(p && p.phase === 'done' && !p.partial && pAge < 8 && !anyProc){
        bar.hidden = false;
        if(m) m.textContent = p.message || 'Finished.';
        if(pc) pc.textContent = '';
        if(f){ f.className = 'runfill'; f.style.width = '100%'; }
        if(typeof syncControlsHeight === 'function') syncControlsHeight();
        return false;
      }
      if(!p || !active){ bar.hidden = true; if(typeof syncControlsHeight === 'function') syncControlsHeight(); return active; }
      bar.hidden = false;
      if(typeof syncControlsHeight === 'function') syncControlsHeight();
      if(!m || !pc || !f) return active;
      m.textContent = p.message || (p.phase + '...');
      f.className = 'runfill';
      if(p.total > 0){
        f.style.width = Math.round(100 * p.done / p.total) + '%';
        pc.textContent = p.done + ' / ' + p.total;
      } else {
        f.style.width = ''; f.classList.add('indet'); pc.textContent = '';
      }
      // Stuck check against the server's own clock, so a laptop that slept
      // mid run cannot make a fine run look stuck, or the reverse.
      if(pAge > 90){
        f.classList.add('stale');
        pc.textContent = (pc.textContent ? pc.textContent + '  ' : '') + 'no update for ' + Math.round(pAge) + 's';
      }
      return active;
    }
    const _runWatch = {};
    // Wait for one run to finish, painting the bar every tick, then say
    // what happened in the one toast: green for done, red for failed with
    // the real reason, plain for skipped or stopped. Never "Done." next to
    // a red light again. key '_any' watches whatever is live (a launchd
    // run seen on page load). A page may define onRunTick(s, key, running)
    // for its own extra painting (the cockpit's Run button and modal).
    function watchRun(key, onFinish){
      if(_runWatch[key]) clearTimeout(_runWatch[key]);
      const tick = async () => {
        let s;
        try { s = await (await fetch('/api/run-status')).json(); }
        catch(e){ _runWatch[key] = setTimeout(tick, 5000); return; }
        const live = renderProgress(s);
        const running = key === '_any' ? live : !!s[key + '_running'];
        if(typeof onRunTick === 'function') onRunTick(s, key, running);
        if(running){ _runWatch[key] = setTimeout(tick, 3000); return; }
        delete _runWatch[key];
        if(key !== '_any') runLock(key, false);
        const p = s.progress || {};
        if(key !== 'login'){
          if(p.phase === 'done' && !p.partial) toast(p.message || 'Finished.', true);
          else if(p.phase === 'error') toast(p.message || 'Failed.', false);
          else if(p.phase === 'skipped') toast(p.message || 'Nothing to do.');
          else if(p.phase === 'stopped') toast(p.message || 'Stopped.');
        }
        slNudge();
        if(typeof loadNotices === 'function') loadNotices();
        // renderProgress just painted the finished bar and, on a plain
        // success, is keeping it up for a few seconds (see there). Nothing
        // polls this key again once it stops running, so without this the
        // bar would stay showing that same result forever, caught live
        // the same day as the fix above: still there over 30s later, no
        // further check ever having run to expire it. One more poll after
        // that window lets renderProgress's own pAge check retire it, or,
        // caught live the same way, pick up a genuinely new background
        // push that has since taken the bar over with nothing of this
        // page's own watching it (an on demand Dealer OS re push starts
        // server side, no startRun call, so nothing here knows to watch
        // it): if it is still live and nothing else is already watching,
        // start the one ambient _any watch, same as a fresh page load
        // would, so that push gets seen through to its own end too.
        if(p.phase === 'done' && !p.partial){
          setTimeout(() => {
            fetch('/api/run-status').then(r => r.json()).then(s2 => {
              const live = renderProgress(s2);
              if(live && !Object.keys(_runWatch).length) watchRun('_any');
            }).catch(() => {});
          }, 8500);
        }
        if(onFinish) onFinish(s);
      };
      _runWatch[key] = setTimeout(tick, 1500);
    }
    // Start a run: ask first when the registry says so (or opts.confirm,
    // '' to skip because the page's own modal was the question), post to
    // the one endpoint, then lock, show the bar, nudge the light and watch.
    // Refusals (switched off, something else running) come back as the
    // server's own sentence in a red toast.
    async function startRun(key, body, opts){
      opts = opts || {};
      const R = RUNS[key] || {};
      const q = opts.confirm !== undefined ? opts.confirm : R.confirm;
      if(q && !confirm(q)) return false;
      const payload = Object.assign({key: key}, body || {});
      if(R.headed){ payload.x = window.screenX; payload.y = window.screenY; }
      let d;
      try {
        const r = await fetch('/api/start-run', {method: 'POST', headers: {'Content-Type': 'application/json'},
                                                  body: JSON.stringify(payload)});
        d = await r.json();
      } catch(e){
        toast('Could not reach the server. Open the page at its web address, not from a file.', false);
        return false;
      }
      if(!d.ok){ toast(d.error || 'Could not start.', false); return false; }
      if(!d.started){ toast(d.reason || 'Something else is already running. Try again once it finishes.', false); return false; }
      runLock(key, true);
      showRunbar(opts.starting || R.starting || 'Starting...');
      if(opts.toast !== false) toast(opts.toast || ((R.label || 'Run') + ' started.'));
      slNudge();
      if(typeof onRunTick === 'function') onRunTick({running: key === 'run', [key + '_running']: true, progress: null}, key, true);
      watchRun(key, opts.onFinish);
      return true;
    }
    // On page load: pick up anything already running (from another tab,
    // another page, or the schedule) so the bar and buttons reflect it.
    function resumeRuns(handlers){
      handlers = handlers || {};
      fetch('/api/run-status').then(r => r.json()).then(s => {
        let watching = false;
        RUN_KEYS.forEach(key => {
          if(s[key + '_running']){ watching = true; runLock(key, true); watchRun(key, handlers[key]); }
        });
        if(s.login_running){ watching = true; watchRun('login', handlers.login); }
        const live = renderProgress(s);
        if(typeof onRunTick === 'function') onRunTick(s, null, live);
        if(!watching && live) watchRun('_any', handlers._any);
      }).catch(err => console.warn('BidBrain: run status could not be read on load.', err));
    }
    async function stopRun(){
      if(!confirm('Stop the current run? Your existing list stays exactly as it is, nothing is lost.')) return;
      try {
        await fetch('/api/stop-run', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
        toast('Stopping. Your list is unchanged.');
        const bar = document.getElementById('runbar'); if(bar) bar.hidden = true;
        RUN_KEYS.forEach(k => runLock(k, false));
        if(typeof onRunTick === 'function') onRunTick(null, null, false);
        slNudge();
      } catch(e){ toast('Could not stop the run.', false); }
    }
    // ---- the notification bell, one copy for every page ----
    function toggleBell(){
      const panel = document.getElementById('bellpanel');
      const btn = document.getElementById('bellbtn');
      if(!panel || !btn) return;
      const open = panel.hidden;
      panel.hidden = !open;
      btn.setAttribute('aria-expanded', open);
    }
    function closeBell(){
      const panel = document.getElementById('bellpanel');
      const btn = document.getElementById('bellbtn');
      if(panel) panel.hidden = true;
      if(btn) btn.setAttribute('aria-expanded', false);
    }
    document.addEventListener('click', (e) => {
      const wrap = document.querySelector('.bellwrap');
      const panel = document.getElementById('bellpanel');
      if(wrap && panel && !panel.hidden && !wrap.contains(e.target)) closeBell();
    });
    function bellNoteHtml(n){
      const cls = n.startsWith('All ') ? 'bellnote' : 'bellnote alert';
      const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
      if(n.includes('Settings > Integrations') || n.includes('Settings > Connections')){
        return '<a class="' + cls + ' bellnote-link" href="settings.html?tab=integrations">' + esc(n) + '</a>';
      }
      if(n.includes("Run Glass's checks") || n.includes('Run DealerKit stock')){
        if(typeof openRunModal === 'function'){
          return '<a class="' + cls + ' bellnote-link" href="#" onclick="closeBell();openRunModal();return false;">' + esc(n) + '</a>';
        }
        return '<a class="' + cls + ' bellnote-link" href="cockpit.html?openrun=1">' + esc(n) + '</a>';
      }
      return '<p class="' + cls + '">' + esc(n) + '</p>';
    }
    function loadNotices(){
      const panel = document.getElementById('bellpanel');
      if(!panel) return;
      fetch('/api/notices').then(r => r.json()).then(d => {
        const notices = (d && d.ok) ? (d.notices || []) : [];
        const alertCount = notices.filter(n => !n.startsWith('All ')).length;
        const badge = document.getElementById('bellbadge');
        if(badge) badge.textContent = alertCount ? String(alertCount) : '';
        panel.innerHTML = notices.length ? notices.map(bellNoteHtml).join('')
          : '<p class="bellempty">Nothing to report.</p>';
        const shortlisted = (d && d.ok) ? (d.shortlisted || 0) : 0;
        const starbadge = document.getElementById('starbadge');
        if(starbadge) starbadge.textContent = shortlisted ? String(shortlisted) : '';
      }).catch((err) => {
        console.warn('BidBrain: notices could not load.', err);
        panel.innerHTML = '<p class="bellempty">Could not load.</p>';
      });
    }
    loadNotices();
a¦    /* Run modal: pick which auction houses to refresh, so a settings tweak for
     one platform does not force a slow repull of the other three. */
  .runmodal-overlay { position:fixed; inset:0; background:oklch(0.279 0.0299 260.05 / .45);
    display:flex; align-items:center; justify-content:center; z-index:60; }
  .runmodal-overlay[hidden] { display:none; }
  .runmodal { width:340px; max-width:90vw; background:#fff; border-radius:14px;
    padding:22px; box-shadow:0 20px 50px oklch(0.279 0.0299 260.05 / .3); }
  .runmodal h3 { margin:0 0 6px; font-size:17px; color:var(--ink); }
  .runmodal p { margin:0 0 16px; font-size:13px; line-height:1.4; color:var(--soft); }
  /* The old v1 modal's option list, footer buttons and coloured extra
     buttons were removed with the status board rebuild; only the "Other
     checks" heading class survives in the new markup (dead rules cleared
     in the 2026-09-01 sweep). */
  .runmodal-extra-label { margin:0 0 10px; font-size:11px; font-weight:700; letter-spacing:.04em;
    text-transform:uppercase; color:var(--soft); }
  /* The Run modal's own status board (2026-08-31). Every rule here is
     scoped to #runmodal-overlay on purpose: .runmodal itself is SHARED
     with #syncmodal-overlay on the purchases and Didn't win pages, so a
     bare .runmodal width would silently widen those too. */
  #runmodal-overlay .runmodal { width:680px; padding:0; max-height:min(88vh, 760px);
    display:flex; flex-direction:column; overflow:hidden; }
  /* On a real MacBook screen this board is taller than the window: header
     plus five platform rows plus seven check cards plus the footer easily
     runs past the visible height, and the modal had no cap and no scroll
     at all (Steven: "the modal on my macbook screen is too big, i cant
     see some of it"). Capped to the viewport and made an internal
     scroller; the head and foot stay put, only the middle scrolls. */
  #runmodal-overlay .rmscroll { overflow-y:auto; flex:1; min-height:0; }
  #runmodal-overlay .rmhead, #runmodal-overlay .rmfoot { flex:none; }
  #runmodal-overlay .rmhead { padding:22px 24px 18px; }
  #runmodal-overlay .rmhead h3 { margin:0 0 3px; }
  #runmodal-overlay .rmhead p { margin:0 0 16px; font-size:12.5px; }
  /* Last run: what happened as a whole, which nothing could say before. */
  .rmlast { border:1px solid var(--line); border-left:3px solid var(--soft);
    border-radius:10px; padding:13px 16px; background:#fbfcfd; }
  .rmlast.bad { border-left-color:var(--no); }
  .rmlast.good { border-left-color:var(--good); }
  .rmlast-top { display:flex; align-items:baseline; justify-content:space-between; gap:16px;
    margin-bottom:6px; }
  .rmlast-lab { font-size:11px; font-weight:800; letter-spacing:.05em; text-transform:uppercase;
    color:var(--soft); }
  .rmlast-when { font-size:11.5px; font-weight:600; color:var(--soft); }
  .rmlast-state { font-size:14px; font-weight:800; display:flex; align-items:center; gap:7px;
    color:var(--ink); }
  .rmlast.bad .rmlast-state { color:var(--no); }
  .rmlast.good .rmlast-state { color:var(--good); }
  .rmlast-note { font-size:12.5px; color:var(--soft); margin:5px 0 0; line-height:1.5; }
  /* The platform table. A grid, not a table, so the failure row can span. */
  .rmtable { padding:0 24px 16px; }
  .rmhdr, .rmrow-main { display:grid; grid-template-columns:1fr 96px 176px 58px;
    align-items:center; gap:0 14px; }
  .rmhdr { font-size:10px; font-weight:800; letter-spacing:.05em; text-transform:uppercase;
    color:var(--soft); padding:0 0 9px; }
  .rmrow { border-top:1px solid var(--line); padding:11px 0; }
  .rmrow[hidden] { display:none; }
  .rmrow.bad { background:oklch(0.444 0.177 26.9 / .045); margin:0 -12px; padding-left:12px;
    padding-right:12px; }
  .rmrow.off { color:var(--soft); }
  .rmpick { display:flex; align-items:center; gap:10px; font-size:13.5px; font-weight:600;
    color:var(--ink); cursor:pointer; }
  .rmrow.off .rmpick { color:var(--soft); cursor:default; }
  .rmpick input { width:16px; height:16px; cursor:pointer; flex:none; }
  .rmstate { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:700;
    color:var(--soft); }
  .rmstate.good { color:var(--good); }
  .rmstate.bad { color:var(--no); font-weight:800; }
  .rmdot { width:7px; height:7px; border-radius:50%; background:currentColor; flex:none; }
  .rmcars { font-size:12.5px; color:var(--soft); }
  .rmage { font-size:11.5px; color:var(--soft); text-align:right; }
  .rmrow.bad .rmcars, .rmrow.bad .rmage { color:var(--no); font-weight:700; }
  /* The failure detail, and the fix, on the row where the failure is. */
  .rmwhy { display:flex; align-items:center; gap:10px; margin-top:7px; }
  .rmwhy-text { flex:1; font-size:11.5px; color:var(--soft); line-height:1.45; }
  .rmwhy-btn { flex:none; border:none; border-radius:8px; padding:6px 13px; font-size:11.5px;
    font-weight:700; font-family:inherit; background:var(--icon); color:#fff; cursor:pointer;
    white-space:nowrap; }
  .rmwhy-btn:hover { filter:brightness(.95); }
  .rmwhy-btn.ghost { background:#fff; color:var(--soft); border:1px solid var(--line); }
  .rmwhy-btn:disabled { background:var(--soft); cursor:default; filter:none; }
  /* Two tabs, one table showing (Steven 2026-09-02, chose "B: tabs" from
     the three mockups): Auction houses and Maintenance share the exact
     same row anatomy, tick, name, state, result, age. The tab you are
     not on shows only its count, or how many need attention. */
  .rmtabs { display:flex; gap:3px; padding:3px; margin:0 24px 14px; background:var(--sidebar-bg, #e9edeb); border-radius:11px; }
  .rmtab { flex:1; display:flex; align-items:center; justify-content:center; gap:8px; height:34px; border:none;
    border-radius:9px; background:none; font:inherit; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer; }
  .rmtab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px oklch(0.279 0.0299 260.05 / .12); }
  .rmtab-n { font-size:11px; font-weight:700; color:var(--soft); }
  .rmtab-n.bad { color:var(--no); }
  .rmpanel[hidden] { display:none; }
  /* Select all / deselect all, one per tab, in the header's own first
     cell (Steven 2026-09-02). Shows part ticked (indeterminate) when the
     rows below disagree. */
  .rmall { display:flex; align-items:center; gap:10px; cursor:pointer; font:inherit; letter-spacing:inherit; text-transform:inherit; }
  .rmall input { width:16px; height:16px; margin:0; flex:none; cursor:pointer; }
  .rmrow.off .rmstate { color:var(--soft); }
  .rmrow.off input { cursor:default; }
  .rmcars { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .rmfoot { display:flex; align-items:center; gap:12px; padding:14px 24px; background:var(--bg);
    border-top:1px solid var(--line); }
  .rmfoot-note { flex:1; font-size:11.5px; color:var(--soft); line-height:1.4; }
  .rmfoot button { border-radius:10px; padding:10px 20px; font-size:14px; font-weight:700;
    font-family:inherit; cursor:pointer; border:1px solid var(--line); }
  .rmfoot .cancel { background:#fff; color:var(--ink); }
  .rmfoot .go { background:var(--icon); color:#fff; border-color:var(--icon); padding:10px 26px; }
  .rmfoot .go:hover { filter:brightness(.95); }
  .rmfoot .go:disabled { background:var(--soft); border-color:var(--soft); cursor:default; filter:none; }
  @media (max-width:700px) {
    #runmodal-overlay .runmodal { width:100%; }
    .rmhdr, .rmrow-main { grid-template-columns:1fr 90px 110px; }
    .rmhdr span:nth-child(4), .rmrow-main .rmage { display:none; }
  }
aýa  
    // ---- the Run modal, one for every page (Steven 2026-09-02: "when i
    // click on the run button on any page other than live auctions the
    // modal doesnt pop up"; chosen: the same Run modal everywhere, the
    // purchases and Didn't win pickers folded in as their own cards) ----
    async function rmPost(url, body){
      const r = await fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'},
                                  body: JSON.stringify(body || {})});
      return r.json();
    }
    // The Run button, the modal's Run and every maintenance card lock while
    // anything is going, and the card that started it says so. The bar,
    // the light, starting and waiting all come from the shared run kit;
    // this is the cockpit's own extra painting, called on every tick.
    function setRunning(on, which){
      // The nav bar button itself stays clickable while a run is going
      // (it opens the board, which is exactly where you want to look
      // then). Only the modal's own Run locks, and the running row says so.
      const go = bbEl('rmgo');
      if(go) go.disabled = on;
      document.querySelectorAll('#runmodal-overlay .rmchk').forEach(row => {
        const age = row.querySelector('.rmage');
        if(age && on && which && which === row.dataset.run && !row.classList.contains('off')) age.textContent = 'Running...';
      });
    }
    let _rmRefreshAt = 0;
    function onRunTick(s, key, running){
      if(!s){ setRunning(false, null); return; }
      let which = key;
      if(!which) which = RUN_KEYS.find(k => s[k + '_running']) || (s.login_running ? 'login' : null);
      setRunning(running || !!s.login_running, which);
      // A modal left open during a run used to show stale ages until it was
      // closed and reopened; repaint it every few ticks while it is open.
      const rov = bbEl('runmodal-overlay');
      if(rov && !rov.hidden && Date.now() - _rmRefreshAt > 9000){ _rmRefreshAt = Date.now(); refreshRunSummary(); }
    }
    // These passes rewrite the page itself, so a clean finish reloads it.
    function reloadIfDone(s){
      const p = (s && s.progress) || {};
      if(p.phase === 'done' && !p.partial && window.RUN_PAGE_RELOAD) setTimeout(() => location.reload(), 1200);
      else refreshRunSummary();
    }
    const RM_LABELS = {motorway:'Motorway', carwow:'Carwow', auction4cars:'Auction4Cars', dealerauction:'Dealer Auction', dealerway:'DealerWay'};
    const RM_WARN_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>';
    // Logging in without leaving the modal. Same two endpoints the settings
    // page has always used, and the same two steps for the same real
    // reason: login.py waits on a flag file, so only a person can say when
    // they are actually done. The window opens on this Mac.
    let _rmLoginSite = null;
    async function startModalLogin(site){
      const label = RM_LABELS[site] || site;
      if(!confirm('Open a login window for ' + label + ' on this Mac now? Log in there, then come back and confirm.')) return;
      try {
        const r = await rmPost('/api/relogin', {site: site});
        if(!r.started){ flash(r.reason || 'Something else is using the browser. Try again once it finishes.', false); return; }
        _rmLoginSite = site;
        paintModalLogin(site);
        pollModalLogin();
        slNudge();
      } catch(e){ flash('Could not open the login window.', false); }
    }
    async function finishModalLogin(site){
      try {
        await rmPost('/api/relogin-done', {site: site});
        toast('Login saved.', true);
        _rmLoginSite = null;
        paintModalLogin(null);
        rmSettleThenRefresh();
      } catch(e){ flash('Could not save it. Was the login window still open?', false); }
    }
    // While a login window is open it holds the one shared browser profile,
    // so a run genuinely cannot start (serve._any_proc_busy). Say so and
    // grey Run out, rather than let it be clicked and silently refused.
    function paintModalLogin(site){
      const go = bbEl('rmgo');
      if(go) go.disabled = !!site;
      bbSetText('rmfoot-note', site
        ? 'Finish the ' + (RM_LABELS[site] || site) + ' login first. It is using the browser, so nothing else can run.'
        : '');
      document.querySelectorAll('#runmodal-overlay .rmwhy').forEach(why => {
        const btn = why.querySelector('[data-login]');
        if(!btn) return;
        const key = btn.dataset.login;
        if(site && key === site){
          why.innerHTML = '<span class="rmwhy-text">A login window is open on this Mac. Log in there, then confirm here.</span>' +
            '<button type="button" class="rmwhy-btn ghost" data-cancel="1">Cancel</button>' +
            '<button type="button" class="rmwhy-btn" data-done="' + key + '">I&#39;m logged in</button>';
          why.querySelector('[data-done]').onclick = () => finishModalLogin(key);
          why.querySelector('[data-cancel]').onclick = async () => {
            try { await rmPost('/api/stop-run', {}); } catch(e){}
            _rmLoginSite = null; paintModalLogin(null); rmSettleThenRefresh();
          };
        } else if(site){
          btn.disabled = true;
        } else {
          btn.disabled = false;
        }
      });
    }
    // Stopping a login is not instant: the process has to actually exit
    // before serve._busy_reason stops reporting it. Refreshing straight
    // away read a stale "A login window is open for auction4cars." back
    // into the footer of a modal that had already returned to normal.
    async function rmSettleThenRefresh(){
      for(let i = 0; i < 12; i++){
        try {
          const s = await (await fetch('/api/run-status')).json();
          if(!s.login_running) break;
        } catch(e){ break; }
        await new Promise(r => setTimeout(r, 500));
      }
      refreshRunSummary();
    }
    async function pollModalLogin(){
      try {
        const s = await (await fetch('/api/run-status')).json();
        if(s.login_running){ setTimeout(pollModalLogin, 3000); return; }
        // The window closed on its own (saved, or stopped from elsewhere).
        if(_rmLoginSite){ _rmLoginSite = null; paintModalLogin(null); rmSettleThenRefresh(); }
      } catch(e){ setTimeout(pollModalLogin, 5000); }
    }
    // The modal's platform rows and automation buttons re-sync from the
    // live settings every time it opens (Steven 2026-08-26: a platform
    // hidden, or switched off in Settings, "wont even show up in the
    // modal"), since this page is a static file that can be older than
    // the latest settings change. Fails open: if the fetch dies, the
    // baseline baked in at render time shows, and the server's own
    // _select_platforms floor still excludes anything off regardless of
    // what this modal displays or sends.
    function openRunModal(){
      // The modal shows THE INSTANT it is clicked (Steven 2026-09-02:
      // "sometimes it takes a long time for the sync modal to appear and
      // sometimes i have to click it 3-4 times"). It used to wait for
      // /api/settings first, and that read can sit behind a database lock
      // for up to five seconds while a run is writing, so the click looked
      // dead. Now it opens at once and the two live fetches (what can run,
      // what happened last time) fill it in as they land; cockpit.html is a
      // static file, so neither can be baked in at render time.
      const rov = bbEl('runmodal-overlay');
      if(rov){ rov.hidden = false; rmSetTab(rov.dataset.defaultTab || 'checks'); }
      rmDefaultTicks();
      fetch('/api/settings').then(r => r.json()).then(d => { if(d.ok) syncRunModal(d.settings); rmDefaultTicks(); }).catch(() => {});
      refreshRunSummary();
    }
    // The page's own things start ticked, the other tab's start clear
    // (Steven 2026-09-02): on Live Auctions every auction house is ticked
    // and no check is; on every other page every check is ticked and no
    // auction house is. Re applied after the live settings sync, which
    // re ticks the platform rows on its own.
    function rmDefaultTicks(){
      const rov = bbEl('runmodal-overlay');
      const auctionPage = rov && rov.dataset.defaultTab === 'auction';
      rmPanelBoxes('auction').forEach(b => { b.checked = auctionPage; });
      rmPanelBoxes('checks').forEach(b => { b.checked = !auctionPage; });
      rmNote();
    }
    function rmSetTab(name){
      document.querySelectorAll('#runmodal-overlay .rmtab').forEach(t => {
        const on = t.dataset.tab === name;
        t.classList.toggle('on', on);
        t.setAttribute('aria-selected', on ? 'true' : 'false');
      });
      document.querySelectorAll('#runmodal-overlay .rmpanel').forEach(p => { p.hidden = p.dataset.panel !== name; });
    }
    // Select all / deselect all, per tab: every enabled row in that tab.
    function rmPanelBoxes(panel){
      const sel = panel === 'auction' ? '.rm-plat' : '.rmchk:not([hidden]):not(.off) .rm-check';
      return [...document.querySelectorAll('#runmodal-overlay .rmpanel[data-panel="' + panel + '"] ' + sel)].filter(b => !b.disabled);
    }
    function rmToggleAll(cb){
      rmPanelBoxes(cb.dataset.panel).forEach(b => { b.checked = cb.checked; });
      rmNote();
    }
    function rmPaintAll(){
      document.querySelectorAll('#runmodal-overlay .rm-all').forEach(cb => {
        const boxes = rmPanelBoxes(cb.dataset.panel);
        const n = boxes.filter(b => b.checked).length;
        cb.checked = boxes.length > 0 && n === boxes.length;
        cb.indeterminate = n > 0 && n < boxes.length;
        cb.disabled = boxes.length === 0;
      });
    }
    // The footer line says exactly what Run will do, from the ticks.
    function rmNote(){
      rmPaintAll();
      const note = bbEl('rmfoot-note');
      if(!note || note.dataset.busy) return;
      const plats = [...document.querySelectorAll('#runmodal-overlay .rm-plat:not(:disabled)')].filter(b => b.checked);
      const checks = [...document.querySelectorAll('#runmodal-overlay .rmchk')]
        .filter(r => !r.hidden && !r.classList.contains('off') && r.querySelector('.rm-check').checked)
        .map(r => r.querySelector('.rmpick span').textContent);
      let parts = [];
      if(plats.length) parts.push('the ' + plats.length + ' ticked auction house' + (plats.length === 1 ? '' : 's'));
      parts = parts.concat(checks);
      if(!parts.length){ note.textContent = 'Nothing ticked. Tick an auction house or a check to run it.'; return; }
      const list = parts.length > 1 ? parts.slice(0, -1).join(', ') + ', then ' + parts[parts.length - 1] : parts[0];
      note.textContent = 'Runs ' + list + (parts.length > 1 ? '. One at a time, so nothing clashes.' : '.');
    }
    async function refreshRunSummary(){
      try {
        const d = await (await fetch('/api/run-summary')).json();
        if(d.ok) paintRunSummary(d);
        // paintRunSummary rebuilds every failure row from scratch, so a
        // modal closed and reopened while a login window was open came
        // back with a plain Log in button and no way to confirm or cancel
        // the login already in progress (2026-09-01 sweep).
        if(_rmLoginSite) paintModalLogin(_rmLoginSite);
      } catch(e){ console.warn('BidBrain: could not read the run summary.', e); }
    }
    function syncRunModal(s){
      const hiddenConns = s.hidden_connections || [];
      const pe = s.platform_enabled || {};
      const integ = s.integrations || {};
      document.querySelectorAll('#runmodal-overlay .rmrow[data-site]:not(.rmchk)').forEach(row => {
        const off = hiddenConns.includes(row.dataset.site) || pe[row.dataset.site] === false;
        const box = row.querySelector('input');
        row.hidden = off;
        box.disabled = off;
        box.checked = !off;
      });
      document.querySelectorAll('#runmodal-overlay .rmchk').forEach(row => {
        row.hidden = !!(row.dataset.site && hiddenConns.includes(row.dataset.site));
        const gate = row.dataset.gate;
        const box = row.querySelector('.rm-check');
        if(gate && Object.prototype.hasOwnProperty.call(integ, gate)){
          const on = integ[gate] && integ[gate].enabled !== false;
          row.classList.toggle('off', !on);
          box.disabled = !on;
          if(!on) box.checked = false;
          box.title = on ? '' : 'Turned off in Settings > Connections.';
          if(on) delete box.dataset.runOff; else box.dataset.runOff = '1';
        }
      });
    }
    // "20h ago" / "yesterday" / "6 days ago" from an ISO stamp, matching the
    // wording the Integrations tab already uses but going finer: that page
    // only needs days, a run's own age is usually measured in hours.
    // Word for word the server's own _age_words tiers, so the modal and
    // the light's tooltip can never describe the same moment differently
    // ("just now" here against "50m ago" there, 2026-09-01 sweep).
    function rmAge(iso){
      if(!iso) return '';
      const then = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
      if(isNaN(then)) return '';
      const secs = (Date.now() - then.getTime()) / 1000;
      if(secs < 60) return 'just now';
      if(secs < 3600) return Math.floor(secs / 60) + 'm ago';
      if(secs < 86400) return Math.floor(secs / 3600) + 'h ago';
      if(secs < 172800) return 'yesterday';
      return Math.floor(secs / 86400) + ' days ago';
    }
    const RM_DOT = '<span class="rmdot"></span>';
    function paintRunSummary(d){
      const st = d.run_status || {};
      const health = d.site_health || {};
      const hiddenConns = d.hidden_connections || [];
      const pe = d.platform_enabled || {};

      // The run as a whole. This is the thing nothing could say before: a
      // run that was stopped, or died, looked exactly like one that
      // finished cleanly.
      const run = st.run;
      const card = bbEl('rmlast');
      if(card){
        card.className = 'rmlast' + (run ? (run.ok ? ' good' : ' bad') : '');
        bbSetText('rmlast-when', run ? rmAge(run.at) : '');
        const state = bbEl('rmlast-state');
        if(state){
          if(!run) state.textContent = 'No run recorded yet';
          else if(run.ok) state.textContent = 'Finished';
          else state.innerHTML = RM_WARN_SVG + ' Did not finish';
        }
        bbSetText('rmlast-note', run ? (run.summary || '') :
          'Nothing has been recorded since this was added. The next run will fill it in.');
      }

      // One row per platform.
      let stale = [];
      document.querySelectorAll('#runmodal-overlay .rmrow[data-site]:not(.rmchk)').forEach(row => {
        const key = row.dataset.site;
        const r = st[key];
        const off = hiddenConns.includes(key) || pe[key] === false;
        const sEl = row.querySelector('.rmstate');
        const cEl = row.querySelector('.rmcars');
        const aEl = row.querySelector('.rmage');
        const why = row.querySelector('.rmwhy');
        row.classList.remove('bad', 'off');
        if(why){ why.hidden = true; why.innerHTML = ''; }
        if(off){
          row.classList.add('off');
          sEl.className = 'rmstate'; sEl.textContent = 'Off in Settings';
          cEl.textContent = ''; aEl.textContent = '';
          return;
        }
        if(!r){
          sEl.className = 'rmstate'; sEl.textContent = 'Not run yet';
          cEl.textContent = ''; aEl.textContent = '';
          return;
        }
        aEl.textContent = rmAge(r.at);
        if(r.ok){
          sEl.className = 'rmstate good'; sEl.innerHTML = RM_DOT + 'Worked';
          cEl.textContent = (r.summary || '').replace(/ cars?$/, '');
        } else {
          row.classList.add('bad');
          sEl.className = 'rmstate bad'; sEl.innerHTML = RM_DOT + 'Failed';
          cEl.textContent = 'stale';
          stale.push(RM_LABELS[key] || key);
          if(why) paintRunFailure(why, key, health[key]);
        }
      });
      // The footer line is the ticks' own sentence (rmNote), unless
      // something is busy or stale, which outranks it.
      const fn = bbEl('rmfoot-note');
      const busyText = d.busy_reason || (stale.length
        ? stale.join(' and ') + ' ' + (stale.length > 1 ? 'are' : 'is') + ' showing cars from an earlier run.'
        : '');
      if(fn){ if(busyText){ fn.dataset.busy = '1'; fn.textContent = busyText; } else { delete fn.dataset.busy; } }

      // The auction houses tab badge: how many are ticked and on.
      const platN = document.querySelectorAll('#runmodal-overlay .rmrow[data-site]:not(.rmchk):not([hidden])').length;
      const ta = bbEl('rmtab-n-auction');
      const platBad = [...document.querySelectorAll('#runmodal-overlay .rmrow[data-site]:not(.rmchk):not([hidden])')].filter(r => r.classList.contains('bad')).length;
      if(ta){ ta.className = 'rmtab-n' + (platBad ? ' bad' : ''); ta.textContent = platBad ? (platBad + ' need' + (platBad === 1 ? 's' : '') + ' attention') : String(platN); }

      // Maintenance rows, painted exactly like the platform rows above.
      let failing = 0, visible = 0;
      const rows = [...document.querySelectorAll('#runmodal-overlay .rmchk')];
      rows.forEach(row => {
        const key = row.dataset.check;
        const r = st[key];
        const sEl = row.querySelector('.rmstate');
        const cEl = row.querySelector('.rmcars');
        const aEl = row.querySelector('.rmage');
        const why = row.querySelector('.rmwhy');
        row.classList.remove('bad');
        if(why){ why.hidden = true; why.innerHTML = ''; }
        if(row.hidden) return;
        visible++;
        if(row.classList.contains('off')){
          sEl.className = 'rmstate'; sEl.textContent = 'Off in Settings';
          cEl.textContent = ''; aEl.textContent = '';
          return;
        }
        if(!r){
          sEl.className = 'rmstate'; sEl.textContent = 'Never run';
          cEl.textContent = ''; aEl.textContent = '';
          return;
        }
        aEl.textContent = rmAge(r.at);
        const sum = r.summary || '';
        if(r.ok && sum.startsWith('Skipped.')){
          sEl.className = 'rmstate'; sEl.innerHTML = RM_DOT + 'Skipped';
          cEl.textContent = sum.slice(8).trim(); cEl.title = cEl.textContent;
        } else if(r.ok){
          sEl.className = 'rmstate good'; sEl.innerHTML = RM_DOT + 'Worked';
          cEl.textContent = sum; cEl.title = sum;
        } else if(sum === 'Stopped from the page.'){
          sEl.className = 'rmstate'; sEl.innerHTML = RM_DOT + 'Stopped';
          cEl.textContent = 'Stopped before it finished'; cEl.title = '';
        } else {
          failing++;
          row.classList.add('bad');
          sEl.className = 'rmstate bad'; sEl.innerHTML = RM_DOT + 'Failed';
          cEl.textContent = sum; cEl.title = sum;
          const site = row.dataset.site;
          const h = site ? health[site] : null;
          if(why) paintRunFailure(why, site, h && h.ok === false ? h : {detail: sum || 'The last run did not work.'}, !!(site && site !== 'dealer_os' && h && h.ok === false));
        }
      });
      // Failing rows float to the top of their table, off ones sink.
      const table = rows.length ? rows[0].parentElement : null;
      if(table){
        const order = r => r.classList.contains('bad') ? 0 : (r.classList.contains('off') ? 2 : 1);
        rows.slice().sort((a, b) => order(a) - order(b)).forEach(r => table.appendChild(r));
      }
      const tc = bbEl('rmtab-n-checks');
      if(tc){ tc.className = 'rmtab-n' + (failing ? ' bad' : ''); tc.textContent = failing ? (failing + ' need' + (failing === 1 ? 's' : '') + ' attention') : String(visible); }
      rmNote();
    }
    // A failed platform's own reason, and the fix, on the row it belongs to
    // (2026-08-31: "why would we have to login from the settings page? can
    // we not do it from that modal?"). The endpoints were always generic
    // over every site, only the button lived elsewhere.
    function paintRunFailure(why, key, health, withLogin){
      const detail = (health && health.detail) || 'The last read did not work.';
      // Built as real nodes, not an innerHTML string: detail is a platform's
      // own error text, and textContent escapes it for free.
      why.hidden = false;
      why.textContent = '';
      const text = document.createElement('span');
      text.className = 'rmwhy-text';
      text.textContent = detail;
      why.appendChild(text);
      if(withLogin === false) return;
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'rmwhy-btn';
      btn.dataset.login = key;
      btn.textContent = 'Log in';
      btn.onclick = () => startModalLogin(key);
      why.appendChild(btn);
    }
    function closeRunModal(){ const rov = bbEl('runmodal-overlay'); if(rov) rov.hidden = true; }
    // rmToggleAll, rmSync and the #rm-all probe went with the v1 modal's
    // own All checkbox: the rebuilt board has no such element, so the
    // probe fired the guard's broken-page warning on every first toggle
    // (2026-09-01 sweep).
    // One Run for everything ticked: the auction houses first (one daily
    // run over the ticked ones), then each ticked check in turn. They
    // share one browser, so they go one at a time; each waits for the
    // one before it to finish before starting.
    let rmQueue = [], rmQueueReload = false;
    function confirmRun(){
      const boxes = Array.from(document.querySelectorAll('#runmodal-overlay .rm-plat:not(:disabled)'));
      const chosen = boxes.filter(el => el.checked).map(el => el.value);
      const checks = [...document.querySelectorAll('#runmodal-overlay .rmchk')]
        .filter(r => !r.hidden && !r.classList.contains('off') && r.querySelector('.rm-check').checked)
        .map(r => ({key: r.dataset.run, body: r.dataset.body ? JSON.parse(r.dataset.body) : {},
                    label: r.querySelector('.rmpick span').textContent}));
      rmQueue = [];
      if(chosen.length){
        const allChosen = chosen.length === boxes.length;
        rmQueue.push({key: 'run', body: allChosen ? {} : {platforms: chosen},
                      label: 'the ' + (allChosen ? 'daily run' : chosen.map(v => RM_LABELS[v]).join(', ') + ' read')});
      }
      rmQueue = rmQueue.concat(checks);
      if(!rmQueue.length){ toast('Nothing ticked. Tick an auction house or a check to run it.', false); return; }
      closeRunModal();
      rmQueueReload = false;
      rmNext();
    }
    async function rmNext(){
      const item = rmQueue.shift();
      if(!item){
        if(rmQueueReload && window.RUN_PAGE_RELOAD) setTimeout(() => location.reload(), 1200);
        else refreshRunSummary();
        return;
      }
      const left = rmQueue.length;
      const ok = await startRun(item.key, item.body, {
        confirm: '',
        toast: 'Started ' + item.label + (left ? ', ' + left + ' more to follow.' : '.'),
        onFinish: (s) => {
          const p = (s && s.progress) || {};
          if(RM_REWRITES.includes(item.key) && p.phase === 'done' && !p.partial) rmQueueReload = true;
          if(typeof onRunFinish === 'function') onRunFinish(item.key, s);
          rmNext();
        }});
      if(!ok){ rmQueue = []; refreshRunSummary(); }
    }
    // A single check started by code (a bell notice, a page's own button).
    function runCheck(key, body, confirmText){
      closeRunModal();
      const opts = {onFinish: (s) => runFinished(key, s)};
      if(confirmText !== undefined) opts.confirm = confirmText;
      startRun(key, body || {}, opts);
    }
    // What happens once any run ends outside the queue: the board repaints
    // (or the cockpit reloads, for the passes that rewrite it), then the
    // page's own hook, if it has one, refreshes whatever it shows.
    const RM_REWRITES = ['run', 'glass', 'dealerkit_stock', 'dealerkit_purchases', 'lecapital_funding', 'clickdealer'];
    function runFinished(key, s){
      if(RM_REWRITES.includes(key)) reloadIfDone(s); else refreshRunSummary();
      if(typeof onRunFinish === 'function') onRunFinish(key, s);
    }
    // Anything already going when the page loads (a run from another tab,
    // the schedule, a login window) shows on the bar and locks the buttons
    // straight away, and finishes the same way as if started from here.
    (function(){
      const handlers = {_any: (s) => runFinished('run', s), login: () => rmSettleThenRefresh()};
      RUN_KEYS.forEach(k => { handlers[k] = (s) => runFinished(k, s); });
      resumeRuns(handlers);
    })();
    // A bell notice or the settings page's own light can arrive here asking
    // for the board (?openrun=1); open it on arrival.
    if(new URLSearchParams(location.search).get('openrun') === '1'){ openRunModal(); }
)Údealerkit_stockzDealerKit stockÚ	dealerkitr¨   N)Údealerkit_purchaseszPush purchases to DealerKitr©   rª   N)Údealerkit_checkzDealerKit records checkr©   rª   N)Ú	purchaseszPurchases syncNNN)Ú	lost_bidszDidn't win syncNNNr­   zDidn't win full historyÚbackfill)Úunder_offerzUnder offer checkNNN)Ú	watchlistzWatchlist syncNNN)Údealer_os_pushzDealer OS pushÚ	dealer_osr±   N)Ú	compmatchzCompMatch dataNNN)Úretail_repricezRe price retail estimatesNNN)ÚglasszGlass's checksrµ   rµ   N)Úlecapital_fundingúLE Capital funding syncÚ	lecapitalr¶   Nc                 ój  • U=(       d    0 nU=(       d    / n/ n[          Hâ  u  pVpxn	U(       a*  UR                  U5      =(       d    0 R                  SS5      OSn
Xq;   nU
(       a  SOSnSU SU S3U(       a  SU S3OS-   U(       a  S	U S3OS-   U	(       a  S
[        R                  " U	5       S3OS-   U(       a  SOS-   U
(       a  SOS-   nU
(       d  SOSnUR	                  SU U SU SU S[        U5       S3
5        Mä     SR                  U5      nUS;   a  UOSnSU S[        X5       SU S3$ )a  The Run modal, identical on every page (Steven 2026-09-02): two tabs,
Auction houses and Maintenance, each a table of rows with the same
anatomy, and one Run button that does the ticked auction houses first
and then each ticked check in turn. default_tab is which tab opens
first: the auction houses on Live Auctions, the checks everywhere
else. A baseline only: openRunModal() re-syncs the rows live from
/api/settings every time it opens, so a page rendered with nothing
passed (every page but the cockpit) still shows the truth on open.ÚenabledTr   zk disabled data-run-off="1" title="Turned off in Settings > Connections. Turn it back on there to use this."z data-check="z" data-run="Ú"z data-site="z data-gate="z data-body='Ú'ú hiddenz class="rmrow rmchk off"z class="rmrow rmchk"z<divz^><div class="rmrow-main"><label class="rmpick"><input type="checkbox" class="rm-check" value="z" onchange="rmNote()"ú><span>ú�</span></label><span class="rmstate"></span><span class="rmcars"></span><span class="rmage"></span></div><div class="rmwhy" hidden></div></div>ú	
        )ÚauctionÚchecksrÂ   zO  <div id="runmodal-overlay" class="runmodal-overlay" hidden data-default-tab="a}  " onclick="if(event.target===this)closeRunModal()">
    <div class="runmodal">
      <div class="rmhead">
        <h3>Run BidBrain</h3>
        <p>Tick what you want run. Everything else is left exactly as it is.</p>
        <div id="rmlast" class="rmlast">
          <div class="rmlast-top">
            <span class="rmlast-lab">Last run</span>
            <span class="rmlast-when" id="rmlast-when"></span>
          </div>
          <div class="rmlast-state" id="rmlast-state"></div>
          <p class="rmlast-note" id="rmlast-note"></p>
        </div>
      </div>

      <div class="rmtabs" role="tablist">
        <button type="button" class="rmtab" data-tab="auction" role="tab" onclick="rmSetTab('auction')">Auction houses <span class="rmtab-n" id="rmtab-n-auction"></span></button>
        <button type="button" class="rmtab" data-tab="checks" role="tab" onclick="rmSetTab('checks')">Maintenance <span class="rmtab-n" id="rmtab-n-checks"></span></button>
      </div>

      <div class="rmscroll">
      <div class="rmpanel" data-panel="auction">
      <div class="rmtable">
        <div class="rmhdr">
          <span><label class="rmall"><input type="checkbox" class="rm-all" data-panel="auction" onchange="rmToggleAll(this)" title="Select or deselect every auction house"><span>Auction house</span></label></span><span>Last read</span><span>Cars</span><span>Age</span>
        </div>
        až  
      </div>
      </div>
      <div class="rmpanel" data-panel="checks" hidden>
      <div class="rmtable">
        <div class="rmhdr">
          <span><label class="rmall"><input type="checkbox" class="rm-all" data-panel="checks" onchange="rmToggleAll(this)" title="Select or deselect every check"><span>Check</span></label></span><span>Last run</span><span>Result</span><span>Age</span>
        </div>
        aN  
      </div>
      </div>
      </div>

      <div class="rmfoot">
        <span class="rmfoot-note" id="rmfoot-note"></span>
        <button type="button" class="cancel" onclick="closeRunModal()">Cancel</button>
        <button type="button" class="go" id="rmgo" onclick="confirmRun()">Run</button>
      </div>
    </div>
  </div>
)Ú_RUN_MODAL_CHECKSÚgetÚjsonÚdumpsÚappendrl   r   Ú_run_modal_platform_rows)Úplatform_enabledÚhidden_connectionsÚintegrationsÚdefault_tabÚrowsrS   r$   ÚsiteÚgateÚbodyÚonÚhiddenÚ	off_attrsÚattrsÚclsÚ
check_rowsÚtabs                    r   Ú_run_modal_markuprØ   W  s‘  € ð  ×% 2€LØ+×1¨rÐØ€Dß(9Ñ$ˆ�D ÞDHˆl×Ñ˜tÓ$×*¨×/Ñ/°	¸4Ô@ÈdˆØÑ+ˆÞ‘Rð $;ˆ	à    \°#°°aÐ8ÖVZ¸lÈ4È&ÐPQÑ<RÐ`bÑcÞ.2�l 4 &¨Ñ*¸ñ<æ:>�l¤4§:¢:¨dÓ#3Ð"4°AÑ6ÀBñHö "(‘I¨Rñ1ö ;=±BÐB\ñ^ˆö ‰bÐ 6ˆØ�‰Ø�3�%˜�wð TàTWÐSXÐXmÐnwÐmxð yÜ˜%“[�Mð ";ð<ö	=ñ ):ð" ×"Ñ" 4Ó(€JØ$Ð(=Ó=‰+À8€CØ^Ð_bÐ^cð d	ô4 
"Ð"2Ó	GÐHð I	ð 
ˆð ðE.ð .r&   c                  óì   • SSK Jn   [        R                  S[        R
                  " U R                  5       5      5      R                  S[        R
                  " [        U R                  5      5      5      $ )z=RUN_KIT_JS_TEMPLATE with the registry's client half baked in.r   )ÚrunsÚ__RUNS_JSON__Ú__ACTIVE_PHASES__)	ÚbidbrainrÚ   ÚRUN_KIT_JS_TEMPLATErv   rÅ   rÆ   Úclient_registryÚlistÚACTIVE_PHASES)Ú_runss    r   r£   r£   ¨  sM   € å&Üß‰W�_¤d§j¢j°×1FÑ1FÓ1HÓ&IÓJß‰WÐ(¬$¯*ª*´T¸%×:MÑ:MÓ5NÓ*OÓPðRr&   c                  ó   • g)z.The one progress bar, identical on every page.az  <div id="runbar" class="runbar" hidden><div class="runbar-row"><span id="runmsg" class="runmsg"></span><span class="runbar-right"><span id="runpct" class="runpct"></span><button type="button" id="stopbtn" class="stopbtn" onclick="stopRun()" title="Stop whatever is running now">Stop</button></span></div><div class="runtrack"><div id="runfill" class="runfill"></div></div></div>r   r   r&   r   Ú_runbar_markuprä   °  s   € ðYr&   aÉ    .sl-run svg, .sl-stall svg, .sl-fail svg,
  .sl-tick svg, .sl-login svg { --sl-amber:#d39e00; }
  .sl-run svg, .sl-tick svg, .sl-login svg { color:var(--good); }
  .sl-stall svg { color:var(--sl-amber); }
  .sl-fail svg { color:var(--no); }
  .sl-run svg { animation:navspin .8s linear infinite; }
  /* Amber stops spinning on purpose: the app can no longer vouch for
     progress, so a confident spin would be a lie. The pulse says the
     watcher itself is still alive. */
  .sl-stall svg { animation:slpulse 1.6s ease-in-out infinite; }
  @keyframes slpulse { 0%, 100% { opacity:1; } 50% { opacity:.35; } }
  @media (prefers-reduced-motion: reduce) {
    .sl-run svg, .sl-stall svg { animation:none; }
  }
uŽ  
    // ---- the sync light (2026-08-31) ----
    // One traffic light in the nav bar of every page, so a run is visible
    // wherever you are and a failure follows you off the page it happened
    // on (Steven: "no matter where you are or what page you are on you can
    // see a sync is happening... with a traffic light system").
    //
    // It paints the button that is ALREADY in the bar: #runbtn on the
    // cockpit, #syncbtn on purchases, Didn't win and under offer, and the
    // one added to settings. Never a new element, so the bar's layout can
    // never shift. Clicks stay whatever that page already did, so the
    // purchases sync modal and the cockpit run modal both still open from
    // their own buttons.
    //
    // This is the app's first always on poll. It has to be, because the
    // whole point is showing runs nobody on this page started: the 17:10,
    // 15:45 and 15:55 launchd jobs. It is cheap by design, /api/run-status
    // is proc flags plus one small file, and it backs off to 10s the
    // moment nothing is happening.
    const SL_ARC = '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path>';
    const SL_ALERT = '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>';
    const SL_TICK = '<polyline points="20 6 9 17 4 12"></polyline>';
    const SL_CLASSES = ['sl-run','sl-stall','sl-fail','sl-tick','sl-login'];
    let slWasBusy = false, slTickUntil = 0, slTimer = null;

    // Plain getElementById on purpose: this is a probe across ids that
    // legitimately exist on different pages, and bbEl's missing-element
    // warning is a real-fault signal that was firing as noise on every
    // page load (2026-09-01 sweep).
    function slButton(){
      return document.getElementById('runbtn') || document.getElementById('syncbtn')
          || document.getElementById('slbtn');
    }

    function slPaint(state, label){
      const b = slButton();
      if(!b) return;
      SL_CLASSES.forEach(c => b.classList.remove(c));
      const svg = b.querySelector('svg');
      let glyph = SL_ARC, cls = '', shape = 'arc';
      if(state === 'active'){ cls = 'sl-run'; }
      else if(state === 'stalled'){ cls = 'sl-stall'; }
      else if(state === 'login'){ cls = 'sl-login'; }
      else if(state === 'failed'){ cls = 'sl-fail'; glyph = SL_ALERT; shape = 'alert'; }
      else if(state === 'tick'){ cls = 'sl-tick'; glyph = SL_TICK; shape = 'tick'; }
      if(cls) b.classList.add(cls);
      // Tracked by name rather than by comparing innerHTML: the browser
      // normalises markup it parses, so a string compare never matches and
      // the icon would be rebuilt on every single poll for no reason.
      if(svg && b.getAttribute('data-sl-glyph') !== shape){
        svg.innerHTML = glyph;
        b.setAttribute('data-sl-glyph', shape);
      }
      // The title is this poller's alone. setRunning used to rewrite it on
      // the cockpit, which quietly destroyed the failure tooltip baked at
      // render time (the red dot only survived because it was a class).
      // aria-label mirrors it because aria-label OUTRANKS title as the
      // accessible name: without this, the cockpit and settings buttons
      // (which carry a static aria-label) announced a fixed "Run now"
      // whatever the light showed, while the other three pages announced
      // the live state â€” the same control, opposite accessibility
      // behaviour per page (2026-09-01 sweep).
      const text = label || b.getAttribute('data-idle-title') || 'Run now';
      b.title = text;
      b.setAttribute('aria-label', text);
    }

    // Poke the light the moment a pass is started or stopped from THIS
    // page, instead of waiting out the idle 10s cadence. Without this,
    // clicking Sync now gave no light for up to ten seconds while each
    // page's own ad hoc feedback differed (2026-09-01 sweep).
    function slNudge(){
      if(slTimer) clearTimeout(slTimer);
      slTimer = setTimeout(slPoll, 250);
    }

    let slInFlight = false, slQueued = false;
    async function slPoll(){
      // Re-entry guard: once a poll has FIRED, slTimer holds a dead id,
      // so a nudge's clearTimeout was a no-op and each collision added a
      // second self-perpetuating chain for the life of the page (the
      // sweep's own verification round). A nudge during a live fetch now
      // queues one fast follow-up instead of forking the loop.
      if(slInFlight){ slQueued = true; return; }
      slInFlight = true;
      let next = 10000;
      try {
        const s = await (await fetch('/api/run-status')).json();
        const L = s.light || {state:'ok', label:''};
        const busy = L.state === 'active' || L.state === 'stalled' || L.state === 'login';
        const now = Date.now();
        // A tick is purely client side: the server has no idea this page
        // was watching, so "it just finished" can only be a transition
        // seen from here. Ten seconds, then back to whatever is true.
        // Gated on a genuine, non-partial terminal done: a Stop writes
        // phase "stopped", and before this gate the busy->ok transition
        // painted a green success tick for a run the user had just
        // deliberately killed (2026-09-01 sweep).
        const ph = (s.progress || {});
        const cleanDone = ph.phase === 'done' && !ph.partial;
        if(slWasBusy && !busy && L.state === 'ok' && cleanDone){ slTickUntil = now + 10000; }
        slWasBusy = busy;
        if(!busy && L.state === 'ok' && now < slTickUntil){
          slPaint('tick', 'Finished just now');
        } else {
          if(now >= slTickUntil) slTickUntil = 0;
          slPaint(L.state, L.label);
        }
        if(busy) next = 3000;
        else if(slTickUntil) next = 2000;
      } catch(e){ /* a dead server is not worth a red light; try again */ }
      slInFlight = false;
      if(slQueued){ slQueued = false; next = 250; }
      slTimer = setTimeout(slPoll, next);
    }

    function startSyncLight(){
      const b = slButton();
      if(!b) return;
      // A red light invites a click to INSPECT, so on every page that click
      // opens the Run menu's own board (the cockpit's modal, or the cockpit
      // with it open); the button's ordinary job is untouched otherwise.
      b.addEventListener('click', (e) => {
        if(!b.classList.contains('sl-fail')) return;
        e.preventDefault(); e.stopImmediatePropagation();
        if(typeof openRunModal === 'function') openRunModal();
        else location.href = 'cockpit.html?openrun=1';
      }, true);
      // Remembered once, so an idle light can put back whatever this
      // page's own button called itself before the light existed.
      if(!b.getAttribute('data-idle-title')) b.setAttribute('data-idle-title', b.title || 'Run now');
      if(slTimer) clearTimeout(slTimer);
      slPoll();
    }
    startSyncLight();
c                 ó˜   • U =(       d    SR                  5       R                  5       nSU;   a  gSU;   a  gSU;   a  gSU;   a  gSU;   a  gg)	Nr   ÚmotorwayÚcarwowÚauction4carsúdealer auctionÚdealerauctionÚ	dealerwayÚotherr5   )ÚsourceÚss     r   Ú_platform_classrï   S  sU   € Ø	��2×ÑÓ×$Ñ$Ó&€AØ�QƒØØ�1ƒ}ØØ˜ÓØØ˜1ÓØØ�aÓØØr&   c                 ó(  • [        U 5      nUS:X  a%  [        (       a
  S[         S3OS[        U 5       S3nOTUS:X  a  [        (       a
  S[         S3OSnO6US	:X  a  [        (       a
  S
[         S3OSnOU (       a  S[        U 5       S3nOgSU SU S3$ )Nræ   z<img class="plat-logo" src="z" alt="Motorway">z<span class="plat-word">ú</span>rç   z" alt="Carwow">z(<span class="plat-word cw">carwow</span>rè   z <img class="plat-logo a4c" src="z" alt="Auction4Cars">z+<span class="plat-word">Auction4Cars</span>r   z<span class="plat plat-ú">)rï   ÚMW_LOGOrl   ÚCW_LOGOÚA4C_LOGO)rí   rÕ   Úinners      r   Ú_platform_pillr÷   b  s°   € Ü
˜&Ó
!€CØ
ˆjÓç’Gð 0´¨yÐ8IÑJØ#;¼DÀ»L¸>ÈÐ!Qñ 	à	�‹ç’Gð 0´¨y¸ÑHØ!Kñ 	à	�Ó	ç’Hð 4´H°:Ð=RÑSØ"Oñ 	æ	Ø*¬4°«<¨.¸Ð@‰àØ$ S E¨¨E¨7°'Ð:Ð:r&   c                 ót  • U R                   nUb'  S[        U5       S[        U5       S[        U5       S3OSn[        U R                  5      nU R                  (       aH  S[        U R                  5       S[        U R
                  5       S[        U R                  5       S3nS	nOS
nSnSU SU U U U S3$ )Nz<span class="grade grade-z" title="Condition grade rò   rñ   r   ú
<img src="ú" alt="r   Úphotoz'<span class="noimg">No photo yet</span>zphoto nophotoú<div class="rš   )Úgraderl   r÷   rí   Ú	photo_urlr@   rA   )r   Úextrarý   Úgrade_badgeÚplatformrö   rÕ   s          r   Ú_photor  t  sÆ   € Ø�I‰I€Eð Ñð $¤D¨£K =Ð0IÌ$ÈuË+ÈÐVXÔY]Ð^cÓYdÐXeÐelÑmØ"$ð ô ˜cŸj™jÓ)€HØ
‡}‡}ØœT #§-¡-Ó0Ð1°¼¸c¿h¹h»Ð8HÈÌ$ÈsÏyÉyË/ÐIZÐZ\Ð]ˆØ‰à9ˆØˆØ˜#˜˜b  ¨ z°+°¸u¸gÀVÐLÐLr&   c                 óÎ   • [        U R                  =(       d    S5      U R                  U R                  U R                  /nSR                  S U 5       5      R                  5       $ )Nr   r   c              3   ó6   #   • U  H  o(       d  M  Uv •  M     g 7fr   r   )r   Úbs     r   r   Ú_title.<locals>.<genexpr>†  s   é € Ð)št˜!£q—A‘Aštùr   )rj   Úyearr@   rA   Ú
derivativer   r6   )r   Úbitss     r   Ú_titler
  „  sG   € Ü�—‘—˜BÓ §¡¨3¯9©9°c·n±nÐE€DØ�8‰8Ñ)™tÓ)Ó)×/Ñ/Ó1Ð1r&   c                 óŠ   • U R                   b  Uc  gXR                   -
  nUS:¼  a  S[        U5       S3$ S[        U* 5       S3$ )Nr   r   z<span class="head over">z over reserve</span>z<span class="head under">z under reserve</span>)Úreserver   )r   Úmax_bidÚdiffs      r   Ú	_headroomr  ‰  sP   € Ø
‡{�{Ñ˜g™oØØ—[‘[Ñ €DØˆqƒyØ)¬&°«,¨Ð7KÐLÐLØ&¤v¨t¨e£} oÐ5JÐKÐKr&   c                 ó>  • SSK nU R                  =(       d    SnUR                  SU5      nU(       a  UR                  S5      OSnU R                  =(       d    SR                  5       R                  5       nU(       a  U(       a  U SU 3$ U(       a  U$ U(       a  U$ g)aG  A clean engine chip from the platforms' messy engine string, which jams
the litres, trim and fuel together (for example "1.6 CDTi Exclusiv 5dr 4WD
Diesel"). The litres and fuel are the useful, reliable parts; the trim is
already in the title. Returns "2.0 Diesel", or the litres or fuel alone, or
None when neither can be read.r   Nr   ú(\d\.\d)r   r   )r   r   r   r   r"   r6   rœ   )r   Ú_rer    rF   r!   r"   s         r   r%   r%   ’  s~   € ó Ø
�*‰*×
˜€CØ�
‰
�; Ó$€AÞˆQ�W‰W�QŒZ $€FØ�H‰H�N˜×!Ñ!Ó#×)Ñ)Ó+€DÞ–$Ø�˜˜4˜&Ð!Ð!ÞØˆÞØˆØr&   c                 óˆ   • SSK nUR                  SU R                  =(       d    S5      nU(       a  UR                  S5      $ S$ )a¤  Just the litres off the platforms' messy engine string ("1.6 CDTi
Exclusiv 5dr 4WD Diesel" -> "1.6"), the one genuinely comparable part of
it, so engine size can be filtered on. Best effort by design: the string
is free text and every platform writes it differently (readable on 94
percent of Motorway cars, 34 percent of Auction4Cars), so a car it cannot
be read from is exempt from the filter rather than hidden by it.r   Nr  r   r   )r   r   r   r   )r   r  rF   s      r   Ú_engine_litresr  ¦  s5   € ó Ø�
‰
�; §
¡
× 0¨bÓ1€AÞˆ1�7‰7�1‹:Ð" Ð"r&   ©ræ   rç   rè   rê   rë   )ræ   rç   rê   ræ   rç   rë   )ræ   rè   rê   )rè   rê   rë   )rý   ÚcapÚbodytypeÚdistanceÚlocationÚ
endswithin)ÚenginelitresÚownersÚservicehistoryc                 ó0   • SSSSSS.R                  X 5      $ )NÚMotorwayÚCarwowÚAuction4CarsúDealer AuctionÚ	DealerWayr  ©rÄ   ©rS   s    r   Ú_platform_labelr&  Ð  s#   € Ø"¨hÈØ-¸KñIßILÉÈSËðWr&   c                 ó  • [         R                  U 5      nU(       d  gU Vs/ s H  n[        U5      PM     nn[        U5      S:X  a  US   OSR	                  USS 5      S-   US   -   n[        U5      S:X  a  SOS	nS
U S3$ s  snf )zŒThe plain sentence under a filter that only some platforms publish, so
it is never a mystery why it does not narrow a given platform's cars.r   r   r   ú, Néÿÿÿÿz and Ú	publishesÚpublishú<p class="filterscope">z	 only</p>)Ú_FILTER_SCOPErÄ   r&  r:   r   )ÚfieldÚplatsÚpÚnamesÚjoinedÚverbs         r   Ú_scope_noter4  Õ  s�   € ô ×Ñ˜eÓ$€EÞØÙ).Ó/ª AŒ_˜QÖ©€EÐ/Ü˜U› q›ˆU�1ŠX¨d¯i©i¸¸c¸r¸
Ó.CÀgÑ.MÐPUÐVXÑPYÑ.Y€FÜ˜e›*¨›/‰;¨y€DØ$ V H¨IÐ6Ð6ùò 0s   ¢Bc           	      ó‚   • SR                  [        R                  U [        5      5      nSU  SU SU [	        U 5       S3$ )z£Wraps one filter control with the platform scope it applies to, so the
drawer can hide it outright when a platform that never publishes it is the
one being viewed.r   z+<div class="filterblock" data-filterfield="z" data-platforms="rò   rš   )r   r-  rÄ   Ú_ALL_PLATFORMSr4  )r.  Úcontrol_htmlr/  s      r   Ú_filter_blockr8  á  sM   € ð �H‰H”]×&Ñ& u¬nÓ=Ó>€EØ9¸%¸Ð@RÐSXÐRYÐY[Øˆnœ[¨Ó/Ð0°ð8ð 9r&   c                 ó‚   • U R                   =(       d    SR                  5       R                  5       nUS:X  a  gUS;   a  gg)zÞA short gearbox label for the cars Steven wants to spot at a glance, the
automatics and semi automatics. Manual and unknown return None so only the
auto cars carry the highlight pill (CVT never reaches here, it is banned).r   Ú	automaticÚ	Automatic)zsemi-automaticzsemi automaticz	semi autoz	Semi autoN)Útransmissionr6   r   )r   r7   s     r   Ú_gearbox_labelr=  ê  s@   € ð 
×	Ñ	×	˜R×&Ñ&Ó(×.Ñ.Ó0€AØˆKÓØØÐ=Ó=ØØr&   c                 óR   • SU 3R                  5       nSU S[        U 5       SU S3$ )Nzchip z<span class="ú"><span class="cl">rñ   )r6   rl   )r$   rk   rÕ   Úcs       r   Ú_chiprA  ö  s6   € Ø
�ˆuˆ×ÑÓ€AØ˜1˜#Ð0´°e³°¸WÀUÀGÈ7ÐSÐSr&   c                 ó,   • SU S[        U 5       SU S3$ )Nrü   r?  z</span><b class="v">z
</b></div>©rl   )r$   rk   rÕ   s      r   Ú_statrD  û  s&   € Ø˜#˜Ð1´$°u³+°Ð>RÐSXÐRYÐYcÐdÐdr&   c                 óÞ   • U R                   c  gXR                   -
  nUS:¼  a  S[        U5       S3nSnOS[        U* 5       S3nSnSU S	[        U R                   5       S
[        U5       S3$ )Nr   r   zRecommendation sits z above what you paid.Úokz below what you paid.Úwarnz<div class="paid z/"><span class="cl">You actually paid</span> <b>z</b><p>z
</p></div>)Úactually_paidr   rl   )r   r  r  ÚverdictrÕ   s        r   Ú_paid_comparerJ  ÿ  s�   € Ø
×ÑÑ ØØ×&Ñ&Ñ&€DØˆqƒyØ(¬°«¨Ð6KÐLˆØ‰à(¬°°«¨Ð7LÐMˆØˆØ ˜uð %Ü˜×*Ñ*Ó+Ð,¨G´D¸³M°?À*ðNð Or&   c                 ón   • SR                  U =(       d    SR                  5       R                  5       5      $ rg   )r   r>   r9   )Úregs    r   Ú_nregrM  	  s'   € Ø�7‰7�C—I˜2×$Ñ$Ó&×,Ñ,Ó.Ó/Ð/r&   c                 ó¼   • SR                  [        U =(       d    S5      R                  5       R                  5       5      n[	        U5      S:”  a  USS  SUSS  3$ U$ )zgUK display form of a registration: a space before the last three
characters, CN18YLP shows as CN18 YLP.r   é   Néýÿÿÿr   )r   rj   r>   r9   r:   )rL  Úrs     r   Ú_pretty_regrR  	  sX   € ð 	�‰”�C—I˜2“×$Ñ$Ó&×,Ñ,Ó.Ó/€AÜ#& q£6¨A£:ˆa��ˆfˆX�Q�q˜˜�v�hÐÐ4°1Ð4r&   a+  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>a  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>a  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.08a2 2 0 0 1 1 1.74v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>a  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>a.  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"></path><path d="M3 6h18"></path><path d="M16 10a4 4 0 0 1-8 0"></path></svg>zù<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="m9.5 9.5 5 5m0-5-5 5"></path></svg>aR  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.42L12 12l-4.41 4.41a2 2 0 0 0-.59 1.42V22M7 2v4.17a2 2 0 0 0 .59 1.42L12 12l4.41-4.41A2 2 0 0 0 17 6.17V2"></path></svg>)ræ   rç   rè   ré   rë   z
le capitalc                 óh  ^• / / / / 4u  pp4U  HŸ  nUR                  S5      (       a  M  UR                  U5        UR                  5       mST;   a  UR                  U5        MU  ST;   a  UR                  U5        Mn  [        U4S j[         5       5      (       d  MŽ  UR                  U5        M¡     XX44$ )aÖ  Split the health notices the daily run writes (Glass's login and match
state, DealerKit stock staleness, Cazana health, and now a platform login
that failed in the last real run, Mark 2026-08-24) into what each Run
button should flag versus what only ever shows in the bell dropdown. A
notice starting with "All " is the happy path confirmation (every car got
both valuations), kept in the bell for visibility but never reddens a
button or counts toward the alert badge.úAll rµ   r©   c              3   ó,   >#   • U  H	  oT;   v •  M     g 7fr   r   )r   ÚkÚlows     €r   r   Ú&_categorize_notices.<locals>.<genexpr>w	  s   øé € ÐAÒ'@ !˜#–XÒ'@ùs   ƒ)Ú
startswithrÇ   r   r?   Ú_PLATFORM_NOTICE_KEYWORDS)Únoticesrµ   r©   r  ÚalertsÚnrW  s         @r   Ú_categorize_noticesr^  `	  sœ   ø€ ð *,¨R°°R¨Ñ&€E�hÛˆð �|‰|˜F×#Ó#Ø�M‰M˜!ÔØ—'‘'“)ˆCØ˜#‹~Ø—‘˜Q–Ø Ó#Ø× Ñ  Ö#ÜÔAÕ'@ÓA×AÓAØ—‘ Ö"ñ ð  ˜XÐ-Ð-r&   c                 óâ   ^^• [        U =(       d    / 5      n [        S U  5       5      nU4S jmU(       a  SU S3OSnU (       a  SR                  U4S jU  5       5      OSnS[         U S	U S
3$ )a¼  The notification bell button plus its dropdown panel, shared by every
page's own nav bar (Mark 2026-08-25: "standardise the nav to icons for
favorites, purchases, notifications, lost bids and settings ... same
order on all pages with same look and design"), not just the cockpit it
was originally built for. Same click-through-to-the-fix behaviour (Mark
2026-08-24) as before: a notice naming Settings > Integrations links
straight there, and a "Run Glass's checks"/"Run DealerKit stock" notice
opens the Run modal, either directly (cockpit=True, where openRunModal()
already exists on the page) or via a ?openrun=1 deep link back to the
cockpit (every other page, which has no Run modal of its own).c              3   óT   #   • U  H  oR                  S 5      (       a  M  Sv •  M      g7f)rT  r   N©rY  )r   r]  s     r   r   Ú_bell_markup.<locals>.<genexpr>ˆ	  s   é € ÐE¢˜A·±¸V×0D—a‘a¢ùs   ‚(Ÿ	(c                 óú   >• U R                  S5      (       a  SOSnSU ;   a  SU S[        U 5       S3$ SU ;   d  S	U ;   a+  T(       a  SU S
[        U 5       S3$ SU S[        U 5       S3$ SU S[        U 5       S3$ )NrT  Úbellnotezbellnote alertzSettings > Integrationsz
<a class="z6 bellnote-link" href="settings.html?tab=integrations">ú</a>zRun Glass's checkszRun DealerKit stockzs bellnote-link" href="#" onclick="document.getElementById('bellpanel').hidden=true; openRunModal(); return false;">z. bellnote-link" href="cockpit.html?openrun=1">z
<p class="rò   ú</p>)rY  rl   )r]  rÕ   Úcockpits     €r   Ú
_bell_noteÚ _bell_markup.<locals>._bell_noteŠ	  s°   ø€ ØŸL™L¨×0Ñ0‰jÐ6FˆØ$¨Ó)Ø   Ð%[Ü˜A“w�i˜tð%ð &à 1Ó$Ð(=ÀÓ(BÞØ$ S Eð *:ä:>¸q»'¸À$ðHð Ið !  Ð%SÜ˜A“w�i˜tð%ð &à˜C˜5 ¤4¨£7 )¨4Ð0Ð0r&   z<span class="bellbadge">rñ   r   c              3   ó4   >#   • U  H  nT" U5      v •  M     g 7fr   r   )r   r]  rh  s     €r   r   rb  ™	  s   øé € Ð8²¨1™ AŸ˜²ùó   ƒz+<p class="bellempty">Nothing to report.</p>z˜<div class="bellwrap"><button id="bellbtn" class="bellbtn" onclick="toggleBell()" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">z6</button><div id="bellpanel" class="bellpanel" hidden>ú</div></div>)rà   Úsumr   Ú	_BELL_SVG)r[  rg  Úalert_countÚ
bell_badgeÚ
bell_notesrh  s    `   @r   Ú_bell_markuprr  |	  s„   ù€ ô �7—=˜bÓ!€GÜÑE¡ÓEÓE€Kõ1ö EPÐ+¨K¨=¸Ñ@ÐUW€JÞ<C�—‘Ô8±Ó8Ô8ÐIv€Jð	Qô ˆ+�j�\ð "8Ø8B°|ð Dð		ðr&   ))ræ   r  )rç   r   )rè   r!  )rê   r"  )rë   r#  c                 ó6  • U =(       d    0 n U=(       d    / n/ n[          Hg  u  p4U R                  US5      (       + =(       d    X1;   nU(       a  SOSnU(       a  SOSnUR                  SU SU SU SU S	[        U5       S
35        Mi     SR	                  U5      $ )a�  The Run modal's per platform checkboxes. A platform switched off in
Settings ("Read this platform"), or hidden entirely from the
Integrations tab, does not show here at all (Steven 2026-08-26: "if i
set it up that i have hidden it or dont want it to autorun... it wont
even show up in the modal"). This SUPERSEDES the earlier same day
choice to show an off platform greyed out with an "Off in Settings"
note, his own newer instruction explicitly covering the not-autorun
case too, the Settings tabs themselves remain where that state is
visible and changed.

Every row is still RENDERED, just carrying the hidden attribute (plus
a disabled, unticked checkbox so confirmRun's own
:not(:disabled) selector already excludes it), never omitted from the
markup: cockpit.html is a static file, so openRunModal() re-syncs
these rows live from /api/settings every time the modal opens, and a
row that had been omitted outright could never reappear after an
unhide without a full re render. platform_enabled/hidden_connections
as None (an older cache, or a bare call that never passes them) read
as everything enabled and nothing hidden. Enforcement never depends on
this display either way: daily_run._select_platforms applies the real
persistent floor to every run, whatever this modal shows or sends.

Each row is a grid, not a plain label (2026-08-31): the status cells
are filled in by paintRunSummary from /api/run-summary when the modal
opens, never baked in here, because cockpit.html is a static file and
a render time age would be stale the moment a run finished. Only the
checkbox and the name sit inside the <label>, so clicking a failed
row's own Log in button cannot also toggle the tick.Tr½   r   z	 disabledú checkedz<div class="rmrow" data-site="r»   z]><div class="rmrow-main"><label class="rmpick"><input type="checkbox" class="rm-plat" value="z onchange="rmNote()"><span>r¿   rÀ   )Ú_RUN_MODAL_PLATFORMSrÄ   rÇ   rl   r   )rÉ   rÊ   rÍ   rS   r$   ÚoffÚhidden_attrÚ	box_attrss           r   rÈ   rÈ   ½	  s±   € ð: (×-¨2ÐØ+×1¨rÐØ€Dß*‰
ˆØ#×'Ñ'¨¨TÓ2Ô2×P°sÑ7PˆÞ#&‘i¨BˆÞ#&‘K¨Jˆ	Ø�‰Ø,¨S¨E°°;°-ð @SàSVÐRWÐWXØˆkÐ4´T¸%³[°Mð B;ð<ö	=ñ	 +ð ×Ñ˜TÓ"Ð"r&   c                 óú   • SR                  [        U R                  =(       d    S5      R                  5       R	                  5       5      nU(       d  gS[        [        U5      5       S[        U5       S[         S3$ )z’The registration shown as a yellow UK number plate, with a copy button so
Steven can paste the reg elsewhere. No reg shows a plain NO PLATE plate.r   zL<span class="hf-reg noplate"><span class="hf-reg-num">NO PLATE</span></span>z.<span class="hf-reg"><span class="hf-reg-num">z;</span><button class="hf-reg-copy" onclick="copyReg(event,'z=')" aria-label="Copy registration" title="Copy registration">z</button></span>)r   rj   rL  r>   r9   rl   rR  Ú	_COPY_SVG)r   Úraws     r   Ú
_reg_plater|  
  sx   € ð �'‰'”#�c—g‘g—m Ó$×*Ñ*Ó,×2Ñ2Ó4Ó
5€CÞØ]ð(Ü(,¬[¸Ó-=Ó(>Ð'?ð @DÜDHÈÃIÀ;ð OHÜHQÀ{ð Sðð r&   u§   Sold (\d+) of these before\. (?:Average (\d+) days to sell|Sold same day)(?:, prep (Â£[\d,]*\d))?(?:, (?:margin (Â£[\d,]*\d) after prep|lost (Â£[\d,]*\d) after prep))?z"Seen before within 30 days: (.+)\.uK   (Reserve|Trade value|AT Trade|Bid price) dropped (Â£[\d,]+) since last seenc           
      ó  • / nU R                    GHô  n[        R                  U5      nU(       aÅ  UR                  5       u  pEpgnU(       a  SOSn	UR	                  U	SSU SU(       a  SU-  OS 345        / n
U(       a  U
R	                  U S35        U(       a  U
R	                  S	U 35        OU(       a  U
R	                  U S
35        U
(       a#  UR	                  U	SSR                  U
5      45        Må  [        R                  U5      nU(       a)  UR	                  SSSUR                  S5       345        GM*  [        R                  U5      nU(       a;  UR	                  SSUR                  S5       SUR                  S5       S345        GM�  UR                  S5      (       a&  UR	                  SSUR                  S5      45        GM½  UR                  S5      (       aD  [        U R                  SS5      (       a  UR	                  S5        GM  UR	                  S5        GM  UR                  S5      (       a  UR	                  S5        GMA  UR                  S5      (       a8  UR	                  SSS [        U R                  R                  5       S!345        GM�  UR                  S"5      (       a  UR	                  S#5        GM¹  UR                  S$5      (       a)  UR	                  SS%UR                  S5      S&   45        GMø  UR                  S'5      (       a  UR	                  S(5        GM"  S)UR!                  5       ;   a˜  UR!                  5       nUR                  S*5      (       a  UR	                  S+5        GMp  UR                  S,5      (       a  SOSn	UR	                  U	S-UR                  S5      S&   R#                  S.S/5      S0U;   a  S1OS/-   45        GMÎ  UR	                  SS2UR                  S5      S3S4 45        GM÷     U$ )5aÛ  Map the assessment's flag lines onto the card's short single line
insight rows: (kind, topic, text). kind is the traffic light colour, red,
amber, green or info (neutral, neither good nor bad); topic picks which
icon represents it (Mark 2026-08-20: "give the icons relevant icons ie
service could be spanner, also use a traffic light system to flag view ie
red for no service history, green for full"). Copy is shortened to fit
one line, the detail stays on the listing view.ÚredÚgreenÚtagzSold u    before Â· z%s days avgzsame day avgz prepzlost z marginu    Â· ÚamberÚrepeatu   Seen within 30 days Â· r   Útrendz	 dropped r-   z since last seenúIn bothÚ.z#Glass's valuation could not be readÚglass_checkedF)Úinfor   u(   Glass's had no match Â· priced on Cazana)r‡  r   u0   Priced on Cazana Â· run Glass's checks to add itz"Cazana valuation could not be read)r�  r   u2   Cazana could not be read Â· priced on Glass's onlyzListing reg is a private plater‡  ÚplatezValued on original plate u    Â· details matchedz Valuation looks high against CAP)r~  rG  u1   Valuation looks high against CAP Â· check the carzKnown electrical issueÚ
electricalr   úVAT qualifying)r‡  ÚvatrŠ  zservice historyzfull service history)r  ÚservicezFull service historyzno service historyrŒ  z onlyr   zLower qualityu    Â· lower quality buyÚgenericNéF   )ÚflagsÚ_INSIGHT_SOLDr<   rR   rÇ   r   Ú_INSIGHT_SEENr   Ú_INSIGHT_DROPrY  ÚrstripÚgetattrr   rR  Úselling_vrmr9   r   rv   )ÚaÚoutrc   rF   r]  ÚdaysÚprepÚmarginÚlostÚkindÚmoneyrW  s               r   Ú_insight_rowsrž  
  sK  € ð €CØ�W�WˆÜ×Ñ Ó"ˆÞð +,¯(©(«*Ñ'ˆA�T 4Þ ‘5 gˆDØ�J‰J˜˜e u¨Q¨C¨{ÖSW¸=È4Ò;OÐ]kÐ:lÐ%mÐnÔoØˆEÞØ—‘ ˜v U˜^Ô,ÞØ—‘˜u T F˜^Õ,ÞØ—‘ ˜x wÐ/Ô0ÞØ—
‘
˜D %¨¯©°UÓ);Ð<Ô=ÙÜ×Ñ Ó"ˆÞØ�J‰J˜ Ð-DÀQÇWÁWÈQÃZÀLÐ+QÐRÔSÚÜ×Ñ Ó"ˆÞØ�J‰J˜ ¨Q¯W©W°Q«Z¨L¸	À!Ç'Á'È!Ã*ÀÐM]Ð*^Ð_Ô`ÚØ�<‰<˜	×"Ñ"Ø�J‰J˜ ¨1¯8©8°C«=Ð9×:Ø�\‰\Ð?×@Ñ@ô �q—u‘u˜o¨u×5Ñ5Ø—
‘
ÐY×Zà—
‘
Ða×bØ�\‰\Ð>×?Ñ?Ø�J‰JÐ`×aØ�\‰\Ð:×;Ñ;Ø�J‰J˜ Ð+DÄ[ÐQR×QVÑQV×QbÑQbÓEcÐDdÐdwÐ)xÐy×zØ�\‰\Ð<×=Ñ=Ø�J‰JÐ[×\Ø�\‰\Ð2×3Ñ3Ø�J‰J˜˜|¨Q¯W©W°S«\¸!©_Ð=×>Ø�\‰\Ð*×+Ñ+Ø�J‰JÐ8×9Ø !§'¡'£)Ó+ð
 —'‘'“)ˆCØ�~‰~Ð4×5Ñ5Ø—
‘
ÐG×Hà #§¡Ð/C× DÑ D‘uÈ'�Ø—
‘
˜D )¨Q¯W©W°S«\¸!©_×-DÑ-DÀWÈbÓ-QØ8GÈ1Ó8LÑ4ÐRTñ.Vð W÷ Xð �J‰J˜ ¨A¯H©H°S«M¸#¸2Ð,>Ð?×@ñy ðz €Jr&   c                 ó´  • U R                   UR                  UR                  pCn[        U R                  5      nSSSS.R                  US5      nSSSS.R                  US5      nUb  Ub  XB-
  OS
nUS
:”  a  Ub  X2-
  U-  S-  n	OSn	[        S[        U	S5      5      n
U	S:  nUS	L=(       a    US	L=(       a    X2:  nU(       a  S[        X#-
  5       SU S3nO-U(       a  S[        X2-
  5       SU S3nOS[        X2-
  5       S3nSU S[        U5       S[        U5       SU S[        UR                  5       S[        U5       S3n[        U SS	5      n[        U SS	5      n[        U SS5      n[        U SS5      n[        U S S5      nS!nS"nUb¦  US
:”  a
  Xò-
  U-  S-  OSn[        S[        US5      5      nUS#:”  a  S$O	US
:  a  S%OS!nU(       a  S&OS'nU(       a  S(OS!nU(       a  S)OS!nS*U S+U U U U(       a  S,U-   OS! 3nS-US. S/[        U5       S03nS1[        U5       S2[        U5       S3U S43nS5U
S. S6U(       a  S!OS7 S8U
S. S9U S:3	nS;U U U S<3$ )=ae  The price gauge (Mark 2026-08-26, "Card Density Mockups": one line
with Max bid promoted to the big figure, "it's the number you act on").
Reserve, Max bid (the prominent figure, with the room pill right next to
it) and Retail now sit in one fixed 3 column row, left/centre/right, above
a thin track with a dot at the max bid position; the old design floated
the max bid label directly above its own dot and had to juggle a
tight/normal/wide mode to stop that label colliding with the retail
figure depending where the dot landed. A fixed 3 column row never
collides regardless of where the dot sits, so that branching is gone;
tight is kept only for the track's own dot spacing (see track below).

Carwow no longer discloses a real reserve at all (2026-08-19), car.reserve
is CAP Clean standing in for it, so the label reads "Trade value" there
instead of "Reserve" to avoid presenting a valuation as if it were the
real reserve. Dealer Auction never discloses one either (2026-08-20),
car.reserve there is Retail minus Margin, labelled "AT Trade" (Mark's own
wording). DealerWay never discloses a numeric reserve either (checked,
2026-08-24, including the raw page for a hidden value the way
Auction4Cars turned out to have one, none found), car.reserve there is
the Current Bid (or Starting Price before any bid), labelled "Bid
price" so it is never mistaken for the real reserve threshold, which
could genuinely be higher, "Reserve Not Met" is shown on the listing
itself for exactly that reason. Motorway and Auction4Cars both carry a
genuine reserve (Auction4Cars' found 2026-08-20 in an inline script),
so they keep the real word.úTrade valueúAT Tradeú	Bid price©rç   rê   rë   ÚReserveztrade valuez	bid pricer  Nr   g      Y@g        é   z <span class="hf-roompill under">z under rñ   z<span class="hf-roompill">z over z<span class="hf-roompill">+z room</span>za
        <div class="hf-gauge-row">
          <div class="hf-glab plain"><span class="hf-klabel">z.</span>
            <span class="hf-gnum dim">z‡</span></div>
          <div class="hf-glab plain mid"><span class="hf-klabel">Max bid</span>
            <span class="hf-gnum strong">uW   </div>
          <div class="hf-glab plain end"><span class="hf-klabel">Retails for Â· z,</span>
            <span class="hf-retail">z</span></div>
        </div>Ú_suggested_bidÚ_suggested_bid_nÚ_suggested_bid_llkFÚ_suggested_bid_ratioÚ_suggested_bid_cappedr   z6<div class="hf-suggest-line" aria-hidden="true"></div>éd   z"beyond this car's own retail valuezbelow reservez=closely matched on mileage, year, transmission or engine sizezsame make and modelz', scaled to this car's own retail valuezq, capped at this car's own retail value, some of the real sales it is based on look high against their own retailz	Based on z& real BidBrain sales of similar cars, r(  z)<span class="hf-dot suggest" style="left:z.1fz
%" title="ú	"></span>z$<div class="hf-suggest-line" title="zQ"><span class="hf-klabel">AI suggests to win</span><span class="hf-gnum suggest">z"</span><span class="hf-suggest-n">z similar sales</span></div>zL
        <div class="hf-track">
          <div class="hf-band" style="width:z%"></div>
          z,<span class="hf-dot" style="left:0%"></span>z3
          <span class="hf-dot filled" style="left:z%"></span>
          zL
          <span class="hf-dot end" style="left:100%"></span>
        </div>z<div class="hf-gauge">rš   )r  r  Úgoverning_valuerï   rí   rÄ   ÚmaxÚminr   rl   Úgoverning_sourcer”  )r   r0  r  ÚmxÚretailÚplatÚrlabelÚrwordÚspanÚmidÚbandÚtightÚunderÚroomÚrowÚsuggÚsugg_nÚsugg_llkÚ
sugg_ratioÚsugg_cappedÚsugg_dotÚ	sugg_lineÚsugg_midÚ	sugg_bandÚbeyondÚbasisÚscalingÚcapped_noteÚ
sugg_titleÚtracks                                 r   Ú	_hf_gaugerÌ  c
  s9  € ð4 Ÿ+™+ q§y¡y°!×2CÑ2C�€GÜ˜3Ÿ:™:Ó&€DØ%¸
Ø&ñ(ß(+©¨D°)Ó(<ð à$°zØ%ñ'ß'*¡s¨4°Ó';ð 
à"(Ñ"4¸Ñ9LˆFÒÐST€DØˆaƒx�B‘NØ‰|˜tÑ# eÑ+‰àˆÜˆs”C˜˜U“OÓ$€DØ�"‰H€EØ�dˆN×C˜w¨dÐ2×C°r±|€EÞØ1´&¸¹Ó2FÐ1GÀwÈuÈgÐU\Ð]‰Þ	Ø+¬F°2±<Ó,@Ð+AÀÈÀwÈgÐV‰à,¬V°B±LÓ-AÐ,BÀ,ÐOˆð>à>D¸Xð F'Ü'-¨g£Ð&7ð 8*ä*0°«*¨°W¸T¸Fð CQÜQUÐVW×VhÑVhÓQiÐPjð k%Ü%+¨F£^Ð$4ð 5ð€Cô, �3Ð(¨$Ó/€DÜ�SÐ,¨dÓ3€FÜ�sÐ0°%Ó8€HÜ˜Ð4°eÓ<€JÜ˜#Ð6¸Ó>€KØ€Hð I€IØÑØ6:¸Q³h�D‘N dÑ*¨UÒ2ÀCˆÜ˜œS ¨5Ó1Ó2ˆ	Ø9AÀC»Ñ5Ø'¨!›|‰O°ð 	ö ñ QØ#8ð 	æ@JÑ<ÐPRˆö 'ñRà,.ð 	ð " & Ð)OØ˜ ˜y¨¨Æv°d¸V²mÐSUÐ5VðXˆ
à>¸yÈ¸oÈZÔX\Ð]gÓXhÐWiÐirÐsˆØ;¼DÀÓ<LÐ;Mð N7ä7=¸d³|°nð E4Ø4:°8Ð;VðXˆ	ð-à-1°#¨Jð 7Þ‰2ÐJÐ
Kð L3Ø37¸°*ð =Øˆ*ð ð€Eð $ C 5¨¨°	¨{¸&ÐAÐAr&   zÜ<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg>a=  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.3"></circle><line x1="12" y1="11" x2="12" y2="16.5"></line><circle cx="12" cy="7.6" r=".4" fill="currentColor" stroke="none"></circle></svg>zð<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>zß<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="2"></circle><circle cx="12" cy="12" r="2"></circle><circle cx="19" cy="12" r="2"></circle></svg>zÐ<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="M6 6l12 12"></path></svg>z¹<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg>z·<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg>zä<svg class="ext-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"></path><path d="M7 7h10v10"></path></svg>c                 ó   • SU  S3$ )z•A small feather style icon for a card insight row, matching the other
inline icons in this file (COPY, BELL, WRENCH). Sized for the 22px flag
circle.z§<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">z</svg>r   )rö   s    r   Ú	_flag_svgrÎ     s   € ðdØdiÐcjÐjpðrð sr&   zª<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>zD<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>zj<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"></polyline><polyline points="17 18 23 18 23 12"></polyline>z�<path d="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7.01" y2="7"></line>zZ<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>zc<rect x="1" y="5" width="22" height="14" rx="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line>zÆ<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>zC<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>zƒ<line x1="19" y1="5" x2="5" y2="19"></line><circle cx="6.5" cy="6.5" r="2.3"></circle><circle cx="17.5" cy="17.5" r="2.3"></circle>zƒ<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line>)
rŒ  r‚  rƒ  r€  r   rˆ  rG  r‰  r‹  r�  é   c                 ó  • U R                   U R                  p2[        UR                  5      nSSSS.R	                  US5      n[        [        USS5      =(       d    / 5      nUR                  (       a,  UR                  U;  a  UR                  SUR                  5        [        US	S5      =(       d    0 n0 S
UR                  _S[        UR                  5      _SUR                  =(       d    S SUR                   SUR                   3R                  5       _SUR                  _SUR                  _SU_SUR                   _SU_SUR                  _SUR"                  _SUR$                  _SUR&                  _S[)        U5      _SUR*                  _SUR,                  _SUR.                  _SUR0                  _0 SUR2                  _SUR4                  _SUR6                  _S [9        UR:                  5      _S!UR<                  _S"[        US"S5      =(       d    S_S#UR>                  _S$U_S%UR@                  _S&Uc  SOgURB                  URD                  URF                  URH                  URJ                  URL                  URN                  URJ                  URN                  -
  S'._S(URP                  _S)[9        URR                  5      _S*U VV	V
s/ s H
  u  p‰o¨XšS+.PM     sn
n	n_S,[        US-S5      _S.[9        [        US/S05      5      _S1[9        [        US2S05      5      _S3[        US4S5      _E[        US5S5      [        US6S5      c  SOmURT                  [        US7S5      [9        [        US8S05      5      [9        [        US9S05      5      [9        [        US:S05      5      [        US;/ 5      =(       d    / S<.UR	                  S=0 5      UR	                  S>/ 5      UR	                  S?/ 5      UR	                  S@5      SA.SB.E$ s  sn
n	nf )Caò  Everything the cockpit's car record drawer shows for one car
(Steven 2026-09-02, Step 5 of the review), as one plain dict the card
carries in a data attribute. Nothing here is new information: the
facts, the pricing breakdown, the flags (the same rows the Details
pop-up drew), CompMatch's suggestion with the comparable sales it
used, and the history the repeat and reserve drop flags were built
from, all already computed by the run or by _write_pages. An unread
figure stays None, never a guess.r   r¡  r¢  r£  r¤  Ú
photo_urlsNr   Ú_historyrL  Ú
pretty_regrb   r   r   r  rí   r  Úlisting_urlÚphotosr  Úmileager  rý   r   Úgearboxr"   Ú	body_typeÚservice_historyÚkeeper_startr  Údistance_milesÚvat_qualifyingÚends_atr•  r  Úreserve_labelÚ	cap_cleanÚpricing)Úglass_retailÚcazana_retailÚ
calc_glassÚcalc_cazanar­  r°  r  ÚspreadÚ
cazana_urlr†  r�  )rœ  ÚtopicÚtextÚ	pick_rankÚ
_pick_rankÚis_newÚ_is_newFÚtasteÚ_taste_matchÚstock_levelÚ_stock_levelÚ_stock_countr¦  r§  r¨  r©  rª  Ú_suggested_bid_comps)Ú	suggestedr]  Úlike_for_likeÚratio_basedÚcappedÚcompsÚpreviousÚsame_dayÚreservesÚsold)rø  rù  rú  rû  )Ústock_countr³   Úhistory)+r   rà  rï   rí   rÄ   rà   r”  rþ   ÚinsertrL  rR  r  r@   rA   r6   r  rÔ  rÖ  r  rý   r%   r<  r"   rØ  rÙ  rÚ  r  rÛ  ÚboolrÜ  Úauction_ends_atr  rß  rá  râ  rã  rä  r­  r°  r  ræ  r†  r¦  )r–  rÍ   r   r0  r³  r´  ÚgalleryÚhistrV  r7   r   s              r   Ú_drawer_datar  (  sR  € ð �U‰U�A—I‘IˆÜ˜3Ÿ:™:Ó&€DØ%¸
Ø&ñ(ß(+©¨D°)Ó(<ð ä”7˜3 ¨dÓ3×9°rÓ:€GØ
‡}‡}˜Ÿ™¨gÓ5Ø�‰�q˜#Ÿ-™-Ô(Ü�3˜
 DÓ)×/¨R€DðØˆs�w‰wðØ$¤k°#·'±'Ó&:ðØ<BÀsÇxÁxÇ~ÐSUÐFVÐVWÐX[×X`ÑX`ÐWaÐabÐcf×clÑclÐbmÐDn×DtÑDtÓDvðà�c—n‘nðà&.°·
±
ðà<FÈðð 	�s—‘ðð )1°'ðð 	�—‘ð	ð $ S§[¡[ð	ð 3;¸C¿J¹Jð	ð IPÐQT×QZÑQZð	ð
 	”- Ó$ðð
 '0°×1AÑ1Aðð
 DJÈ3Ï8É8ðð 	�S—]‘]ðð %6°s×7JÑ7Jñð 	˜×(Ñ(ðð +5°c·l±lðð 	˜#×,Ñ,ðð /?ÄÀS×EWÑEWÓ@Xðð 	�3×&Ñ&ðð )6´w¸sÀMÐSUÓ7V×7\ÐZ\ðð 	�3—;‘;ðð !0°ðð :EÀcÇmÁmðð 	˜1™9‘4ØŸN™N¸Q¿_¹_ØŸ,™,°q·}±}Ø ×0Ñ0Àa×FXÑFXØ—y‘y¨A×,=Ñ,=ÀÇ	Á	Ñ,Iñ	+Kðð  	�c—n‘nð!ð  '6´t¸C×<MÑ<MÓ7Nð!ð" 	ÁDÕIÂD¹¸¸q qÔ4ÁDÓIð#ð$ 	”W˜S ,°Ó5ð%ð$ 8@ÄÄgÈcÐS\Ð^cÓFdÓAeð%ð& 	””g˜c >°5Ó9Ó:ð'ð( 	”w˜s N°DÓ9ñ)ô( KRÐRUÐWeÐgkÓJlÜ$ SÐ*:¸DÓAÑI‘TØ×+Ñ+´'¸#Ð?QÐSWÓ2XÜ!¤'¨#Ð/CÀUÓ"KÓLÜ¤¨Ð-CÀUÓ KÓLÜœ7 3Ð(?ÀÓGÓHÜ˜SÐ"8¸"Ó=×CÀñPEð !%§¡¨°RÓ 8ÀdÇhÁhÈzÐ[]ÓF^Ø $§¡¨°RÓ 8À$Ç(Á(È6ÓBRñTò7ð ùô" Js   ËPc                 ó  ^.^/• U=(       d
    [        5       nU=(       d    0 nU R                  U R                  sm/nUR                  T/R                  T/R                  5      nSSKJn  UR                  UT/R                  [        T/SS 5      5      n[        S U R                   5       5      nUR                  b&  T/R                  b  UR                  T/R                  -
  OSnUR                  b&  T/R                  b  UR                  T/R                  -
  OUn	UR                  b  UR                  OUR                  n
[!        T/5      nSR#                  S T/R                  T/R                  T/R$                  T/R&                  4 5       5      R)                  5       n[        T/SS 5      n[        T/S	S 5      n[+        [        T/S
S 5      =(       d    / 5      nT/R,                  (       aŸ  S[/        T/R,                  5       S[/        T/R                  5       S[/        T/R$                  5       S3[1        U5      S:”  a#  S[/        [2        R4                  " U5      5       S3OS-   S-   n[1        U5      S:”  a  US[1        U5       S3-  nOSnSU(       a  SOS SU(       a  SOS SU(       a  SOS S[6         S3	n/ n[        T/SS 5      (       a&  UR9                  S [:         S!T/R<                   S"35        [        T/S#S$5      (       a  UR9                  S%5        T/R>                  (       a(  UR9                  S&[/        T/R>                  5       S'35        [        T/S(S$5      (       a  UR9                  S)5        U(       a’  T/R                  =(       d    S ST/R$                  =(       d    S 3RA                  5       =(       d    S*nUS:X  a  S+U S,3OU SU S,3nSS-S.S/.R                  US5      nUR9                  S0U S1[/        U5       S2U S335        U(       a  UR9                  S45        U(       a  S5SR#                  U5       S63OSnS7U S8U S9U S:[/        U5       S;[C        T/5       S<3n/ nT/RD                  (       a(  UR9                  S=[/        T/RD                  5       S>35        T/RF                  b   UR9                  S=T/RF                  S? S@35        [I        T/5      nU(       a  UR9                  SA[/        U5       S>35        [K        T/5      nU(       a  UR9                  SB[/        U5       S>35        T/RL                  b4  UR9                  S=T/RL                   SCT/RL                  S:w  a  SDOS S>35        T/RN                  b!  UR9                  S=T/RN                  SE SF35        O9T/RP                  (       a(  UR9                  S=[/        T/RP                  5       S>35        T/RR                  bq  [T        RV                  " SG[Y        T/RR                  5      5      nU(       a  SHUR[                  5        3OSnUR9                  SIU SJ[/        T/RR                  5       S>35        SKSR#                  U5       S63n[]        U 5      nSR#                  SL U 5       5      nSMUR^                  4SNUR`                  4SOURb                  4SPUR                  4SQT/Rd                  44 V V!s/ s H  u  n n!U!c  M  U U!4PM     n"n n!Sn#U"(       a\  U/4SR jm.SR#                  U.4SS jU" 5       5      n$T/Rf                  (       a   ST[/        T/Rf                  5       SU[h         SV3OSn%SWU$ U% S63n#U(       a  U#(       a  U SXU# 3n&OU(       d  U#(       a  UU#-   n&OSYn&SZ[j         S[[1        U5       S\3n'S][l         S^U& S_3n(T/Rn                  (       d  S`n)OÇ[q        T/Rr                  5      n*[t        [v        [x        [z        [|        Sa.R                  U*5      n+U+(       a  SbU* Sc[/        T/Rr                  5       Sd3n,O"Se[/        T/Rr                  =(       d    Sf5       S>3n,Sg[/        T/Rn                  5       Sh[/        T/Rr                  =(       d    Sf5       S2U, [h         SV3n)Si[~         Sj3n-SR#                  / SkP[/        T/R                  5       PSlP[/        U5       PSmP[/        T/R$                  5       PSnP[/        [�        T/R                  T/R$                  5      5       PSoP[/        U5       PSpPUR                  b  UR                  OS PSqPUR                  b  UR                  OS PSrPT/R                  b  T/R                  OS PSsP[q        T/Rr                  5       PStPU PSuPU	 PSvPU
 PSwP[/        U5       PSxPU=(       d    S PSyPUb  UOS PSzP[/        T/RD                  5       PS{PT/RF                  b  T/RF                  OS PS|P[/        T/RR                  5       PS}P[/        T/R‚                  5       PS~P[/        T/R„                  5       PSPT/RL                  b  T/RL                  OS PS€P[/        T/R†                  =(       d    SRA                  5       R)                  5       5       PS�PT/RN                  b  [‰        T/RN                  5      OS PS‚P[/        T/RP                  5       PSƒP[‹        T/5       PS„P[/        T/RŒ                  5       PS…P[/        T/R>                  5       PS†PT/Rd                  b  T/Rd                  OS PS‡P[        T/SˆS5       PS‰P[K        T/5      (       a  SOS PSŠP[/        T/Rn                  5       PS‹P[        T/SS 5      =(       d    S PSŒP[        T/S�S5      =(       d    S PSŽP[        T/S#S$5      (       a  SOS PS�P[/        [2        R4                  " [�        U U5      5      5       PS�PU PS‘PU PS’P[‘        T/U5       PS“PU' PS”PU) PS”PU- PS•PU( PS–P5      $ s  sn!n f )—Nr   ©Údbrí   c              3   ór   #   • U  H-  oR                  S 5      =(       d    UR                  S5      v •  M/     g7f)zSeen beforer„  Nra  ©r   rc   s     r   r   Ú"_shortlist_card.<locals>.<genexpr>`  s+   é € Ð^ÒV]ÐQR—l‘l =Ó1×L°Q·\±\À)Ó5LÔLÒV]ùs   ‚57r   r   c              3   óH   #   • U  H  o(       d  M  [        U5      v •  M     g 7fr   ©rj   r   s     r   r   r	  r  ó   é € ÐZÒ&T ÓXY“f”c˜!—f�fÒ&Tùó   ‚
"�"rð  rñ  rÑ  rù   rú   z" loading="lazy"z' class="hf-gallery-open" data-gallery='z' onclick="openGallery(this)"r   Ú>z<span class="hf-photocount">z photos</span>z,<span class="hf-nophoto">No photo yet</span>z<button class="starbtn hf-starú onz" aria-pressed="ÚtrueÚfalsez#" onclick="toggleBid(this)" title="zOn your shortlistzAdd to my shortlistz" aria-label="Watch">ú	</button>rê  z0<span class="hf-gap rank" title="One of today's zK top picks, ranked by room, stock gap, your taste, CompMatch and newness">#z pick</span>rì  Fzc<span class="hf-gap new" title="Not seen on any auction house in the last 30 days">New today</span>z'<span class="hf-gap ends" data-endsat="z">Ends soon</span>rî  z2<span class="hf-gap taste">Your kind of car</span>z
this modelzNo z in your DealerKit stock listz okz many)ÚgaprF  Útoo_manyz<span class="hf-gapú	" title="rò   z in stock</span>z,<span class="hf-gap seen">Seen before</span>z<div class="hf-badges">rš   z&
      <div class="hf-photo">
        z4
        <div class="hf-photo-scrim"></div>
        rÀ   zH
        <div class="hf-photofoot">
          <h3 class="hf-phototitle">z</h3>
          z
        </div>
      </div>z<span class="hf-chip">rñ   Ú,z
 mi</span>z%<span class="hf-chip hf-chip-engine">z#<span class="hf-chip hf-chip-auto">z ownerrî   Úgz mi away</span>z\dz gz"<span class="hf-chip hf-chip-gradez">Grade z<div class="hf-chips">c           	   3   ó`   #   • U  H$  u  pnS U S[         U    S[        U5       S3v •  M&     g7f)z<div class="hf-flag-full z"><span class="hf-flag-ic">z</span><span>ú</span></div>N)Ú_FLAG_ICONSrl   )r   rV  rç  r7   s       r   r   r	  ò  sA   é € ð !â‰KˆA�að $ A 3Ð&AÄ+ÈeÑBTÐAUÐUbÔcgÐhiÓcjÐbkÐkxÕyÚùs   ‚,.úGlass'szGlass's +15ÚCazanaz
Cazana +15ú	CAP Cleanc                 óš   >• U S:X  a6  TR                   (       a%  S[        TR                   5       S[        U 5       S3$ S[        U 5       S3$ )Nr  z	<a href="z�" target="_blank" rel="noopener" class="hf-val-link" title="Open this car's own Cazana lookup" onclick="event.stopPropagation()">re  z<em>z</em>)ræ  rl   )Úlabr   s    €r   Ú
_val_labelÚ#_shortlist_card.<locals>._val_label  sR   ø€ Ø�h‹ 3§>§>Ø#¤D¨¯©Ó$8Ð#9ð :=ä=AÀ#»Y¸KÀtðMð Nð œ$˜s›)˜ EÐ*Ð*r&   c              3   óV   >#   • U  H  u  pS T" U5       [        U5       S3v •  M      g7f)z<span class="hf-val">rñ   N)r   )r   r  rV   r   s      €r   r   r	    s.   øé € ÐiÒdhÑZ`ÐZ]Ð1±*¸S³/Ð1BÄ6È!Ã9À+ÈWÕUÒdhùs   ƒ&)z<a class="hf-cazana-btn" href="zU" target="_blank" rel="noopener" onclick="event.stopPropagation()">Open Cazana lookupre  z<div class="hf-vals-grid">z<div class="hf-pop-div"></div>z@<div class="hf-pop-empty">Nothing more to add on this car.</div>z`<button type="button" class="hf-details-btn" onclick="openDetails(this)" aria-haspopup="dialog">z/<span>Details</span><span class="hf-details-n">r   zý<div class="hf-detailpop" role="dialog" aria-label="What we know about this car"><div class="hf-pop-arrow"></div><div class="hf-pop-head"><span>What we know</span><button type="button" class="hf-pop-close" onclick="closeAllDetails()" aria-label="Close">z*</button></div><div class="hf-pop-scroll">rl  z4<span class="hf-cta disabled">No listing link</span>r  z<span class="platlogo z" role="img" aria-label="r¬  ú<span>r  z<a class="hf-cta" href="z5" target="_blank" rel="noopener" aria-label="View on z£<div class="hf-more"><button class="hf-more-btn" type="button" onclick="toggleCardMenu(this)" aria-label="More actions" aria-haspopup="true" aria-expanded="false">z�</button><div class="hf-more-menu" hidden><button type="button" class="hf-more-item" onclick="openHide(this)">Hide this car</button></div></div>z-
    <article class="card hf-card" data-reg="ú" data-make="ú" data-model="ú"
      data-modelfamily="z"
      data-name="z" data-maxbid="z"
      data-governing="ú"
      data-reserve="ú" data-platform="z"
      data-headroom="z" data-cazanaheadroom="z" data-cazanaprice="z"
      data-search="z" data-stock="ú"
      data-stockcount="z"
      data-year="z" data-mileage="ú"
      data-grade="ú" data-fuel="ú" data-bodytype="ú"
      data-owners="ú"
      data-servicehistory="ú"
      data-distance="ú"
      data-location="ú" data-enginelitres="ú"
      data-transmission="z" data-endsat="ú"
      data-cap="z" data-taste="Ú_taste_scoreú"
      data-auto="z" data-listingurl="z"
      data-pickrank="z" data-pickscore="Ú_pick_scorez"
      data-isnew="z"
      data-drawer="z-">
      <div class="hf-card-inner">
        z)
        <div class="hf-body">
          z
          z8
          <div class="hf-utility-actions">
            ú
            a¢  
          </div>
          <div class="hidebox" hidden>
            <input class="hidereason" type="text" placeholder="Why would you not buy this car">
            <div class="hidebtns">
              <button class="confirm" onclick="confirmHide(this)">Send</button>
              <button class="cancel" onclick="cancelHide(this)">Cancel</button>
            </div>
          </div>
        </div>
      </div>
      z
    </article>
    )IrM   r   rà  rÄ   r@   r   r  ÚstarredrL  r”  r?   r�  r  r  rä  r­  r
  r   rA   r  r   rà   rþ   rl   r:   rÅ   rÆ   Ú_HF_STAR_SVGrÇ   ÚTOP_PICKS_LABELrê  r   r6   r|  r  rÖ  r%   r=  r  rÛ  r  rý   r   r   rj   r   rž  rá  rã  râ  rß  ræ  Ú_EXTERNAL_LINK_SVGÚ_HF_INFO_SVGÚ_HF_POPCLOSE_SVGrÔ  rï   rí   ÚMW_LOGO_WHITEÚCW_LOGO_WHITErõ   ÚDA_LOGO_WHITEÚDW_LOGO_WHITEÚ_HF_MORE_SVGrI   r"   rØ  rÙ  Úintr  r<  r  rÌ  )0r–  ÚbidsetÚ
make_canonr0  Úfilter_makeÚ_dbr8  Úseen_beforeÚheadroomÚcazana_headroomÚcazana_pricerb   r   rï  rü  r  rû   ÚstarÚbadgesÚsrcÚnoterÕ   Úbadges_htmlÚheaderÚchipsÚengine_labelr×  ÚgmÚgclsÚ
chips_htmlrÍ   Úinsights_fullr  rV   ÚvalsÚvals_grid_htmlÚchips_vÚ
cazana_btnÚpop_bodyÚdetails_btn_htmlÚdetails_pop_htmlÚctar³  Ú
_plat_logor$   Ú	more_htmlr   r   s0                                                 @@r   Ú_shortlist_cardrb  Y  sÀ  ù€ Ø�_”s“u€FØ×!˜r€JØ�U‰U�A—I‘I€F€CˆØ—.‘. §¡¨3¯8©8Ó4€KÝØ�k‰k˜& #§'¡'¬7°3¸À$Ó+GÓH€GÜÑ^ÐVW×V]ÒV]Ó^Ó^€KØ-.¯Y©YÑ-BÀsÇ{Á{ÑG^�—	‘	˜CŸK™KÒ'Ðef€Hð ŸM™MÑ5¸#¿+¹+Ñ:Qð Ÿ™¨¯©Ò3ØX`ð ð
 %&§M¡MÑ$=�1—=’=À1×CTÑCT€LÜ�#‹;€DØ�X‰XÑZ s§w¡w°·±¸#¿)¹)ÀSÇ^Á^Ñ&TÓZÓZ×`Ñ`Ób€FÜ˜#˜~¨tÓ4€Kô ˜#˜~¨tÓ4€Kô ”7˜3 ¨dÓ3×9°rÓ:€GØ
‡}‡}Øœd 3§=¡=Ó1Ð2°'¼$¸s¿x¹x».Ð9IÈÌ4ÐPS×PYÑPYË?ÐJ[Ð[kÐlä7:¸7³|ÀaÓ7Gð ?¼tÄDÇJÂJÈwÓDWÓ?XÐ>Yð Z3ñ 4ØMOñQð ñˆô ˆw‹<˜!ÓØÐ3´C¸³L°>ÀÐPÑPˆEøà>ˆØ,¶g©UÀ2Ð,Fð GÞ'.™V°GÐ<ð =Þ-4Ñ)Ð:OÐPð Q"Ü". ¨yð:€Dð €Fô ˆs�L $×'Ñ'Ø�‰ÐIÌ/ÐIZð [XØX[×XfÑXfÐWgÐgsðuô 	väˆs�I˜u×%Ñ%Ø�‰Ð{Ô|Ø
××ð
 	�‰Ð?ÄÀS×EXÑEXÓ@YÐ?ZÐZlÐmÔnÜˆs�N E×*Ñ*Ø�‰ÐJÔKö Ø—‘—˜RÐ   #§)¡)§/¨rÐ!2Ð3×9Ñ9Ó;×K¸|ˆØ<GÈ1Ó<L�#�c�UÐ7Ñ8Ø#�} A c UÐ*GÐHð 	à °7Ñ;×?Ñ?ÀÈRÓPˆØ�‰Ð+¨C¨5°	¼$¸t»*¸ÀRØ$˜Ð%5ð7ô 	8æØ�‰ÐDÔEÞGMÐ+¨B¯G©G°F«OÐ+<¸FÑCÐSU€Kð	à	ˆð 	à	ˆð 	Ø	ˆð %ä%)¨$£Z Lð 1Ü�c‹?Ð
ð ð
€Fð €EØ
‡x‡xØ�‰Ð-¬d°3·8±8«nÐ-=¸WÐEÔFØ
‡{�{ÑØ�‰Ð-¨c¯k©k¸!¨_¸JÐGÔHÜ  Ó%€LÞØ�‰Ð<¼TÀ,Ó=OÐ<PÐPWÐXÔYÜ˜SÓ!€GÞØ�‰Ð:¼4À»=¸/ÈÐQÔRØ
‡z�zÑØ�‰Ð-¨c¯j©j¨\¸ÀsÇzÁzÐUVÃÁÐ\^Ð?_Ð_fÐgÔhØ
×ÑÑ%Ø�‰Ð-¨c×.@Ñ.@ÀÐ-CÀ?ÐSÕTØ	��Ø�‰Ð-¬d°3·<±<Ó.@Ð-AÀÐIÔJØ
‡y�yÑÜ�YŠY�uœc #§)¡)›nÓ-ˆÞ$&��B—H‘H“J�<Ñ ¨BˆØ�‰Ð9¸$¸¸xÌÈSÏYÉYËÐGXÐX_Ð`ÔaØ)¨"¯'©'°%«.Ð)9¸Ð@€Jô ˜Ó€DØ—G‘Gñ !áó!ó !€Mð 
�A—N‘NÐ# m°Q·\±\Ð%BØ	�1—?‘?Ð# l°A·M±MÐ%BØ	�c—m‘mÐ$ñ$&ô 8ò $&™˜˜að *+ó ˆS�!‹Hñ $&€Dñ 8ð €NÞõ	+ð —'‘'ÔiÑdhÓiÓiˆð ŸŸð 8¼¸S¿^¹^Ó8LÐ7Mð N\Ü\nÐ[oÐosñuà-/ð 	ð 6°g°Y¸z¸lÈ&ÐQˆö
 žØ#�_Ð$BÀ>ÐBRÐS‰Þ	ž.Ø  >Ñ1‰àUˆð"äˆ.ÐGÌÈDË	À{ÐRbð	dð ðô /Ð/ð 0&Ø&. Zð 0ð	ð ð �?�?ØD‰ä˜sŸz™zÓ*ˆÜ"/¼=ÔZbÜ'4Ä=ñRßRUÑRUÐVZÓR[ð 	æØ,¨T¨FÐ2KÌDÐQT×Q[ÑQ[ÓL\ÐK]Ð]fÐg‰EàœT #§*¡*×":°
Ó;Ð<¸GÐDˆEØ)¬$¨s¯©Ó*?Ð)@ð A&Ü&*¨3¯:©:×+C¸Ó&DÐ%EÀRÈÀwÔOaÐNbÐbfðhˆðYäYeÐXfð g ð!€I÷*ó *ð -ð *Ü-1°#·'±'«]¨Oð*Ø;Hð*ÜIMÈkÓIZÐH[ð*Ø[ið*ÜjnÐor×oxÑoxÓjyÐizð*ð{ð*äœm¨C¯H©H°c·i±iÓ@ÓAÐBð*ðCð*ô �t“*�ð*ð -ð*ð ;<¿)¹)Ñ:O¨Q¯YªYÐUWÐ,Xð*ðYð*ð -.×,=Ñ,=Ñ,I�q×(Ò(ÈrÐRð	*ðSð	*ð
 %(§K¡KÑ$;�S—[’[ÀÐDð*ð
 EVð*ô
 WfÐfi×fpÑfpÓVqÐUrð*ð
sð*ð �Zð*ð  7ð*ð 8GÐ6Gð*ð H\ð*ð ]iÐ[ið*ðjð*ô ˜“L�>ð*ð "0ð*ð 1<×0A¸rÐ/Bð*ðCð*ð (3Ñ'>™ÀBÐGð*ðHð*ô �s—x‘x“.Ð!ð*ð "2ð*ð BEÇÁÑAX°#·+²+Ð^`Ð1að*ðbð*ô ˜Ÿ	™	“?Ð#ð*ð $1ð*ô 26°c·h±h³Ð0@ð*ð ARð*ô SWÐWZ×WdÑWdÓReÐQfð*ðgð*ð #&§*¡*Ñ"8�C—J’J¸bÐAð*ðBð*ô ! #×"5Ñ"5×";¸×!BÑ!BÓ!D×!JÑ!JÓ!LÓMÐNð*ðOð*ð 25×1CÑ1CÑ1O”c˜#×,Ñ,Ô-ÐUWÐXð*ðYð*ô ˜3Ÿ<™<Ó(Ð)ð*ð *?ð*ô @NÈcÓ?RÐ>Sð*ðTð*ô ˜s×/Ñ/Ó0Ð1ð*ð 2Að*ô BFÀc×FYÑFYÓAZÐ@[ð*ð\ð*ð  #&§-¡-Ñ";�—’ÀÐDð!*ð  ESð!*ô  T[Ð[^Ð`nÐpqÓSrÐRsð!*ð tð!*ô" & c×*Ñ*‘!°Ð2ð#*ð" 3Fð#*ô" GKÈ3Ï?É?ÓF[ÐE\ð#*ð"]ð#*ô$ ˜c <°Ó6×<¸"Ð=ð%*ð$ >Pð%*ô$ QXÐX[Ð]jÐlmÓPn×PsÐrsÐOtð%*ð$uð%*ô&    Y°×6Ñ6‘1¸AÐ>ð'*ð&?ð'*ô( œŸš¤L°°DÓ$9Ó:Ó;Ð<ð)*ð(=	ð)*ð, 
ˆð-*ð,ð-*ð0 ˆ,ð1*ð0ð1*ô2 �S˜!ÓÐ
ð3*ð2ð3*ð6 Ðð7*ð6ð7*ð8 ˆEð9*ð8ð9*ð: ˆKð;*ð:ð;*ðP ÐðQ*ðPôQ*ð *ùók8s   Û
pÛpc           	      óž  • U R                   nU=(       d    0 nUR                  UR                  UR                  5      nSR                  S U R                   5       5      nSR                  S U R
                   5       5      nU(       a  SU S3OSnSR                  S UR                  UR                  UR                  UR                  4 5       5      R                  5       nSn	US:X  aO  UR                  (       a>  S	[        UR                  5       S
[        UR                  =(       d    S5       [         S3n	SR                  / SPU PSP[        UR                  5       PSP[        U5       PSP[        U5       PSP[        UR                  5       PSP[        UR                  5       PSPUR                   b  UR                   OS PSP[        [#        UR                  UR                  5      5       PSP[        UR$                  5       PSP[        UR&                  5       PSP[        UR(                  5       PSP[        UR*                  5       PSPUR,                  b  UR,                  OS PSPUR.                  b  UR.                  OS PSP[        UR0                  =(       d    SR3                  5       R                  5       5       PSPUR4                  b  [7        UR4                  5      OS PSP[        UR8                  5       PSP[;        U5       PSPUR<                  b  UR<                  OS PS P[?        US!S 5      b  [?        US!S 5      OS PS"P[A        U5      (       a  S#OS$ PS%PUS:X  a  S&OS PS'P[        [C        U5      5       PS(P[        UR                  5      =(       d    S) PS*PU PS+PU PS,PU	 PS-P5      $ ).Nr   c              3   ó@   #   • U  H  nS [        U5       S3v •  M     g7f©z<li>z</li>NrC  )r   rQ  s     r   r   Ú_plain_card.<locals>.<genexpr>€  s   é € Ð?²Y°˜œT !›W˜I UÕ+²Yùó   ‚c              3   ó@   #   • U  H  nS [        U5       S3v •  M     g7fre  rC  r  s     r   r   rf  �  s   é € Ð;²7¨a�dœ4 ›7˜) 5Õ)²7ùrg  z<ul class="flags">z</ul>r   c              3   óH   #   • U  H  o(       d  M  [        U5      v •  M     g 7fr   r  r   s     r   r   rf  ƒ  r  r  Úheldz<a class="viewbtn" href="z)" target="_blank" rel="noopener">View on r  re  z
    <article class="card slim r(  ú" data-search="z"
      data-make="r%  z" data-year="z"
      data-mileage="r&  r2  r,  r*  r+  r3  r-  r.  r/  r0  r1  r'  r)  rñ  r5  r   r   r»   z data-held="1"z:>
      <div class="body">
        <header>
          <h2>z"</h2>
          <span class="reg">úno platez6</span>
        </header>
        <ul class="reasons">z</ul>
        rÀ   z!
      </div>
    </article>
    )"r   rÄ   r@   r   Úreasonsr�  rL  rA   r  r   rÔ  rl   rí   r;  rï   r  rÖ  rI   r<  rØ  rý   r"   rß  r  rÙ  r6   rÛ  rC  r  r  r  r”  r=  r
  )
r–  rœ  rE  r   rF  rm  r�  Úflags_blockr   Úviews
             r   Ú_plain_cardrp  |  s§  € Ø
�%‰%€CØ×!˜r€JØ—.‘. §¡¨3¯8©8Ó4€KØ�g‰gÑ?°Q·Y²YÓ?Ó?€GØ�G‰GÑ;°1·7²7Ó;Ó;€EÞ7<Ð& u g¨UÑ3À"€KØ�X‰XÑZ s§w¡w°·±¸#¿)¹)ÀSÇ^Á^Ñ&TÓZÓZ×`Ñ`Ób€Fð €DØˆvƒ~˜#Ÿ/Ÿ/Ø+¬D°·±Ó,AÐ+Bð C*Ü*.¨s¯z©z×/G¸ZÓ*HÐ)IÔJ\ÐI]Ð]aðcˆ÷ó ð ð Ø#˜fðØ$5ðÜ6EÀcÇjÁjÓ6QÐ5RðØRaðÜbfÐgmÓbnÐaoððpðä�{Ó#Ð$ðà$2ðä37¸¿	¹	³?Ð2CðàCPðäQUÐVY×V^ÑV^ÓQ_ÐP`ððaðð %(§K¡KÑ$;�S—[’[ÀÐDððEðô œm¨C¯H©H°c·i±iÓ@ÓAÐBð	ðCð	ô
 ˜s×/Ñ/Ó0Ð1ðð
 2Cðô
 DHÈÏÉÓCVÐBWðð
Xðô ˜Ÿ	™	“?Ð#ðð $1ðô 26°c·h±h³Ð0@ððAðð #&§-¡-Ñ";�—’ÀÐDððEðð #&§*¡*Ñ"8�C—J’J¸bÐAððBðô ! #×"5Ñ"5×";¸×!BÑ!BÓ!D×!JÑ!JÓ!LÓMÐNððOðð 25×1CÑ1CÑ1O”c˜#×,Ñ,Ô-ÐUWÐXððYðô ˜3Ÿ<™<Ó(Ð)ðð *?ðô @NÈcÓ?RÐ>SððTðð %(§K¡KÑ$;�S—[’[ÀÐDððEðô ?FÀcÈ>Ð[_Ó>`Ñ>lœ  ^°TÔ:ÐrtÐuððvðô & c×*Ñ*‘!°Ð2ðð 34ðð IMÐPVËÑ4DÐ\^Ð3_ðð`ðô" ”F˜3“KÓ Ð!ð#ð""ð#ô$ " #§'¡'›]×8¨jÐ9ð%ð$:ð%ð( %˜Ið)ð(&	ð)ð* 
ˆð+ð*	ð+ð, 
ˆð-ð,ô-ð r&   c                 óH  • U R                   nUR                  b  SUR                  S S3p2O,UR                  (       a  S[        UR                  5      p2OSu  p#UR                  =(       d    Sn[        U5      nUR                  =(       d5    UR                  (       a"  SUR                  R                  SS	5      S
   -   OSnSR                  S UR                  UR                  UR                  UR                  4 5       5      R                  5       nSR                  / SP[        U5       PSP[        UR                  5       PSP[        UR                  5       PSP[        U5       PSP[        U5       PSP[        U5       PSP[        U5       PSP[        UR                  5      =(       d    S PSP[        S[        UR                   5      5       PSP[        S[        [#        U5      5      5       PSP[        S[        UR$                  =(       d    S5      5       PSP[        S[        UR&                  =(       d    S5      5       PSP[        SUR(                  b  UR(                  S OS5       PS P[        S![+        UR,                  5      S"5       PSP[        S#[+        UR.                  5      5       PSP[        S$UR0                  b  [        UR0                  5      OS5       PSP[        S%[        U5      5       PSP[        X#5       PS&P[2         PS'P5      $ )(NÚDistancer  z miÚLocation)rr  r   z
not statedzlisting Ú/r   r)  rl  r   c              3   óH   #   • U  H  o(       d  M  [        U5      v •  M     g 7fr   r  r   s     r   r   Ú_review_card.<locals>.<genexpr>±  r  r  r   z,
    <article class="card review" data-reg="r$  z"
      data-model="ú" data-name="rk  ú	">
      ú.
      <div class="body">
        <header><h2>ú</h2><span class="reg">z]</span></header>
        <div class="statcols">
          <div class="col left">
            ÚYearr7  ÚEngineÚGearboxr   ÚFuelÚMileager  z@
          </div>
          <div class="col right">
            r¤  Úcapstatr  ÚOwnersÚHistoryz)
          </div>
        </div>
        z
      </div>
    </article>)r   rÛ  r  rl   rÙ  r
  rL  rÔ  Úrsplitr   r@   rA   r  r   r  rD  r  r%   r<  r"   rÖ  r   r  rß  r  Ú
HIDE_BLOCK)r–  r   ÚdlabelÚdvalÚshrb   rL  r   s           r   Ú_review_cardrˆ  ¦  sq  € Ø
�%‰%€CØ
×ÑÑ%Ø! c×&8Ñ&8¸Ð%;¸3Ð#?‘Ø	��Ø!¤4¨¯©Ó#5‘à-‰ˆØ	×	Ñ	×	, €BÜ�#‹;€DØ
�'‰'×
iÈÏÏ�j 3§?¡?×#9Ñ#9¸#¸qÓ#AÀ"Ñ#EÒEÐ^h€CØ�X‰XÑZ s§w¡w°·±¸#¿)¹)ÀSÇ^Á^Ñ&TÓZÓZ×`Ñ`Ób€F÷ó ð ,ð Ü,0°«I¨;ðØ6CðÜDHÈÏÉÃNÐCSððTðä˜Ÿ	™	“?Ð#ðà#0ðä15°d³°ðà<KðäLPÐQWËLÈ>ððZðô ˆcƒ{€mðððô
 ˜$“Z�Lðð
 !8ðô
 9=¸S¿W¹W»×8SÈÐ7Tðð
Uðô �6œ4 §¡›>Ó*Ð+ðð,ðô �8œT¤-°Ó"4Ó5Ó6Ð7ðð8ðô �9œd 3×#3Ñ#3×#A°zÓBÓCÐDððEðô �6œ4 §¡× 6¨JÓ7Ó8Ð9ðð:ðô �9°C·K±KÑ4K §¡¨Q¡ÐQ[Ó\Ð]ðð^ðô �9œf S§[¡[Ó1°9Ó=Ð>ðð?ðô  �;¤ s§}¡}Ó 5Ó6Ð7ð!ð 8ð!ô" �8°·±Ñ1GœT #§*¡*Ô-ÈZÓXÐYð#ð"Zð#ô$ �9œd 2›hÓ'Ð(ð%ð$)ð%ô& �6Ó Ð!ð'ð&"	ð'ô, 
ˆð-ð,ô-ð r&   c                 ó@  • U R                   n[        U5      nUR                  =(       d    SnSR                  [	        S[        [        U5      5      5      [	        S[        UR                  =(       d    S5      5      [	        S[        UR                  =(       d    S5      5      [	        SUR                  b  UR                  S OS5      [	        S	UR                  b  [        UR                  5      OS5      [	        S
[        UR                  5      5      [	        S[        UR                  5      5      /5      nS[        UR                  =(       d    U5       S[        U5       S[        U5       S[        U5       S[        UR                  5      =(       d    S SU S3$ )Nrl  r   r|  r}  r   r~  r  r  r�  r¤  r  z%
    <article class="card" data-reg="rw  rx  ry  rz  z0</span></header>
        <div class="notestats">zÉ</div>
        <textarea class="note" rows="3" placeholder="Why would you not buy this one? (engine, gearbox, model, value, anything)" oninput="noteChange(this)"></textarea>
      </div>
    </article>)r   r
  rL  r   rD  rl   r%   r<  r"   rÖ  r  r   r  rß  r  )r–  r   rb   rL  Ústatss        r   Ú
_note_cardr‹  Í  sJ  € Ø
�%‰%€CÜ�#‹;€DØ
�'‰'×
�Z€CØ�G‰GÜˆhœœ]¨3Ó/Ó0Ó1Üˆiœ˜c×.Ñ.×<°*Ó=Ó>Üˆf”d˜3Ÿ8™8×1 zÓ2Ó3Üˆi¨s¯{©{Ñ/F˜CŸK™K¨™?ÈJÓWÜˆh¨C¯J©JÑ,Bœ˜SŸZ™ZÔ(È
ÓSÜˆiœ §¡Ó,Ó-Üˆkœ6 #§-¡-Ó0Ó1ðó €Eð%Ü%)¨#¯'©'¯/°TÓ%:Ð$;¸=ÌÈdËÈð UÜˆcƒ{€mð ä˜$“Z�LÐ 7¼¸S¿W¹W»×8SÈÐ7Tð U Ø %˜wð 'ðð r&   c                 óÞ   • U=(       d    [         R                  R                  5       nUR                  S5      nSR	                  S U  5       5      =(       d    SnSU S[        U 5       SU S3$ )	Nú%d %B %Yr   c              3   ó8   #   • U  H  n[        U5      v •  M     g 7fr   )r‹  ©r   r–  s     r   r   Úrender_notes.<locals>.<genexpr>è  s   é € Ð0ª4 a”J˜q—M�Mª4ùó   ‚z(<p class="empty">Nothing to explain.</p>aˆ  <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, why not these</title>
<style>
  :root { --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --card:#fff; --no:#b03a2e;
    --g1:#0c8a4a; --g3:#e08a00; --g4:#d9534f; --g5:#9b2d22; --motorway:#0a5fb4; --carwow:#7b3ff2; --accent:#0a5fb4; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }
  .topbar { background:var(--ink); color:#fff; padding:14px 22px; }
  .topbar h1 { margin:0; font-size:19px; }
  .topbar .sub { color:#9fb1c0; font-size:13px; margin-top:2px; }
  .picker { position:sticky; top:0; z-index:9; background:#fff; border-bottom:1px solid var(--line); padding:12px 22px; box-shadow:0 2px 8px rgba(20,30,40,.06); }
  .picker p { margin:0 0 6px; font-size:13px; color:var(--soft); } .picker b { color:var(--icon); }
  .picker textarea { width:100%; height:64px; font-family:ui-monospace,Menlo,monospace; font-size:12px; border:1px solid var(--line); border-radius:8px; padding:7px; }
  .wrap { max-width:1200px; margin:0 auto; padding:20px; }
  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:18px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden; box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column; }
  .photo { position:relative; aspect-ratio:16/10; background:#dde4ea; }
  /* Only the car photo fills the box. The logo img inside the pill is sized by
     .plat-logo, so scope this to the direct child or it crops the logo. */
  .photo > img { width:100%; height:100%; object-fit:cover; display:block; }
  .nophoto { display:flex; align-items:center; justify-content:center; } .noimg { color:#92a3b1; font-size:14px; }
  .plat { position:absolute; top:10px; left:10px; height:26px; border-radius:7px; display:flex; align-items:center; padding:0 8px; box-shadow:0 1px 5px rgba(0,0,0,.35); }
  .plat-logo { height:15px; display:block; } .plat-word { font-size:13px; font-weight:800; color:#fff; } .plat-word.cw { color:#16c8d6; font-size:15px; }
  .plat-motorway { background:#f2f500; padding:0 7px; } .plat-carwow { background:#fff; } .plat-auction4cars { background:#fff; } .plat-dealerauction { background:#fff; } .plat-dealerway { background:#fff; } .plat-other { background:#55606b; }
  .grade { position:absolute; bottom:10px; right:10px; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:21px; border:3px solid #fff; box-shadow:0 2px 6px rgba(0,0,0,.35); background:#55606b; }
  .grade-1 { background:var(--g1); } .grade-2 { background:#f6cb00; color:#13202b; } .grade-3 { background:var(--g3); } .grade-4 { background:var(--g4); } .grade-5 { background:var(--g5); }
  .body { padding:14px 16px 16px; display:flex; flex-direction:column; gap:11px; }
  header { display:flex; justify-content:space-between; align-items:baseline; gap:10px; }
  header h2 { font-size:16px; margin:0; line-height:1.25; }
  .reg { font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px; font-size:12.5px; letter-spacing:.5px; white-space:nowrap; }
  .notestats { display:grid; grid-template-columns:1fr 1fr; gap:3px 14px; }
  .stat { font-size:13.5px; } .stat .cl { display:inline; font-size:10px; margin-right:6px; color:var(--soft); text-transform:uppercase; letter-spacing:.5px; } .stat .v { font-weight:700; }
  textarea.note { width:100%; border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; font-family:inherit; resize:vertical; }
  textarea.note:focus { outline:2px solid var(--icon); border-color:var(--icon); }
  .empty { color:var(--soft); }
  footer { color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }
</style>
</head>
<body>
  <div class="topbar">
    <h1>BidBrain, why not these</h1>
    <div class="sub">ú. a€   cars you rejected that the rules did not catch. Write why next to each, then copy the box below to me.</div>
  </div>
  <div class="picker">
    <p>Notes written: <b id="count">0</b>. Copy this box and paste it to BidBrain when done.</p>
    <textarea id="allnotes" readonly placeholder="Your notes appear here as you type"></textarea>
  </div>
  <div class="wrap"><div class="grid">aÂ  </div></div>
  <footer>BidBrain learning step. Your reasons become the next rules.</footer>
  <script>
    const KEY='bidbrain_notes';
    let notes=JSON.parse(localStorage.getItem(KEY)||'{}');
    const names={};
    document.querySelectorAll('.card').forEach(c=>names[c.dataset.reg]=c.dataset.name);
    function refresh(){
      const items=Object.entries(notes).filter(([r,n])=>(n||'').trim());
      document.getElementById('count').textContent=items.length;
      document.getElementById('allnotes').value=items.map(([r,n])=>r+'  '+(names[r]||'')+'  ::  '+n.trim()).join('\n');
      localStorage.setItem(KEY,JSON.stringify(notes));
    }
    function noteChange(t){ const reg=t.closest('.card').dataset.reg; notes[reg]=t.value; refresh(); }
    document.querySelectorAll('.card').forEach(card=>{ const ta=card.querySelector('textarea.note'); if(ta&&notes[card.dataset.reg]) ta.value=notes[card.dataset.reg]; });
    refresh();
  </script>
</body>
</html>
)ÚdatetimeÚdateÚtodayÚstrftimer   r:   )Úcarsr•  Ú	nice_dateÚcardss       r   Úrender_notesrš  å  sv   € Ø×*”X—]‘]×(Ñ(Ó*€EØ—‘˜zÓ*€IØ�G‰GÑ0©4Ó0Ó0×^Ð4^€Eð0ð`  �[ ¤3 t£9 +ð .'ð (- gð .ðmIð Ir&   c           	      óì   • U=(       d    [         R                  R                  5       nUR                  S5      nSR	                  S U  5       5      =(       d    SnSU S[        U 5       SU S[         S	3	$ )
Nr�  r   c              3   ó8   #   • U  H  n[        U5      v •  M     g 7fr   )rˆ  r�  s     r   r   Ú render_review.<locals>.<genexpr>8  s   é € Ð5ªW¨”L —O�OªWùr‘  z-<p class="empty">No cars passed the gate.</p>a¹  <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain review</title>
<style>
  :root { --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --card:#fff; --no:#b03a2e;
    --g1:#0c8a4a; --g3:#e08a00; --g4:#d9534f; --g5:#9b2d22; --motorway:#0a5fb4; --carwow:#7b3ff2; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
    font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }
  .topbar { background:var(--ink); color:#fff; padding:14px 22px; }
  .topbar h1 { margin:0; font-size:19px; }
  .topbar .sub { color:#9fb1c0; font-size:13px; margin-top:2px; }
  .picker { position:sticky; top:0; z-index:9; background:#fff; border-bottom:1px solid var(--line);
    padding:12px 22px; box-shadow:0 2px 8px rgba(20,30,40,.06); }
  .picker p { margin:0 0 6px; font-size:13px; color:var(--soft); }
  .picker b { color:var(--no); }
  .picker textarea { width:100%; height:54px; font-family:ui-monospace,Menlo,monospace; font-size:12px;
    border:1px solid var(--line); border-radius:8px; padding:7px; resize:vertical; }
  .wrap { max-width:1200px; margin:0 auto; padding:20px; }
  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:18px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
    box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column; transition:opacity .15s, outline .15s; }
  .card.out { opacity:.4; outline:3px solid var(--no); outline-offset:-3px; }
  .photo { position:relative; aspect-ratio:16/10; background:#dde4ea; }
  /* Only the car photo fills the box. The logo img inside the pill is sized by
     .plat-logo, so scope this to the direct child or it crops the logo. */
  .photo > img { width:100%; height:100%; object-fit:cover; display:block; }
  .nophoto { display:flex; align-items:center; justify-content:center; }
  .noimg { color:#92a3b1; font-size:14px; }
  .plat { position:absolute; top:10px; left:10px; height:26px; border-radius:7px; display:flex; align-items:center;
    padding:0 8px; box-shadow:0 1px 5px rgba(0,0,0,.35); }
  .plat-logo { height:15px; display:block; } .plat-word { font-size:13px; font-weight:800; color:#fff; }
  .plat-word.cw { color:#16c8d6; font-size:15px; }
  .plat-motorway { background:#f2f500; padding:0 7px; } .plat-carwow { background:#fff; } .plat-auction4cars { background:#fff; } .plat-dealerauction { background:#fff; } .plat-dealerway { background:#fff; } .plat-other { background:#55606b; }
  .grade { position:absolute; bottom:10px; right:10px; width:44px; height:44px; border-radius:50%; display:flex;
    align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:21px; border:3px solid #fff;
    box-shadow:0 2px 6px rgba(0,0,0,.35); background:#55606b; }
  .grade-1 { background:var(--g1); } .grade-2 { background:#f6cb00; color:#13202b; }
  .grade-3 { background:var(--g3); } .grade-4 { background:var(--g4); } .grade-5 { background:var(--g5); }
  .body { padding:14px 16px 16px; display:flex; flex-direction:column; gap:12px; flex:1 1 auto; }
  header { display:flex; justify-content:space-between; align-items:baseline; gap:10px; }
  header h2 { font-size:16px; margin:0; line-height:1.25; min-height:2.5em; }
  .reg { font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px; font-size:12.5px; letter-spacing:.5px; white-space:nowrap; }
  .cl { display:block; font-size:10.5px; text-transform:uppercase; letter-spacing:.7px; color:var(--soft); font-weight:600; }
  .statcols { display:flex; justify-content:space-between; gap:14px; }
  .col { display:flex; flex-direction:column; gap:7px; } .col.right { align-items:flex-end; text-align:right; }
  .stat { font-size:15px; } .stat .cl { display:inline; font-size:10.5px; margin-right:6px; letter-spacing:.5px; }
  .stat .v { font-weight:700; }
  .capstat { background:#e9f1fc; border:1px solid #b6d2f0; border-radius:8px; padding:4px 10px; color:#0a5fb4; font-size:15px; }
  .capstat .cl { display:inline; color:#0a5fb4; opacity:.85; margin-right:6px; font-size:10px; }
  .capstat .v { font-weight:800; }
  .empty { color:var(--soft); }
  .card.gone { display:none; }
  /* Hide: a quiet link that opens a tidy reason panel, saving to the database. */
  .hide-row { margin-top:auto; text-align:center; }
  .hidelink { background:none; border:none; color:var(--soft); font-size:12.5px; cursor:pointer; padding:4px; }
  .hidelink:hover { color:var(--no); text-decoration:underline; }
  .hidebox { display:flex; flex-direction:column; gap:8px; margin-top:4px; background:#fbf3f1;
    border:1px solid #ecd9d5; border-radius:10px; padding:10px; }
  .hidebox[hidden] { display:none; }
  .hidebox input { border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; }
  .hidebtns { display:flex; gap:8px; }
  .hidebox .confirm { flex:1; background:var(--no); color:#fff; border:none; border-radius:9px; padding:9px 12px; font-weight:700; cursor:pointer; }
  .hidebox .cancel { flex:1; background:#eef1f5; border:none; border-radius:9px; padding:9px 12px; cursor:pointer; }
  #flash { display:none; position:fixed; left:50%; bottom:20px; transform:translateX(-50%); background:var(--no);
    color:#fff; padding:11px 16px; border-radius:10px; font-size:14px; box-shadow:0 4px 14px rgba(0,0,0,.25); z-index:50; }
  footer { color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }
</style>
</head>
<body>
  <div class="topbar">
    <h1>BidBrain review, tomorrow's sales</h1>
    <div class="sub">r’  zŸ cars passed your rules, not yet valued. Hide any you would not buy and your reason is saved to BidBrain.</div>
  </div>
  <div class="wrap"><div class="grid">aJ  </div></div>
  <div id="flash"></div>
  <footer>BidBrain review step. No cars valued yet. You place every bid yourself.</footer>
  <script>
    function flash(m){ const f=document.getElementById('flash'); f.textContent=m; f.style.display='block';
      clearTimeout(f._t); f._t=setTimeout(()=>f.style.display='none', 6000); }
    z
  </script>
</body>
</html>
)r“  r”  r•  r–  r   r:   ÚHIDE_JS)Úkeepersr•  r˜  r™  s       r   Úrender_reviewr   5  s�   € Ø×*”X—]‘]×(Ñ(Ó*€EØ—‘˜zÓ*€IØ�G‰GÑ5©WÓ5Ó5×hÐ9h€EðMðZ  �[ ¤3 w£< .ð 1'à', gð .ô €Ið ðkYð Yr&   c                 óR   • U =(       d    [         R                  R                  5       n g)zËA small live page listing cars Steven has hidden, each with an unhide
button. It fetches the current list from the server, so it is always up to
date. Must be opened at the served address, not as a file.a·  <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, hidden cars</title>
<style>
  :root { --ink:#13202b; --soft:#64757f; --line:#e4e9ef; --bg:#eef1f5; --no:#b03a2e; --motorway:#0a5fb4; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; }
  .topbar { background:var(--ink); color:#fff; padding:14px 22px; }
  .topbar h1 { margin:0; font-size:19px; }
  .topbar a { color:#9fb1c0; font-size:13px; text-decoration:none; }
  .wrap { max-width:760px; margin:0 auto; padding:20px; }
  .row { background:#fff; border:1px solid var(--line); border-radius:12px; padding:13px 15px; margin-bottom:10px;
    display:flex; align-items:center; gap:14px; }
  .row .info { flex:1; }
  .row .name { font-weight:700; }
  .row .reason { color:var(--soft); font-size:13px; margin-top:2px; }
  .row .reg { font-weight:700; background:#f4d000; padding:2px 8px; border-radius:5px; font-size:12.5px; }
  .unhide { background:var(--motorway); color:#fff; border:none; border-radius:9px; padding:9px 13px; font-weight:700; cursor:pointer; }
  .empty { color:var(--soft); }
  #flash { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); background:var(--no); color:#fff; padding:11px 16px; border-radius:10px; display:none; }
</style>
</head>
<body>
  <div class="topbar"><h1>Hidden cars</h1><a href="cockpit.html">Back to the cockpit</a></div>
  <div class="wrap"><div id="list"><p class="empty">Loading...</p></div></div>
  <div id="flash"></div>
  <script>
    function flash(m){ const f=document.getElementById('flash'); f.textContent=m; f.style.display='block'; setTimeout(()=>f.style.display='none',5000); }
    async function load(){
      try {
        const r = await fetch('/api/hidden'); const d = await r.json();
        const list = document.getElementById('list');
        if(!d.hidden || !d.hidden.length){ list.innerHTML='<p class="empty">No cars are hidden.</p>'; return; }
        list.innerHTML='';
        d.hidden.forEach(h => {
          const row=document.createElement('div'); row.className='row';
          row.innerHTML = '<span class="reg">'+(h.reg||'')+'</span>'+
            '<div class="info"><div class="name">'+(h.name||(h.make+' '+h.model)||'')+'</div>'+
            '<div class="reason">'+(h.reason||'no reason given')+'</div></div>'+
            '<button class="unhide">Unhide</button>';
          row.querySelector('.unhide').onclick = async () => {
            try { await fetch('/api/unhide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reg:h.reg})});
              row.remove(); if(!list.querySelector('.row')) list.innerHTML='<p class="empty">No cars are hidden.</p>'; }
            catch(e){ flash('Could not save. Open this page at its web address.', false); }
          };
          list.appendChild(row);
        });
      } catch(e){ document.getElementById('list').innerHTML='<p class="empty">Could not load. Open this page at its web address, not from a file.</p>'; }
    }
    load();
  </script>
</body>
</html>
)r“  r”  r•  )r•  s    r   Úrender_hiddenr¢  •  s    € ð ×*”X—]‘]×(Ñ(Ó*€Eð9r&   c                 óL  • SSK Jn  UR                  5       R                  S5      =(       d    0 R                  U 5      =(       d    / nU Vs/ s HL  n[	        U[
        5      (       d  M  UR                  S5      (       d  M2  UR                  S5      (       a  MJ  UPMN     sn$ s  snf )a  This dealership's own configured answers for one working list dropdown
(Settings > Purchases), so a filter can never offer an answer the page
itself would not. Retired answers stay readable on old cars but are never
offered again, same rule the cells themselves follow.r   r  Úpurchase_dropdownsrk   Úretired)rÝ   r  Úget_settingsrÄ   Ú
isinstanceÚdict)rS   rG  ÚoptsÚos       r   Ú_opt_valuesr«  Ö  sq   € õ
 #Ø×ÑÓ×"Ñ"Ð#7Ó8×>¸B×CÑCÀCÓH×NÈB€DÙÓ_’t�!œz¨!¬T×2‹A°q·u±u¸W·~‹AÈaÏeÉeÐT]×N^�A‘tÑ_Ð_ùÒ_s   ÁB!Á(B!Â B!ÂB!c                  ó–  • SSK Jn   U R                  5       nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d    S	nUR                  S
5      =(       d    S	nUR                  S5      =(       d    U=(       d    [
        n	Sn
S0 S.UR                  S5      =(       d    0 EnSn SS	Kn[        USS5      =(       d    SnU
R                  S[        R                  " U R                  5       5      5      R                  S[        R                  " U R                  5       5      5      R                  S[        R                  " U5      5      R                  S[        R                  " U R                  5       5      5      R                  S[        R                  " U R                  5       R                  S5      =(       d    / 5      5      R                  SU=(       d    S5      n
[        U
R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU	5      R                  S [         5      R                  S!["        5      R                  S"[$        5      R                  S#[&        5      R                  S$[(        5      R                  S%[*        5      R                  S&[-        5       5      R                  S'[/        5       5      R                  S([1        SS)95      R                  S*[3        5       5      R                  S+[5        5       5      R                  S,[7        5       5      R                  S-[9        S.S/S0S1[;        S2S3/ S4QS5S6S79-   [=        S8S9/ S5S:S;9-   [?        S<S=5      -   [?        S>S?5      -   [=        S@SA[A        SB5       Vs/ s H  oîSC   PM	     snS5SD9-   [=        SESF[A        SG5       Vs/ s H  oîSC   PM	     snS5SD9-   [=        SHSI[A        SJ5       Vs/ s H  oîSC   PM	     snS5SD9-   [=        SKSL[A        SM5       Vs/ s H  oîSC   PM	     snS5SD9-   [;        SNSOSPSQ/S5SD9-   [;        SRSSSTSU/S5SD9-   [C        SVSWSXSY9-   [C        SZS[S\SY9-   [C        S]S^S_SY9-   [C        S`SaSbScSdSe9-   4Sf/SgSh95      R                  Si[D        5      R                  Sj[G        5       5      R                  Sk[I        Sl5      5      R                  Sm[K        5       5      R                  Sn[M        U5      5      5      $ ! [         a     GN^f = fs  snf s  snf s  snf s  snf )oaa  The purchases admin page (Mark 2026-08-20): every car actually bought,
read daily off Motorway and Carwow's own purchases screens by
purchases_run.py into the purchases table (Auction4Cars and Dealer
Auction do not feed this yet, no reader exists for their won screens).
Cancelled or voided sales never reach this table, they are suppressed
into walked_away instead, so every row here is a genuine buy. Fetches
live from /api/purchases on load, same pattern as the hidden cars page,
so it is never stale. Must be opened at the served address, not as a
file, same as every other admin page here. Each row carries a small
exterior photo and a Status column showing the platform's own status
plus, while Motorway's own payment breakdown is still only an
Estimated breakdown rather than the confirmed final one, an amber note
saying so (2026-08-21).

Tidied up 2026-08-25 (Mark: "tidy up the purchases screen so it fits
on small screens"), from 17 columns down to 11: Winning bid's own fee
breakdown (Fee, Transport, VAT, Total paid) and Retail est.'s own
Cazana 115%/Margin both moved off their own columns into a small
popover, shown on hover or a tap (the page's own JS _infoCell), and
the Service history/V5 columns each show only the first document
photo, a small badge if there is more than one, the rest still open
in the same shared gallery modal on click (_docCell).

Theme colours (Mark 2026-08-25) read fresh from the saved settings on
every request, same live pattern as everything else on this page, so a
colour saved on the settings page shows here immediately, no rebuild
needed the way the cockpit's own static file does.r   r  Útheme_bgÚtheme_priceÚ
theme_pillÚ
theme_iconÚtheme_sidebarÚtheme_logo_fullNÚtheme_logo_smallÚtheme_faviconu%- <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, purchases</title>
<link rel="icon" href="{{THEME_FAVICON}}">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root { --ink:oklch(0.279 0.0299 260.05); --soft:oklch(0.446 0.0313 257.3); --line:oklch(0.929 0.0095 255.53);
    --bg:{{THEME_BG}}; --no:oklch(0.444 0.177 26.9); --good:oklch(0.448 0.119 151.33);
    --motorway:#0a5fb4; --carwow:#7b3ff2;
    --price:{{THEME_PRICE}}; --pill:{{THEME_PILL}}; --icon:{{THEME_ICON}};
    --sidebar-bg:{{THEME_SIDEBAR}}; --radius-xl:14px; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif; }
  /* App shell, same as the cockpit's own (Mark 2026-08-25: "build the full
     app shell into BidBrain"): a persistent left nav plus a floating,
     rounded, shadowed content panel. The page still scrolls as one, only
     the outer frame is new. CSS duplicated verbatim across every page's
     own plain template, same convention already used here for the bell. */
  .appshell { display:flex; align-items:flex-start; gap:0; min-height:100vh; background:var(--sidebar-bg); padding:8px; }
  /* No overflow:hidden here (Mark 2026-08-25: "make the top nav sticky so
     the listing go under as you scroll") â€” it was clipping the .controls
     bar's own position:sticky, an ancestor with overflow hidden breaks
     sticky positioning entirely since it stops being "the page scrolls"
     and becomes "this box scrolls", which .insetpanel itself never
     actually does (the whole page does). .controls gets the top corners
     directly instead so the rounded panel still looks seamless where it
     is actually ever seen; the bottom corners are moot in practice, the
     panel is always taller than the viewport until the very end of a
     long list, and only a sharp edge on the last row could ever poke
     past them there. */
  .insetpanel { flex:1; min-width:0; background:var(--bg); border-radius:var(--radius-xl);
    box-shadow:0 1px 3px rgba(20,30,40,.08), 0 1px 2px rgba(20,30,40,.04); }
  @media (max-width:860px) {
    /* align-items:stretch: the column flip otherwise inherits the row
       layout's flex-start, the panel shrinks to content width and the
       whole PAGE scrolls sideways (2026-08-29 hardening). */
    .appshell { flex-direction:column; padding:0; align-items:stretch; }
    .insetpanel { border-radius:0; }
  }
  /* Control bar: same shape as the cockpit's own (brand, tabs, actions all
     in one sticky row), Mark 2026-08-24: "the same nav bar design as the
     main page should be used, it frees up more space having the toggles
     up there and keeps UI the same". Replaces the old stacked dark topbar
     plus a separate tabs row plus a separate summary row. */
  .controls { position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px rgba(20,30,40,.05); border-radius:var(--radius-xl) var(--radius-xl) 0 0; }
  .controls .brand { font-size:18px; font-weight:800; letter-spacing:-0.02em; color:var(--ink);
    margin-right:2px; white-space:nowrap; flex:none; text-decoration:none; cursor:pointer; }
  .controls .brand span { color:var(--icon); }
  .tabs { display:inline-flex; background:var(--sidebar-bg); border-radius:10px; padding:3px; gap:2px; flex:none; }
  .tab { border:none; background:none; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer;
    padding:7px 14px; border-radius:8px; white-space:nowrap; }
  .tab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px rgba(20,30,40,.15); }
  /* The Live Auctions/Purchases/Lost page switcher (Mark 2026-08-27: "copy
     the toggle for the auction house and make toggle for Live Autions,
     Purchases, Lost"), same .tabs/.tab shape, real links not JS state. */
  .pageswitcher .tab { display:flex; align-items:center; gap:6px; text-decoration:none; }
  .pageswitcher .tab svg { width:15px; height:15px; flex:none; }
  /* The search and date filters live in the shared More drawer now
     (v3.4), reached from the head bar's own More button. */
  /* The dealership logo at the left of the nav bar, where the left rail
     used to carry it (2026-08-31). */
  .navlogo { flex:none; display:flex; align-items:center; height:34px; margin-right:4px; }
  .navlogo img { height:26px; width:auto; max-width:150px; object-fit:contain; display:block; }
  .controls-actions { display:flex; align-items:center; gap:4px; margin-left:auto; }
  /* Moved out of the Filters panel onto the nav bar itself, next to the
     refresh icon (Mark 2026-08-26: "move the search from all the filters
     to next to the refresh on the nav bar"), same #search id and oninput
     as before so render()'s own filtering is unchanged, just reachable
     without opening Filters first. */
  .navsearch-wrap { position:relative; display:inline-flex; align-items:center; }
  .navsearch-wrap svg { position:absolute; left:11px; color:var(--soft); pointer-events:none; }
  /* -webkit-appearance:none strips Safari's own native search-field chrome
     on type="search", which reserves internal space for its own icon
     invisible to the CSS box model (padding/width look fine, the text
     still clips) â€” this box already draws its own magnifying glass
     (.navsearch-wrap svg), so the native one was never wanted anyway. */
  .navsearch { height:34px; padding:0 12px 0 32px; border:1px solid var(--line); border-radius:9px;
    font:inherit; font-size:13px; background:var(--sidebar-bg); color:var(--ink); width:230px; max-width:36vw;
    -webkit-appearance:none; appearance:none; }
  .navsearch::placeholder { color:var(--soft); }
  /* Icon only, no outline (Mark 2026-08-25: "starting from the top right
     user icon, notification bell, star, and a refresh icon all just icons
     no outlines to match the left panel look"), replacing the old solid
     coloured Sync now button. Same id and onclick throughout. */
  .syncbtn { display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    background:none; color:var(--soft); border:none; border-radius:9px; cursor:pointer; padding:0; }
  .syncbtn:hover { background:var(--bg); color:var(--ink); }
  .syncbtn:disabled { color:var(--line); cursor:default; }
  .syncbtn:disabled:hover { background:none; }
  /* A real progress bar, shared by every sync/check on this page (Sync
     now, Check all DealerKit records, and each row's own Check DK
     record/Check Motorway/Check Carwow), 2026-08-27, Steven: "there is no
     animation, or progress bar or anything to tell you its running... i
     keep thinking its timed out or something". The server already writes
     a live phase/message (and, for the bulk DealerKit check, a real
     N of M count) to the same progress file the cockpit's own run bar
     reads; this page's five poll loops already fetched it every 3s and
     threw it away except to check for an error. Same shape as the
     cockpit's own runbar, so it reads as the same feature everywhere in
     the app, not a second design. The one new thing (see paintRunbar):
     if nothing has genuinely moved for 90 seconds, against the server's
     own clock, the bar turns amber and says so honestly, the actual
     answer to "is this stuck or just slow", not just a spinner that
     could mean either. */
  @keyframes navspin { to { transform:rotate(360deg); } }
  /* Icon only, same nav convention as the cockpit's own bag/target/wrench
     buttons (Mark 2026-08-25: "use icons to navigate to other pages like
     home page"), so all four pages read as the same product. */
  .settingslink { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px; border:none; background:none; cursor:pointer; padding:0; font:inherit;
    border:none; border-radius:9px; color:var(--soft); text-decoration:none; margin-left:4px; background:none; cursor:pointer; }
  .settingslink:hover { background:var(--bg); color:var(--ink); }
  .settingslink.on { color:#d39e00; }
  .settingslink.on svg { fill:currentColor; }
  /* Notification bell, same design as the cockpit's own (Mark 2026-08-25:
     "standardise the nav to icons for favorites, purchases, notifications,
     lost bids and settings ... same order on all pages with same look and
     design"); this page has no run data of its own to bake notices into at
     render time, so the panel starts empty and fills in from GET
     /api/notices on load, the same live-fetch pattern this page's own
     table already uses. */
  .bellwrap { position:relative; margin-left:4px; }
  /* Signed in user, moved here from the left panel's own footer (Mark
     2026-08-25: "move the user icon top right on the nav"), since the
     panel can now collapse to a rail too narrow for a name. Just the
     round avatar now (Mark 2026-08-25 again: "all just icons no
     outlines"), the name is still on the title attribute as a tooltip. */
  .navuser { display:flex; align-items:center; margin-left:4px; }
  .navuser-av { width:32px; height:32px; border-radius:50%; background:var(--line); flex:none;
    display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:var(--ink); cursor:default; }
  .bellbtn { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .bellbtn:hover { background:var(--bg); color:var(--ink); }
  .bellbadge:empty { display:none; }
  /* box-sizing:border-box shrinks the badge's own content area to 12px
     (16px minus the 2px top+bottom border), but a plain line-height:16px
     still laid the digit out at the full 16px, so it overflowed past the
     visible circle, reading as cut off at the bottom (Mark 2026-08-26:
     "fix the ios red flags they seen to cut off at the bottom"). Flex
     centring is immune to that box-sizing/line-height mismatch, same
     mechanism every other icon button here already centres its own SVG
     with. */
  .bellbadge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 3px;
    border-radius:8px; background:var(--no); color:#fff; font-size:10px; font-weight:800;
    box-sizing:border-box; display:flex; align-items:center; justify-content:center; border:2px solid #fff; }
  .bellpanel { position:absolute; top:calc(100% + 8px); right:0; width:320px; max-height:360px; overflow-y:auto;
    background:#fff; border:1px solid var(--line); border-radius:12px; padding:8px; box-shadow:0 10px 28px rgba(20,30,40,.16);
    z-index:10; }
  .bellpanel[hidden] { display:none; }
  .bellnote { display:block; margin:0; padding:9px 10px; border-radius:8px; font-size:13px; line-height:1.4;
    color:var(--soft); text-decoration:none; }
  .bellnote + .bellnote, .bellnote + a.bellnote, a.bellnote + .bellnote { margin-top:2px; }
  .bellnote.alert { background:#fdf3e0; color:#7a5a00; font-weight:600; }
  .bellnote-link { cursor:pointer; }
  .bellnote-link:hover { filter:brightness(.96); text-decoration:underline; }
  .bellempty { margin:0; padding:14px 10px; font-size:13px; color:var(--soft); text-align:center; }
  /* Red when there is something to actually see, not just the small badge
     count (Mark 2026-08-25: "make the bell go red when notifications").
     The star icon stays the same look as every other nav icon (Mark:
     "make sure the watched / shortlisted icons are always the same"),
     just gaining the same small corner badge with a real count ("add the
     amount watched like the amount of notifications"), both filled in by
     loadNotices() once GET /api/notices resolves. */
  #starbadge:empty { display:none; }
  /* Wide, not fixed, so a real monitor gets to show every column with no
     scrolling (Mark 2026-08-24: "use all the monitors width if size allows
     so no scrolling"); the old 1560px cap forced a horizontal scrollbar on
     a normal 1920px or wider screen even though there was clearly room.
     .tablewrap's own overflow-x:auto stays as the fallback for whatever
     genuinely still does not fit (a narrow laptop window). */
  .wrap { max-width:3560px; margin:0 auto; padding:20px; }
  /* Heading above the table matches the live auction page's own section
     heading (Mark 2026-08-26: "make the heading above the table match
     the live auction"): the slice chip and the platform's own logo tabs
     plus a live count on the left, Add car and More on the right. */
  .section-head { display:flex; align-items:center; justify-content:space-between; gap:14px;
    flex-wrap:wrap; margin-bottom:16px; }
  .section { font-size:14px; text-transform:uppercase; letter-spacing:1px; color:var(--soft);
    margin:0; display:flex; align-items:center; gap:10px; flex:none; }
  .section .setcount { background:var(--bg); color:var(--ink); border-radius:999px; padding:1px 9px;
    font-size:12px; font-weight:700; vertical-align:middle; }
  .section-logo { height:20px; width:auto; display:block; }
  .section-logo.auction4cars { border-radius:4px; }
  /* The platform toggle, moved here from the top nav bar (Mark 2026-08-27:
     "make the 2nd nav toggle for auction house move to the same place as
     the home page on the purchases and didn't win pages, make the look
     the same ie logos"), same .tabs/.tab shape and real logos the live
     auction page's own toggle uses. Gained a real Auction4Cars tab, then a
     real Dealer Auction one, the same day ("i now want to add a4c
     purchase info in to BB", then "ok lets do DA now"); DealerWay still
     has no purchase reader of its own, so this page still stops at four
     real platforms rather than the live auction page's own five. */
  .platform-toggle .tab { display:flex; align-items:center; justify-content:center; min-height:26px; }
  .platform-toggle .tab .section-logo { height:18px; }
  .platform-toggle .tab .section-logo.dealerauction { height:21px; }
  .section-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-left:auto; }
  .summary { display:flex; flex-direction:column; gap:2px; padding:6px; width:280px; max-width:80vw; }
  .summary .stat { display:flex; align-items:baseline; justify-content:space-between; gap:14px;
    padding:8px 10px; border-radius:8px; }
  .summary .stat:hover { background:var(--bg); }
  .summary .stat b { font-size:16px; white-space:nowrap; }
  .summary .stat span { font-size:11.5px; color:var(--soft); text-align:right; }
  /* Small screens: the control bar's own flex-wrap already reflows the
     brand, tabs, filters and actions onto their own lines, this just makes
     the search and date fields fill the width instead of staying a fixed
     size once they are alone on a line, and gives the summary tiles and
     table breathing room down to a phone's own width. */
  .addcarbtn { display:flex; align-items:center; gap:6px; height:34px; padding:0 12px;
    border-radius:9px; border:1px solid var(--line); background:#fff; color:var(--ink);
    font:inherit; font-size:13px; font-weight:700; cursor:pointer; }
  .addcarbtn:hover { border-color:var(--icon); color:var(--icon); }
{{FRAME_CSS}}
  @media (max-width: 760px) {
    .controls { padding:10px 14px; }
    .controls-actions { margin-left:0; width:100%; justify-content:space-between; }
    .navsearch-wrap { flex:1 1 auto; min-width:0; }
    .navsearch { width:100%; }
    .wrap { padding:12px; }
    th, td { padding:8px 9px; font-size:13px; }
    .thumb { width:52px; height:36px; }
    .history-thumb { width:36px; height:26px; }
    .gallery-nav { width:38px; height:38px; font-size:20px; }
    .gallery-prev { left:8px; } .gallery-next { right:8px; }
    .gallery-close { top:10px; right:12px; font-size:28px; }
  }
  /* With the fee breakdown and Cazana/Margin both tucked into a hover
     popover instead of five extra columns (Mark 2026-08-25), and only one
     document thumbnail shown per car, the table now fits most windows on
     its own; this stays as the fallback for whatever genuinely still does
     not, a phone in portrait or a very narrow split window. */
  /* The table scrolls inside its own box (max-height set by
     sizeTableArea in the JS), so the toolbar above never moves, the
     column titles stick to the top of the box and the rollup footer to
     its bottom. border-collapse must be separate: with collapse, sticky
     headers shed their borders as they float in Chrome. */
  .tablewrap { overflow:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
  /* table-layout FIXED so a dragged column width is genuinely honoured:
     under auto layout the browser treats col widths as suggestions and
     redistributes them, so resizing saved a number and changed nothing
     on screen (the real reason resize "still" did not work, caught
     2026-08-27 by finally checking the RENDERED width, not the style).
     With fixed layout the used table width is the larger of 100% and
     the sum of the column widths, so it fills the box when narrow and
     scrolls sideways when wide, same as before. */
  table { width:100%; table-layout:fixed; border-collapse:separate; border-spacing:0; }
  th, td { text-align:left; padding:10px 10px; font-size:13.5px; border-bottom:1px solid var(--line); white-space:nowrap; }
  /* a column dragged narrower than its content clips rather than
     spilling over its neighbour; the cells that host popovers (Auc
     status, the money columns, DealerKit) stay visible or their
     rollovers would be clipped with them */
  tbody td { overflow:hidden; }
  tbody td.status, tbody td.num, tbody td.dktd, td.editing { overflow:visible; }
  th.sortable { overflow:hidden; text-overflow:ellipsis; }
  thead th { position:sticky; top:0; z-index:6; }
  tfoot td { position:sticky; bottom:0; z-index:5; background:#fff; border-bottom:none; }
  .vname { display:inline-block; max-width:230px; overflow:hidden; text-overflow:ellipsis;
    white-space:nowrap; vertical-align:bottom; }
  /* Links out to the original listing (Mark 2026-08-27: "theres a view
     advert link... to see the original listing with the images"). */
  .vlink { display:inline-flex; margin-left:6px; color:var(--soft); vertical-align:middle; }
  .vlink:hover { color:var(--accent); }
  th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--soft); background:#f7f8f6; }
  /* Click a header to sort by it, click again to reverse (Steven
     2026-08-27: "i'd like to be able to sort the columns on that page per
     column"), see SORT_KEYS/compareRows/onSortHeaderClick below. */
  th.sortable { cursor:pointer; user-select:none; }
  th.sortable:hover { color:var(--icon); }
  th.sortable.sorted { color:var(--icon); }
  th .sortarrow { display:inline-block; margin-left:3px; }
  tr:last-child td { border-bottom:none; }
  .reg { font-weight:700; background:#f4d000; padding:2px 8px; border-radius:5px; font-size:12px; white-space:nowrap; }
  .plat { font-size:11px; font-weight:700; color:#fff; padding:2px 8px; border-radius:999px; white-space:nowrap; }
  .plat.motorway { background:var(--motorway); } .plat.carwow { background:var(--carwow); }
  /* Platform column as real logos (Mark 2026-08-27: "under the platform
     colume use the auction house logos"), same small size as the toggle
     bar's own tabs, a compact table row has no more room than that does. */
  .plat-logo { height:18px; width:auto; display:block; }
  .plat-logo.dealerauction { height:19px; }
  .plat.manual { background:#55606b; }
  .num { font-variant-numeric:tabular-nums; }
  .num.dim { color:var(--soft); }
  .num.total { font-weight:700; }
  .margin.good { color:var(--good); font-weight:700; } .margin.bad { color:var(--no); font-weight:700; }
  .thumb { width:64px; height:44px; border-radius:6px; object-fit:cover; background:#e4e9ef; display:block; }
  .thumb.blank { display:flex; align-items:center; justify-content:center; color:var(--soft); font-size:10px; }
  .thumb.gallery-open, .history-thumb { cursor:pointer; }
  /* Only the first service history/V5 photo shows now (Mark 2026-08-25:
     "only show one image... to view click and open modal"), a small badge
     on the corner says there is more, the rest still open in the shared
     gallery modal on click. Replaces the old row of every thumbnail, which
     could grow wide enough on a long service history to force the whole
     table into a horizontal scroll even on a wide monitor. */
  .history-thumb { width:44px; height:32px; border-radius:5px; object-fit:cover; background:#e4e9ef; display:block; }
  .doc-thumb-wrap { position:relative; display:inline-block; }
  .thumb-count { position:absolute; top:-6px; right:-6px; background:var(--ink); color:#fff;
    font-size:10px; font-weight:700; line-height:1; padding:3px 5px; border-radius:999px;
    border:2px solid #fff; pointer-events:none; }
  /* Winning bid's own fee breakdown and Retail est.'s own Cazana 115%/
     Margin, tucked behind a small popover instead of five extra columns
     (Mark 2026-08-25: "tidy up the purchases screen so it fits on small
     screens... put the fee, transport, vat, total paid on a modal that is
     viewable on hover of winning bid"). Shown on hover on a real pointer,
     and on a tap via the JS .show class for a touch screen with no hover
     to trigger it. */
  .info-trigger { position:relative; cursor:help; border-bottom:1px dotted var(--soft); }
  .info-pop { display:none; position:absolute; z-index:15; top:calc(100% + 8px); left:0;
    background:#fff; border:1px solid var(--line); border-radius:9px; box-shadow:0 10px 26px rgba(20,30,40,.15);
    padding:8px 10px; min-width:150px; white-space:nowrap; text-align:left; }
  .info-trigger:hover .info-pop, .info-trigger.show .info-pop { display:block; }
  .info-row { display:flex; justify-content:space-between; gap:16px; font-size:12.5px;
    color:var(--soft); padding:3px 0; font-weight:400; }
  .info-row span:last-child { font-variant-numeric:tabular-nums; color:var(--ink); }
  .info-row.total { border-top:1px solid var(--line); margin-top:2px; padding-top:6px; font-weight:700; }
  .info-row.total span:last-child { color:var(--ink); }
  .info-row.good span:last-child { color:var(--good); font-weight:700; }
  .info-row.bad span:last-child { color:var(--no); font-weight:700; }
  .info-row.est span:last-child { color:#a06a00; font-weight:700; }
  /* A popover positioned off its own trigger's left edge can run past a
     narrow screen's own right edge; anchoring it to the viewport's own
     right edge instead keeps it fully on screen (Mark 2026-08-25: "tidy
     up the purchases screen so it fits on small screens"). A second media
     query block rather than folded into the earlier one above, since that
     one sits BEFORE .info-pop's own base rule in the file and would have
     been overridden right back by it (same cascade order, base rule wins
     when it comes later, caught live: the override was computing to no
     effect at all on a real 375px viewport before this fix). */
  @media (max-width: 760px) {
    .info-pop { left:auto; right:0; min-width:140px; }
  }
  /* The full gallery behind a thumbnail, one shared overlay for every row
     (Mark 2026-08-21: "apply this to the purchases page"), same pattern
     as the cockpit's own card gallery. */
  .gallery-overlay { position:fixed; inset:0; background:rgba(10,16,14,.92); z-index:70;
    display:flex; align-items:center; justify-content:center; flex-direction:column; }
  .gallery-overlay[hidden] { display:none; }
  .gallery-img { max-width:min(92vw, 1100px); max-height:70vh; object-fit:contain;
    border-radius:8px; box-shadow:0 20px 60px rgba(0,0,0,.5); }
  .gallery-close { position:absolute; top:18px; right:22px; background:none; border:none;
    color:#fff; cursor:pointer; opacity:.85; display:flex; align-items:center; justify-content:center; }
  .gallery-close:hover { opacity:1; }
  .gallery-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.12);
    color:#fff; border:none; width:48px; height:48px; border-radius:999px; font-size:26px;
    cursor:pointer; display:flex; align-items:center; justify-content:center; }
  .gallery-nav:hover { background:rgba(255,255,255,.22); }
  .gallery-prev { left:24px; } .gallery-next { right:24px; }
  .gallery-count { color:#fff; font-size:13px; font-weight:700; opacity:.8; margin-top:14px; }
  .gallery-thumbs { display:flex; gap:8px; overflow-x:auto; max-width:92vw; padding:14px 6px 4px; }
  .gallery-thumb { width:56px; height:40px; object-fit:cover; border-radius:5px; cursor:pointer;
    opacity:.55; border:2px solid transparent; flex:none; }
  .gallery-thumb:hover { opacity:.85; }
  .gallery-thumb.on { opacity:1; border-color:#fff; }
  .status { font-size:11.5px; color:var(--soft); }
  .au-stages { display:inline-flex; align-items:center; gap:5px; }
  /* The shared .info-trigger style draws a dotted underline meant for a
     text figure like "Â£12,495" (2026-08-27); under a row of 3 icon
     circles that reads as a stray line for no reason, so it is switched
     off specifically here. */
  .info-trigger:has(.au-stages) { border-bottom:none; cursor:pointer; }
  .au-stage { display:inline-flex; align-items:center; line-height:0; }
  .due-unconfirmed { color:#d39e00; font-weight:600; font-size:11.5px; }
  .empty { color:var(--soft); padding:20px; text-align:center; }
  /* DealerKit push status per row (Mark 2026-08-24, redone 2026-08-26:
     "wrong sync button i meant the row buttons ... change the DK button
     on each row"): a small circle, green tick when every real category
     Check DK record can confirm is actually present, orange when some
     are, a plain grey ring when none are, hovering (or a tap, same
     .info-trigger hybrid the table's own Winning bid/Retail est
     popovers already use) shows a tick box per pushable item defaulted
     to whatever is missing on DealerKit and present in BidBrain, plus an
     Upload to DK button ("add tick boxes ... and a button to upload").
     A kebab next to it opens a small menu for the two live checks
     instead, Check DK record and Check Motorway/Carwow. */
  .dkrow { position:relative; display:inline-flex; align-items:center; gap:2px; }
  .dkrow-status svg { display:block; }
  /* The row's own DealerKit push status, a small coloured fraction badge
     (Mark 2026-08-27: "like the icon found in dealer kit for job... a
     faction of how many items in total... use the traffic light system
     from red to green"), replacing the old plain tick/ring icon. Colour
     itself is set inline per row (dkFractionBadge, real red to green
     interpolation, not a fixed set of classes). */
  .dk-fraction-badge { display:inline-flex; align-items:center; gap:4px; padding:2px 8px 2px 4px;
    border-radius:999px; color:#fff; font-size:11px; font-weight:700; line-height:1; cursor:pointer;
    white-space:nowrap; }
  .dk-fraction-text { display:inline-block; }
  /* DealerKit is the last column, so its own popover opens leftward
     (right aligned) rather than the other popovers' default left:0,
     which would run off the right edge of the screen here. */
  .dkrow-pop { min-width:150px; left:auto; right:0; }
  .dkrow-kebabwrap { position:relative; display:inline-flex; }
  .dkrow-kebab { width:22px; height:22px; display:flex; align-items:center; justify-content:center;
    background:none; border:none; border-radius:6px; color:var(--soft); cursor:pointer; padding:0; }
  .dkrow-kebab:hover { background:var(--bg); color:var(--ink); }
  .dkrow-kebab:disabled { opacity:.6; cursor:default; }
  .dkrow-kebab.pushing svg { animation:navspin .8s linear infinite; }
  .dkrow-menu { display:none; position:absolute; top:calc(100% + 6px); right:0; background:#fff;
    border:1px solid var(--line); border-radius:9px; box-shadow:0 10px 26px rgba(20,30,40,.15);
    padding:5px; min-width:160px; z-index:12; }
  .dkrow-menu.show { display:block; }
  .dkrow-menuitem { display:block; width:100%; text-align:left; background:none; border:none;
    padding:7px 9px; border-radius:6px; font-size:12.5px; font-weight:600; color:var(--ink);
    cursor:pointer; white-space:nowrap; }
  .dkrow-menuitem:hover { background:var(--bg); }
  .dkrow-menusep { height:1px; background:var(--line); margin:4px 2px; }
  /* What a real "Check DK record" last found genuinely present on the
     live record (Mark 2026-08-26: "add a check DK record for whats been
     added or present on the record"), a tick per item rather than plain
     text ("the roll over on the row sync status, should have the green
     tick not 'Pushed' when its present on DK"). */
  .info-row.dkcheck-head { padding-top:2px; font-weight:700; color:var(--ink); }
  .dkpresence-row .dkmark { display:flex; align-items:center; }
  .info-row.absent span:last-child { color:var(--line); font-weight:700; }
  .info-row.dim { color:var(--line); }
  .info-row.dim span:last-child { color:var(--line); font-weight:400; }
  /* The Upload to DealerKit tick boxes (Mark 2026-08-26, moved to sit
     right in the presence row's own mark position: "make the upload tick
     boxes appear where the tick for uploaded is"). Four real states, one
     colour each: nothing to push is a plain grey ring, disabled; present
     here but not yet confirmed on DealerKit is an amber outline ("if its
     present in BB then make the circle outline orange"), the ordinary
     unticked state for something genuinely worth pushing; already
     confirmed on DealerKit (data-ondk, set when Check DK record found it
     there) is a green outline, matching this same popover's own "done"
     colour everywhere else, ticking it again is a deliberate re push, not
     the default; ticked to go up is blue, not green (Mark: "when i
     select the missing purchase price its selected green this looks
     confusing ... this should go blue", green already meant "confirmed
     present" everywhere else in here, ticking something NEW needed its
     own colour so the two are never confused). The tick mark itself is
     drawn with a simple rotated border rather than another embedded SVG,
     cheaper and this is the only place that needs it. */
  .dkcheck-toggle { -webkit-appearance:none; appearance:none; width:16px; height:16px;
    border-radius:50%; border:2px solid #d39e00; background:#fff; margin:0; padding:0;
    cursor:pointer; position:relative; flex-shrink:0; }
  /* Already confirmed on DealerKit but not ticked (2026-08-26, Mark on
     KF66GJZ, genuinely already fully pushed: "BB is saying purchase
     price, photos are present ... this is still showing as not present
     in BB" â€” a real outline-only ring, no fill, read as "empty" next to
     the same shape red rings use for a genuine absence, even though its
     colour was correct). Now a full solid fill with the same white tick
     "checked" uses, exactly the same visual weight as every other
     confirmed mark in this popover ("use the same green circle tick as
     completed", the original ask), never confused with an actual
     absence again; ticking it over the top still turns it blue.
     --good (fixed, non theme customisable) rather than the old --go,
     merged 2026-08-26 against Steven's own v3.1 theme split (background/
     price/pill/icon, all customisable); a genuine status colour needs to
     stay reliably green regardless of a dealer's own brand accent, the
     same reasoning --good's other existing use on this page (.info-row.
     good) already follows. */
  .dkcheck-toggle[data-ondk="1"] { border-color:var(--good); background:var(--good); }
  .dkcheck-toggle:checked { border-color:#2f6fed; background:#2f6fed; }
  .dkcheck-toggle:checked::after, .dkcheck-toggle[data-ondk="1"]:not(:checked)::after {
    content:''; position:absolute; left:2px; top:4px; width:8px; height:4px;
    border-left:2px solid #fff; border-bottom:2px solid #fff; transform:rotate(-45deg); }
  .dkcheck-toggle:disabled { border-color:var(--line); background:#fff; opacity:.6; cursor:default; }
  .dkcheck-toggle:disabled[data-ondk="1"] { background:var(--line); }
  .dkcheck-toggle:disabled::after { content:none; }
  .retail-wrap { display:inline-flex; align-items:center; gap:5px; }
  .retail-flag { display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px;
    border-radius:50%; background:var(--icon); color:#fff; font-size:9px; font-weight:800; margin-left:4px; cursor:help; }
  .retail-edit-btn { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px;
    border:1px solid var(--line); border-radius:6px; background:#fff; color:var(--soft); cursor:pointer; padding:0; }
  .retail-edit-btn:hover { color:var(--icon); border-color:var(--icon); }
  .retail-edit-input { width:80px; padding:3px 6px; border:1px solid var(--line); border-radius:6px; font-size:12.5px; }
  /* A real date, plus the browser's own calendar picker icon, needs more
     room than an 80px number field (2026-08-27, Due in made editable the
     same way as Retail est., a plain number). */
  input[type="date"].retail-edit-input { width:135px; }
  .retail-save-btn, .retail-clear-btn { padding:3px 8px; border:none; border-radius:6px; background:var(--icon);
    color:#fff; font-size:11.5px; font-weight:700; cursor:pointer; }
  .retail-clear-btn { background:#fff; color:var(--soft); border:1px solid var(--line); }
  .retail-save-btn:disabled, .retail-clear-btn:disabled { opacity:.6; cursor:default; }
  .dkupload-btn { display:block; width:100%; margin-top:6px; padding:6px 0; border:none;
    border-radius:7px; background:var(--icon); color:#fff; font-size:12.5px; font-weight:700;
    cursor:pointer; }
  .dkupload-btn:hover { filter:brightness(1.06); }
  .dkupload-btn:disabled { opacity:.6; cursor:default; }
  /* Sync modal (Mark 2026-08-24: "the sync now button should also open a
     modal to allow for full syncing of motorway or carwow together or
     indivually"), same shape and class names as the cockpit's own Run
     modal so it feels like the same product, this page just never shares
     a stylesheet with that one. */

  /* ============ the working list rebuild (Steven 2026-08-27) ============ */
  /* the Due in / In stock / Cancelled / All views */

  /* dropdown option pills, colour names shared with the settings editor */
  .opill { display:inline-block; font-size:11px; font-weight:700; padding:2px 9px; border-radius:999px;
    max-width:100%; overflow:hidden; text-overflow:ellipsis; vertical-align:middle; white-space:nowrap; }
  .oc-grey{background:#e8eae6;color:#4b5563}   .oc-blue{background:#dbeafe;color:#1e40af}
  .oc-navy{background:#2d3748;color:#fff}      .oc-green{background:#d8f3dc;color:#166534}
  .oc-teal{background:#ccf2ec;color:#0f6d5f}   .oc-teal2{background:#c9eef7;color:#0e5c73}
  .oc-sky{background:#d7e9fb;color:#1d4e89}    .oc-yellow{background:#fde68a;color:#78350f}
  .oc-purple{background:#e9d5ff;color:#5b21b6} .oc-pink{background:#fbd0e0;color:#9d174d}
  .oc-red{background:#fde0dd;color:#9b1c1c}    .oc-orange{background:#fed7aa;color:#9a3412}
  .oc-amber{background:#fdf0d5;color:#92400e}

  /* three tiers of cell: yours edits, platform locks, computed overrides */
  td.yourscell { cursor:pointer; }
  td.yourscell:hover { background:#f6f9ff; }
  .cellempty { color:#c3c8c1; font-weight:700; }
  .opill.asdefault { opacity:.6; }
  tfoot td { border-top:2px solid var(--line); font-weight:800; font-size:12px; color:var(--soft);
    padding-top:9px; padding-bottom:11px; }
  tfoot td.num { color:var(--ink); }
  .vmrow { display:flex; align-items:center; gap:8px; padding:6px 7px; border-radius:8px; }
  .vmrow:hover { background:#f5f7f3; }
  .vmrow .vmname { flex:1; border:none; background:none; font:inherit; font-size:13px; font-weight:600;
    color:var(--ink); text-align:left; cursor:pointer; padding:0; }
  .vmrow .vmdel { border:none; background:none; cursor:pointer; color:#9aa39b; font-size:15px;
    font-weight:700; padding:0 4px; border-radius:5px; }
  .vmrow .vmdel:hover { color:#b42318; background:#fdf0ef; }
  .vm-empty { font-size:12px; color:var(--soft); padding:6px 7px; }
  .vm-saverow { display:flex; gap:7px; margin-top:8px; }
  .vm-saverow input { flex:1; min-width:0; border:1px solid var(--line); border-radius:8px;
    padding:6px 9px; font:inherit; font-size:12.5px; }
  .vm-saverow input:focus { outline:none; border-color:var(--icon); }
  .vm-saverow button { border:none; background:var(--icon); color:#fff; border-radius:8px;
    padding:6px 13px; font:inherit; font-size:12.5px; font-weight:700; cursor:pointer; }
  .pfcheck { width:16px; height:16px; border:1.6px solid var(--soft); border-radius:4px; display:inline-block;
    vertical-align:middle; position:relative; cursor:pointer; background:#fff; }
  .pfcheck.on { background:var(--icon); border-color:var(--icon); }
  .pfcheck.on::after { content:""; position:absolute; left:4.5px; top:1.5px; width:4px; height:8px;
    border:solid #fff; border-width:0 2px 2px 0; transform:rotate(40deg); }
  .notescell { text-overflow:ellipsis; font-size:12.5px; }
  .noval { font-size:11px; font-weight:700; color:var(--no); opacity:.7; }
  .inline-ok { display:block; margin:5px 0 0 auto; width:26px; height:26px; border:none; cursor:pointer;
    border-radius:50%; background:var(--good); display:flex; align-items:center; justify-content:center;
    box-shadow:0 2px 6px rgba(0,0,0,.18); }
  .inline-ok:hover { filter:brightness(1.08); }
  .chipcell { cursor:pointer; display:inline-flex; align-items:center; gap:5px; }
  .chipneg { color:var(--no); font-weight:700; }
  .chipadd { color:var(--soft); font-weight:800; border:1px dashed var(--line); border-radius:6px;
    width:20px; height:20px; display:inline-flex; align-items:center; justify-content:center; }
  .chipcell:hover .chipadd { color:var(--icon); border-color:var(--icon); }
  .chipn { background:#fde0dd; color:#9b1c1c; border-radius:999px; font-size:10px; font-weight:800; padding:0 5px; }
  td.opener { cursor:pointer; }
  td.opener:hover .vname { text-decoration:underline; text-underline-offset:2px; }
  td.opener:hover .reg { box-shadow:0 0 0 2px rgba(0,0,0,.18); }
  .manualtag { font-size:9px; font-weight:800; letter-spacing:.05em; text-transform:uppercase;
    background:#eceee9; color:var(--soft); border-radius:4px; padding:1px 5px; margin-left:5px; vertical-align:middle; }

  /* THE DOUBLE WIN WARNING (Steven 2026-09-20): the same car won twice,
     so two live sales share one registration. Red, at the top, above the
     filters, because nothing on the page is more expensive to miss:
     until one sale is cancelled neither car goes to accounts or to
     DealerKit. One line per car, saying which to keep and which to
     cancel. See bidbrain.db.double_wins for the rule and the wording. */
  .dupbar { background:#fdeceb; border:1px solid #f0b6b1; border-left:4px solid #c0392b;
    border-radius:9px; padding:10px 13px; margin:0 0 11px; }
  .dupbar h4 { margin:0 0 5px; font-size:13px; font-weight:800; color:#93261b; }
  .dupbar ul { margin:0; padding:0; list-style:none; display:grid; gap:4px; }
  .dupbar li { font-size:12.5px; color:#5c1d16; line-height:1.45; }
  .duptag { font-size:9px; font-weight:800; letter-spacing:.05em; text-transform:uppercase;
    background:#c0392b; color:#fff; border-radius:4px; padding:1px 5px; margin-left:5px;
    vertical-align:middle; cursor:help; }
  .duptag.dupkeep { background:#2f7d4f; }

  /* the floating select editor for a dropdown cell */
  td.editable { cursor:pointer; }
  td.editable:hover { background:#f6f8f4; }
  .ddcaret { color:#a8b0a9; margin-left:5px; vertical-align:middle; flex:none; }
  .ddcell:hover .ddcaret { color:var(--ink); }
  td.tedit::after { content:"âœŽ"; color:#c9c2e2; font-size:11px; margin-left:6px; opacity:0; }
  td.tedit:hover::after { opacity:1; }
  th { position:relative; }
  /* The resize grips. The first version was a 9px strip half hanging
     into the NEXT header, and since every sticky header is its own
     stacking context the neighbour painted OVER the overhang: the real
     hittable area was about 5 invisible pixels, which is why resizing
     "still" did not work with a real mouse while synthetic tests
     passed. Now: a wide right grip fully inside its own header, plus a
     left grip on every header that resizes the PREVIOUS column, so the
     whole strip around a border works from either side, with a visible
     divider so it can actually be found. */
  .thgrip { position:absolute; top:0; bottom:0; cursor:col-resize; z-index:8; touch-action:none; }
  .thgrip.gr { right:0; width:14px; }
  .thgrip.gl { left:0; width:10px; }
  .thgrip.gr::after { content:""; position:absolute; right:1px; top:18%; bottom:18%; width:3px;
    border-radius:2px; background:var(--line); opacity:0; }
  thead th:hover .thgrip.gr::after { opacity:1; }
  .thgrip.gr:hover::after, body.dresizing .thgrip.gr::after { background:var(--icon); opacity:1; }
  th[draggable] { cursor:grab; }
  th.dropbefore { box-shadow:inset 3px 0 0 var(--icon); }
  th.dropafter { box-shadow:inset -3px 0 0 var(--icon); }
  .v5wrap { display:inline-flex; align-items:center; gap:7px; }
  .lbstate { cursor:pointer; }
  tr.cancelled-row td { opacity:.55; }
  tr.cancelled-row td:last-child { opacity:1; }
  .selmenu { position:fixed; z-index:90; background:#fff; border:1px solid var(--line); border-radius:9px;
    box-shadow:0 8px 26px rgba(0,0,0,.17); padding:5px; min-width:180px; max-height:260px; overflow:auto; }
  .selmenu button { display:block; width:100%; text-align:left; border:none; background:none; padding:5px 7px;
    border-radius:6px; cursor:pointer; font:inherit; }
  .selmenu button:hover { background:#f2f4f0; }
  .selmenu .clearopt { font-size:12px; color:var(--soft); font-weight:600; }
  .inline-edit { width:100%; box-sizing:border-box; font:inherit; font-size:12.5px;
    border:1.5px solid var(--icon); border-radius:6px; padding:3px 6px; background:#fff; outline:none; }
  textarea.inline-edit { resize:none; min-height:26px; line-height:1.4; display:block; }

  /* ============ the record: journey header, stages, timeline ============ */
  .drawer-scrim { position:fixed; inset:0; background:rgba(20,25,30,.32); z-index:60; }
  .drawer-scrim[hidden] { display:none; }
  .drawer { position:fixed; top:0; right:0; bottom:0; width:940px; max-width:96vw; min-width:340px;
    background:#fff; z-index:61; box-shadow:-14px 0 44px rgba(0,0,0,.2); display:flex; flex-direction:column;
    transform:translateX(103%); transition:transform .2s ease; }
  .drawer.open { transform:none; }
  @media (prefers-reduced-motion: reduce){ .drawer { transition:none; } }
  .dgrab { position:absolute; left:0; top:0; bottom:0; width:8px; cursor:col-resize; z-index:3; }
  .dgrab::after { content:""; position:absolute; left:3px; top:50%; transform:translateY(-50%);
    width:2px; height:36px; border-radius:2px; background:var(--line); }
  .dgrab:hover::after { background:var(--icon); height:60px; }
  body.dresizing { user-select:none; cursor:col-resize; }
  /* The header is a real flex layout, never absolute positioning: an
     earlier version anchored the three dot button with position:absolute
     inside an inline span, which resolved against the zero size span
     rather than the header and dropped the button under the platform
     pill, caught live 2026-08-27. Row one is the identity cluster (reg
     plate, platform, manual tag) with the actions pushed right; the
     vehicle name gets its own full width line underneath. */
  .dhead { padding:16px 20px 6px; flex:none; }
  .dh-top { display:flex; align-items:center; gap:8px; }
  .dh-actions { display:flex; gap:4px; margin-left:auto; position:relative; flex:none; }
  .dhead .dclose, .dhead .ddots { width:30px; height:30px; border:none;
    background:none; border-radius:8px; color:var(--soft); cursor:pointer;
    display:flex; align-items:center; justify-content:center; }
  .dhead .dclose:hover, .dhead .ddots:hover { background:#eceee9; color:var(--ink); }
  .dh-name { margin:9px 0 0; font-size:17px; line-height:1.35; font-weight:800; letter-spacing:-.01em; }
  .actmenu { position:absolute; top:34px; right:0; background:#fff; border:1px solid var(--line);
    border-radius:11px; box-shadow:0 12px 34px rgba(0,0,0,.18); padding:6px; min-width:212px; z-index:30; }
  .actmenu[hidden] { display:none; }
  .actmenu button { display:flex; align-items:center; gap:9px; width:100%; text-align:left; border:none;
    background:none; padding:8px 9px; border-radius:8px; cursor:pointer; font:inherit; font-size:13px;
    color:var(--ink); }
  .actmenu button:hover { background:#f5f7f3; }
  .actmenu button.bad { color:var(--no); }
  .actmenu .actsep { height:1px; background:var(--line); margin:5px 4px; }
  .actmenu .actnote { font-size:11px; color:var(--soft); padding:4px 9px 5px; line-height:1.45; }

  /* the stepper above the tabs, survives every tab. Capped and centred
     so a wide drawer keeps it composed rather than stretching the dots
     to the far corners. */
  .jstep { display:flex; align-items:flex-start; padding:14px 20px 0; flex:none;
    max-width:560px; margin:0 auto; width:100%; box-sizing:border-box; }
  .js { flex:1; display:flex; flex-direction:column; align-items:center; position:relative; }
  .js .ln { position:absolute; top:9.5px; height:2.5px; background:var(--line); left:-50%; right:50%;
    width:100%; border-radius:2px; }
  .js.done .ln, .js.cur .ln { background:var(--good); }
  .js:first-child .ln { display:none; }
  .jdot { width:21px; height:21px; border-radius:50%; background:#fff; border:2px solid var(--line);
    position:relative; z-index:1; display:flex; align-items:center; justify-content:center; color:#fff; }
  .js.done .jdot { background:var(--good); border-color:var(--good); }
  .js.cur .jdot { border:3px solid var(--icon); box-shadow:0 0 0 4px #e8eefa; width:23px; height:23px; }
  .js.cur .jdot::after { content:""; width:8px; height:8px; border-radius:50%; background:var(--icon); }
  .js.killed .jdot { background:var(--no); border-color:var(--no); }
  .jlab { font-size:10.5px; margin-top:6px; color:var(--soft); font-weight:700; text-align:center; line-height:1.3; }
  .js.cur .jlab { color:var(--icon); font-weight:800; font-size:11.5px; }
  .js.killed .jlab { color:var(--no); font-weight:800; }
  .js.dead .jlab { color:#c3c8c1; text-decoration:line-through; }
  .jsum { text-align:center; font-size:11.5px; color:var(--soft); padding:10px 20px 12px; flex:none; }
  .jsum b { color:var(--ink); }
  .jsum .warn { color:#a06a00; font-weight:700; }
  .jsum .bad { color:var(--no); font-weight:700; }

  .dscroll { flex:1; overflow-y:auto; min-height:0; }
  /* The permanent split (Steven 2026-08-27: record left, always side by
     side): the record never hides behind a tab, the right panel carries
     Journey, Timeline and DealerKit. */
  .csplit { display:flex; align-items:stretch; min-height:100%; border-top:1px solid var(--line); }
  .cleft { flex:1.3; min-width:0; }
  .cright { flex:1; min-width:0; border-left:1px solid var(--line); background:#fbfaf7;
    display:flex; flex-direction:column; }
  .seclab { display:flex; align-items:center; gap:9px; padding:16px 20px 6px; }
  .seclab b { font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; color:#8a938c; }
  .seclab .ln2 { flex:1; height:1px; background:var(--line); }
  .rtabs { display:flex; gap:3px; padding:8px 14px 0; border-bottom:1px solid var(--line); flex:none; }
  .rtab { border:none; background:none; font:inherit; font-size:12.5px; font-weight:700; color:var(--soft);
    padding:8px 11px 9px; border-bottom:2.5px solid transparent; margin-bottom:-1px; cursor:pointer; }
  .rtab.on { color:var(--icon); border-bottom-color:var(--icon); }
  .rtab .n { background:#eceee9; color:var(--soft); border-radius:999px; font-size:10.5px; font-weight:800;
    padding:0 6px; }
  .rbody { flex:1; }
  .recgrid { padding:2px 20px 16px; }
  .rgroup { margin-bottom:14px; }
  .rgh { font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase; color:#98a09a;
    margin:0 0 3px; }

  /* the next up card: blue = do it now, grey = waiting, green = done, red = cancelled */
  .nextup { margin:14px 16px 12px; border-radius:12px; padding:13px 16px; border:1px solid; }
  .nextup.action { border-color:#cfdcf3; background:#f4f8fe; }
  .nextup.waiting { border-color:var(--line); background:#fafbf8; }
  .nextup.alldone { border-color:#cbe8d2; background:#f2fbf4; }
  .nextup.stopped { border-color:#edc9c5; background:#fff6f5; }
  .nulab { font-size:9.5px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; }
  .nextup.action .nulab { color:var(--icon); }
  .nextup.waiting .nulab { color:var(--soft); }
  .nextup.alldone .nulab { color:var(--good); }
  .nextup.stopped .nulab { color:var(--no); }
  .nuwhat { font-size:15.5px; font-weight:800; letter-spacing:-.01em; margin:5px 0 3px; }
  .nuwhy { font-size:12.5px; color:var(--soft); line-height:1.5; }
  .nuwhy b { color:var(--ink); }
  .nuact { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
  .nuact button { border:none; background:var(--icon); color:#fff; font:inherit; font-size:12.5px;
    font-weight:700; border-radius:8px; padding:8px 13px; cursor:pointer; }
  .nuact button.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }

  /* the journey rail, one row per stage down the right panel */
  .vrail { padding:2px 18px 16px; }
  .vrow { position:relative; padding:10px 0 10px 30px; }
  .vrow::before { content:""; position:absolute; left:8px; top:32px; bottom:-4px; width:2px; background:var(--line); }
  .vrow:last-child::before { display:none; }
  .vdot { position:absolute; left:0; top:12px; width:18px; height:18px; border-radius:50%; background:var(--good);
    display:flex; align-items:center; justify-content:center; }
  .vrow.cur .vdot { background:#fff; border:2.5px solid var(--icon); }
  .vrow.todo .vdot { background:#fff; border:2px solid var(--line); }
  .vname { font-weight:700; font-size:13px; }
  .vrow.todo .vname { color:var(--soft); }
  .vsum { font-size:11.5px; color:var(--soft); margin-top:1px; }
  .vact { margin-top:7px; }
  .opill-btn { cursor:pointer; display:inline-block; }
  .opill-btn .opill { box-shadow:0 0 0 1px rgba(0,0,0,.06); }
  .paidtog { display:inline-flex; align-items:center; gap:7px; font-size:12px; font-weight:700;
    color:var(--soft); cursor:pointer; }
  /* the DealerKit sync panel */
  .dkpane { padding:12px 16px 16px; }
  .dkchk { font-size:11.5px; color:var(--soft); margin:2px 2px 8px; }
  .dkchk b { color:var(--ink); }
  .dkr { display:flex; align-items:center; gap:10px; padding:5.5px 8px; margin:0 -8px; border-radius:7px;
    font-size:12.5px; }
  .dkr .lab { color:var(--ink); font-weight:600; flex:none; }
  .dkr .det { color:#98a09a; font-size:11.5px; min-width:0; overflow:hidden; text-overflow:ellipsis;
    white-space:nowrap; }
  .dkr .mk { margin-left:auto; flex:none; display:flex; align-items:center; }
  .dkr.absent .lab { color:var(--no); }
  .dkr.na { opacity:.5; }
  .dkr.na .lab { color:var(--soft); font-weight:500; }
  .dkring { width:15px; height:15px; border-radius:50%; border:2px solid var(--no); display:inline-block; }
  .dkring.unk { border-color:#c9cec7; }
  .dkdrawer-check { width:16px; height:16px; accent-color:#2563eb; cursor:pointer; }
  .dklegend { display:flex; gap:15px; flex-wrap:wrap; font-size:10.5px; color:var(--soft);
    border-top:1px dashed var(--line); margin-top:10px; padding-top:9px; align-items:center; }
  .dklegend span { display:inline-flex; align-items:center; gap:6px; }
  .dkbtns { display:flex; gap:8px; margin-top:12px; }
  .dkupload { flex:1; border:none; background:var(--icon); color:#fff; font:inherit; font-size:12.5px;
    font-weight:700; border-radius:9px; padding:9px; cursor:pointer; }
  .dkupload[disabled] { opacity:.5; cursor:default; }
  .dkghost { border:1px solid var(--line); background:#fff; color:var(--ink); font:inherit; font-size:12.5px;
    font-weight:700; border-radius:9px; padding:9px 13px; cursor:pointer; }
  .sgr { display:grid; grid-template-columns:minmax(126px,168px) 1fr; gap:0 14px; padding:6px 7px;
    margin:0 -7px; border-radius:7px; font-size:12.5px; align-items:center; }
  .sgr > span:first-child { color:var(--soft); }
  .sgr b { text-align:left; font-weight:600; min-width:0; overflow-wrap:anywhere; }
  .sgr.edit { cursor:pointer; }
  .sgr.edit:hover { background:#f6f9ff; }
  .sgr.edit:not(.ddcell) b::after { content:"âœŽ"; color:#c9c2e2; font-size:11px; margin-left:7px; }
  /* The money block is a receipt, one figure per line in its own quiet
     box, never a flex strip of equal columns (squashed at 400px, sparse
     and disconnected at 900px, seen live before this shape). Rows with
     class tot (Car price, All in) rule off and read larger. */
  .sgmoney { display:flex; flex-direction:column; background:#fafbf8; border:1px solid #eceee9;
    border-radius:10px; padding:9px 13px; margin:9px 0 5px; max-width:400px; }
  .sgmoney div { display:flex; justify-content:space-between; align-items:baseline; gap:14px; padding:3px 0; }
  .sgmoney .l { font-size:11.5px; color:var(--soft); font-weight:600; min-width:0;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .sgmoney .v { font-size:13px; font-weight:700; flex:none; }
  .sgmoney .v.win { color:var(--good); }
  .sgmoney div.tot { border-top:1px solid #e6e9e3; margin-top:4px; padding-top:7px; }
  .sgmoney div.tot .l { font-size:10px; text-transform:uppercase; letter-spacing:.05em; font-weight:800; }
  .sgmoney div.tot .v { font-size:15.5px; font-weight:800; }
  /* An editable money row on a hand added car (2026-08-31), same pencil
     affordance .sgr.edit already gives every other editable row. */
  .sgmoney div.medit { cursor:pointer; margin:0 -6px; padding-left:6px; padding-right:6px;
    border-radius:6px; }
  .sgmoney div.medit:hover { background:#f1f5ff; }
  .sgmoney div.medit .v::after { content:"âœŽ"; color:#c9c2e2; font-size:11px; margin-left:7px;
    font-weight:400; }
  .sgmoney div.medit .inline-edit { width:110px; text-align:right; }
  /* The per fee VAT tag on the receipt (2026-08-31): grey when the fee
     carries no VAT, green when it does, clickable on a hand added car. */
  .vattag { display:inline-block; margin-left:6px; padding:1px 5px; border-radius:5px;
    font-size:9.5px; font-weight:800; letter-spacing:.03em; background:#eceee9; color:#9aa39c;
    vertical-align:middle; }
  .vattag.on { background:color-mix(in srgb, var(--icon) 16%, #fff); color:var(--icon); }
  .sgmoney div.medit .vattag { cursor:pointer; }
  .sgmoney div.medit .vattag:hover { filter:brightness(.95); }
  .checkinbtn { width:100%; border:none; background:var(--icon); color:#fff; font:inherit; font-size:13px;
    font-weight:700; border-radius:10px; padding:10px; cursor:pointer; margin-top:6px; }
  .checkinbtn[disabled] { opacity:.45; cursor:default; }
  .vact .checkinbtn { width:auto; padding:7px 14px; margin-top:0; }
  .checkedin { background:#e7f6e9; border:1px solid #cbe8d2; border-radius:10px; padding:9px 12px;
    font-size:12.5px; color:#166534; margin-top:6px; }
  .cancelbanner { margin:12px 20px 14px; border:1px solid #edc9c5; background:#fff8f7; border-radius:11px;
    padding:11px 13px; }
  .cbtop { display:flex; align-items:center; gap:8px; color:var(--no); font-weight:800; font-size:13.5px; }
  .cbwhen { margin-left:auto; font-size:11px; color:var(--soft); font-weight:600; }
  .cbreason { font-size:12.5px; color:var(--soft); line-height:1.5; margin-top:6px;
    background:#fff; border:1px solid #f0dad7; border-radius:8px; padding:7px 9px; }

  /* the timeline */
  .tl { list-style:none; margin:0; padding:14px 20px 4px; }
  .tl li { position:relative; padding:0 0 14px 24px; font-size:12.5px; line-height:1.5; }
  .tl li::before { content:""; position:absolute; left:4px; top:4px; width:9px; height:9px; border-radius:50%;
    background:var(--good); border:2px solid #fff; box-shadow:0 0 0 1.5px var(--good); }
  .tl li.k-comment::before { background:var(--icon); box-shadow:0 0 0 1.5px var(--icon); }
  .tl li.k-field::before { background:#d39e00; box-shadow:0 0 0 1.5px #d39e00; }
  .tl li.k-sync::before { background:#7c5cd6; box-shadow:0 0 0 1.5px #7c5cd6; }
  .tl li.k-chip::before { background:var(--no); box-shadow:0 0 0 1.5px var(--no); }
  .tl li.k-cancel::before { background:var(--no); box-shadow:0 0 0 1.5px var(--no); }
  .tl li:not(:last-child)::after { content:""; position:absolute; left:8px; top:16px; bottom:0; width:1.5px;
    background:var(--line); }
  .tl .tlwhen { color:#98a0a8; font-size:11px; display:block; margin-top:1px; }
  .tl .tlwas { color:#98a0a8; text-decoration:line-through; }
  .tl .tlnow { font-weight:700; }
  .tl .tlbody { background:#e8eefa; border-radius:8px; padding:6px 9px; margin-top:3px; white-space:pre-wrap; }

  /* comments, pinned below whichever tab is open */
  .dcomments { flex:none; border-top:1px solid var(--line); background:#fafbf8; padding:10px 20px 13px; }
  .dcomments h4 { margin:0 0 7px; font-size:10px; text-transform:uppercase; letter-spacing:.05em;
    color:var(--soft); font-weight:800; }
  .clist { max-height:110px; overflow-y:auto; margin-bottom:8px; display:flex; flex-direction:column; gap:6px; }
  .cnote { background:#fff; border:1px solid #eceee9; border-radius:9px; padding:6px 9px; font-size:12.5px;
    line-height:1.45; white-space:pre-wrap; }
  .cnote .cmeta { display:block; color:#98a0a8; font-size:10.5px; margin-top:3px; }
  .cinput { display:flex; gap:7px; }
  .cinput textarea { flex:1; min-width:0; border:1px solid var(--line); border-radius:9px; padding:7px 9px;
    font:inherit; font-size:12.5px; resize:none; height:36px; line-height:1.4; background:#fff; }
  .cinput textarea:focus { outline:none; border-color:var(--icon); height:60px; }
  .cinput button { border:none; background:var(--icon); color:#fff; border-radius:9px; padding:0 14px;
    font:inherit; font-size:12.5px; font-weight:700; cursor:pointer; height:36px; }

  /* A flat, labelled section for record data that is NOT part of the car
     journey (Steven 2026-08-27: "the everything else section... looks
     like its part of the car journey at the moment, and its not"). No
     dot, no chevron, no stage card border: a small caps heading over a
     plain run of rows, unindented, so it reads as reference data under
     the journey rather than a fifth stage of it. */
  /* The drawer is a size container so ITS width, not the viewport's,
     decides the layout: on a screen genuinely too narrow for the split,
     a phone, the journey panel stacks under the record. */
  .drawer { container-type:inline-size; container-name:drawer; }
  @container drawer (max-width: 700px) {
    .csplit { flex-direction:column; }
    .cright { border-left:none; border-top:1px solid var(--line); }
  }

  /* chip / cancel / add car dialogs */
  .pmodal { position:fixed; inset:0; background:rgba(18,24,30,.42); z-index:70; display:flex;
    align-items:center; justify-content:center; padding:20px; }
  .pmodal[hidden] { display:none; }
  .pmbox { background:#fff; border-radius:15px; width:440px; max-width:100%; max-height:92vh;
    overflow-y:auto; padding:20px 22px; box-shadow:0 24px 70px rgba(0,0,0,.3); }
  /* The Add car modal alone is two columns (2026-08-31, Steven: "now there
     is alot more going on... the modal is split into 2 columns"), the
     other modals here are a handful of fields and stay narrow. */
  #manualmodal .pmbox { width:660px; }
  .pmbox h3 { margin:0 0 4px; font-size:16.5px; }
  .pmsub { margin:0 0 14px; font-size:12.5px; color:var(--soft); line-height:1.5; }
  .pmlab { display:block; font-size:10px; text-transform:uppercase; letter-spacing:.05em;
    color:var(--soft); font-weight:800; margin:12px 0 5px; }
  .pmbox input[type=text], .pmbox input[type=number], .pmbox input[type=date],
  .pmbox input[type=datetime-local], .pmbox textarea {
    width:100%; border:1px solid var(--line); border-radius:9px; padding:8px 10px; font:inherit; font-size:13px; }
  .pmbox textarea { resize:vertical; line-height:1.5; }
  .pmbox input:focus, .pmbox textarea:focus { outline:none; border-color:var(--icon); }
  /* Section headings, splitting a long form into the three things a person
     actually thinks in (2026-08-31: "think deeply about this... it needs
     to be made much simpler"). Same weight as the drawer's own .rgh. */
  .pmsec { font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase;
    color:#98a09a; margin:22px 0 12px; padding-top:14px; border-top:1px solid #eceee9; }
  .pmsec:first-of-type { margin-top:14px; padding-top:14px; }
  .pmreq { color:var(--no, #c0392b); }
  .pmhelp { font-size:11.5px; color:var(--soft); margin:4px 0 0; line-height:1.4; }
  /* Two fields a row, a few spanning both. Collapses to one column on a
     narrow window, so a phone still gets a readable single stack.

     The vertical rhythm between rows lives ENTIRELY in the grid's own
     row-gap (2026-08-31, "a lot of spacing issues... it looks very
     cluttered"): every .pmlab still carries its own 12px top margin for
     the plain stacked modals (chip, cancel), which is fine there since
     nothing else spaces those fields apart. Inside a grid that margin
     was a SECOND, overlapping source of vertical space on top of the
     row-gap, and since a row's real height follows its tallest cell
     (align-items:start), the two compounded unevenly depending on how
     tall the row above happened to be, sometimes leaving a big gap and
     sometimes a cramped one. Zeroing it here leaves one clean, constant
     gap between every row, whatever it contains. */
  .pmgrid { display:grid; grid-template-columns:1fr 1fr; gap:18px 16px; align-items:start; }
  .pmgrid > .pmspan { grid-column:1 / -1; }
  .pmgrid .pmlab { margin-top:0; }
  @media (max-width:620px) { .pmgrid { grid-template-columns:1fr; } }
  .pmfeerow { display:flex; gap:8px; }
  .pmfeecol { flex:1 1 0; min-width:0; }
  .pmsublab { display:block; font-size:10.5px; color:var(--soft); font-weight:600; margin-bottom:4px; }
  /* Each fee carries its own VAT tick, mirroring how the platforms
     themselves invoice, rather than one typed lump (2026-08-31). */
  .pmvat { display:flex; align-items:center; gap:6px; margin-top:7px;
    font-size:11.5px; color:var(--soft); cursor:pointer; }
  .pmvat input { width:auto; margin:0; accent-color:var(--icon); cursor:pointer; }
  .pmtotals { font-size:12px; color:var(--soft); margin:10px 0 0; font-weight:600; min-height:15px; }
  .pmtotals b { color:var(--ink); }
  /* One control, three states, shared by Supplier and Person collecting
     (2026-08-31, Steven: "the supplier should still be a dropdown... with
     the ability to add a new one... the same should be for person
     collecting"). At rest a plain select ending in "+ Add a new X...";
     picking it REPLACES the select with a name box, never joins it, so
     nothing on screen ever disagrees with anything else; committing (Enter,
     or moving on to the next field) saves the name and repaints back to
     the select with it already chosen, so you SEE the answer land. */
  .pmdd select { width:100%; border:1px solid var(--line); border-radius:9px;
    padding:8px 10px; font:inherit; font-size:13px; background:#fff; color:var(--ink); }
  .pmdd select:focus { outline:none; border-color:var(--icon); }
  .pmdd-add { display:flex; gap:8px; align-items:center; }
  .pmdd-add input { flex:1 1 auto; min-width:0; border:1px solid var(--icon); border-radius:9px;
    padding:8px 10px; font:inherit; font-size:13px; }
  .pmdd-add input:focus { outline:none; }
  .pmdd-cancel { flex:none; width:32px; height:32px; border:1px solid var(--line); background:#fff;
    color:var(--soft); font-size:16px; line-height:1; border-radius:9px; cursor:pointer; }
  .pmdd-cancel:hover { border-color:var(--no, #c0392b); color:var(--no, #c0392b); }
  /* A visible tick to finish the name (2026-08-31, "there needs to be a
     tick button when adding a new supplier or person collecting"), the
     same affordance the notes editor's own .inline-ok already gives.
     Enter and moving on still commit, this just makes it obvious. */
  .pmdd-ok { flex:none; width:32px; height:32px; border:none; background:var(--icon);
    border-radius:9px; cursor:pointer; display:flex; align-items:center; justify-content:center; }
  .pmdd-ok:hover { filter:brightness(1.08); }
  .pmdd-ok svg { pointer-events:none; }
  .pmradio { display:flex; gap:9px; align-items:flex-start; border:1px solid var(--line); border-radius:10px;
    padding:9px 11px; margin-bottom:7px; cursor:pointer; font-size:12.5px; color:var(--soft); line-height:1.45; }
  .pmradio input { margin-top:2px; accent-color:var(--icon); flex:none; }
  .pmradio b { display:block; color:var(--ink); font-size:13px; }
  .chippreview { background:#fafbf8; border:1px solid #eceee9; border-radius:11px; padding:10px 12px;
    margin-top:13px; }
  .cprow { display:flex; justify-content:space-between; font-size:12.5px; padding:3px 0; color:var(--soft); }
  .cprow b { color:var(--ink); }
  .cprow.tot { border-top:1px solid var(--line); margin-top:4px; padding-top:6px; font-weight:700; }
  .cprow .was { color:#98a0a8; text-decoration:line-through; margin-right:4px; font-weight:400; }
  .cprow b.goodm { color:var(--good); } .cprow b.badm { color:var(--no); }
  .chipworth { font-size:12px; color:var(--soft); line-height:1.5; margin-top:9px; padding-top:9px;
    border-top:1px solid #eceee9; }
  .chipworth b { color:var(--ink); }
  .chiphint { background:#fdf3e0; border:1px solid #ecdcb4; border-radius:8px; padding:7px 9px; margin-top:8px;
    font-size:12px; color:#7c5406; line-height:1.45; }
  .pmact { display:flex; gap:9px; margin-top:22px; padding-top:16px; border-top:1px solid #eceee9; }
  .pmact button { flex:1; padding:10px; border-radius:9px; font:inherit; font-size:13px; font-weight:700;
    cursor:pointer; border:none; }
  .pmact .go2 { background:var(--icon); color:#fff; }
  .pmact .ghost2 { background:#f2f4f0; color:var(--ink); }
  .pmact .danger2 { background:var(--no); color:#fff; }

  /* the Fields menu: table and record visibility, set separately */
  .fmhead { display:flex; align-items:center; gap:8px; padding:4px 8px 7px; font-size:10px;
    text-transform:uppercase; letter-spacing:.05em; color:var(--soft); font-weight:800; }
  .fmhead .sp { flex:1; }
  .fmhead .cl { width:44px; text-align:center; }
  .fmrow { display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:7px; font-size:12.5px; }
  .fmrow:hover { background:#f7f8f6; }
  .fmrow .fname2 { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .fmrow .cl { width:44px; display:flex; justify-content:center; flex:none; }
  .fmrow input[type=checkbox] { accent-color:var(--icon); width:14px; height:14px; }
  .dkmismatch { color:var(--no); font-weight:700; }
</style>
</head>
<body>
<div class="appshell" id="appshell">
<div class="insetpanel">
  <div class="controls">
    {{NAVLOGO}}
    {{PAGE_SWITCHER}}
    <div class="controls-actions">
      <div class="navsearch-wrap">{{SEARCH_SVG}}<input id="search" class="navsearch" type="search" placeholder="Search reg, make or model" oninput="render()"></div>
      <button type="button" class="syncbtn" id="syncbtn" onclick="openRunModal()" title="Run now"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></button>
      <a id="navstar" class="settingslink" href="cockpit.html?view=shortlist" title="Shortlisted cars" aria-label="Shortlisted cars"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg><span id="starbadge" class="bellbadge"></span></a>
      <div class="bellwrap">
        <button id="bellbtn" class="bellbtn" onclick="toggleBell()" aria-label="Notifications" aria-haspopup="true" aria-expanded="false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span id="bellbadge"></span></button>
        <div id="bellpanel" class="bellpanel" hidden><p class="bellempty">Loading...</p></div>
      </div>
      {{SETTINGS_NAVLINK}}
      <div class="navuser" title="Mark, Right Drive">
        <div class="navuser-av">MN</div>
      </div>
    </div>
  </div>
  {{RUN_MODAL}}
  <div class="wrap">
    <div id="runbar" class="runbar" hidden><div class="runbar-row"><span id="runmsg" class="runmsg"></span><span class="runbar-right"><span id="runpct" class="runpct"></span><button type="button" id="stopbtn" class="stopbtn" onclick="stopRun()" title="Stop whatever is running now">Stop</button></span></div><div class="runtrack"><div id="runfill" class="runfill"></div></div></div>
    <div class="section-head">
      {{VIEWS_CHIP}}
      <div class="tabs platform-toggle" id="tabs">
        <button class="tab on" data-plat="all" onclick="setTab(this)" title="All" aria-label="All">All</button>
        <button class="tab" data-plat="Motorway" onclick="setTab(this)" title="Motorway" aria-label="Motorway"><img class="section-logo motorway" src="{{MW_LOGO}}" alt="Motorway"></button>
        <button class="tab" data-plat="Carwow" onclick="setTab(this)" title="Carwow" aria-label="Carwow"><img class="section-logo carwow" src="{{CW_LOGO}}" alt="Carwow"></button>
        <button class="tab" data-plat="Auction4Cars" onclick="setTab(this)" title="Auction4Cars" aria-label="Auction4Cars"><img class="section-logo auction4cars" src="{{A4C_LOGO}}" alt="Auction4Cars"></button>
        <button class="tab" data-plat="Dealer Auction" onclick="setTab(this)" title="Dealer Auction" aria-label="Dealer Auction"><img class="section-logo dealerauction" src="{{DA_LOGO}}" alt="Dealer Auction"></button>
      </div>
      <div class="section-controls">
        <button type="button" class="addcarbtn" onclick="openManualModal()" title="Add a car bought outside the platforms, a local trader for instance">
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>
          Add car
        </button>
        {{MORE_BTN}}
      </div>
    </div>
    {{FILTER_CHIPS}}
    <div id="dupbar" class="dupbar" hidden></div>
    <div id="list" class="tablewrap"><p class="empty">Loading...</p></div>
  </div>

  <div class="drawer-scrim" id="drawer-scrim" hidden onclick="closeDrawer()"></div>
  <div class="drawer" id="drawer" aria-label="Purchase record">
    <div class="dgrab" id="dgrab" title="Drag to resize"></div>
    <div class="dhead" id="dhead"></div>
    <div id="jstepwrap"></div>
    <div class="dscroll" id="dscroll"></div>
    <div class="dcomments" id="dcomments"></div>
  </div>

  <div class="pmodal" id="chipmodal" hidden onclick="if(event.target===this)closeChip()">
    <div class="pmbox">
      <h3>Record a chip</h3>
      <p class="pmsub">Knocks the agreed amount off the price. The winning bid stays on file exactly as
        the platform reported it, and every chip is logged to the car's own timeline.</p>
      <label class="pmlab" for="chipamt">How much</label>
      <input id="chipamt" type="number" min="0" step="5" oninput="paintChipPreview()" placeholder="0">
      <label class="pmlab" for="chipwhy">Why</label>
      <input id="chipwhy" type="text" placeholder="e.g. gearbox whine on the video">
      <div id="chippreview" class="chippreview"></div>
      <div class="pmact">
        <button type="button" class="ghost2" onclick="closeChip()">Cancel</button>
        <button type="button" class="go2" onclick="saveChip(this)">Record the chip</button>
      </div>
    </div>
  </div>

  <div class="pmodal" id="cancelmodal" hidden onclick="if(event.target===this)closeCancelModal()">
    <div class="pmbox">
      <h3>Cancel this sale</h3>
      <p class="pmsub">The car comes off the working list for good. It stays viewable under Cancelled,
        with the reason on file for when the platform asks for proof.</p>
      <label class="pmlab">What happened</label>
      <div id="cancelreasons"></div>
      <label class="pmlab" for="cancelwhy">Reason, for the record</label>
      <textarea id="cancelwhy" rows="3" placeholder="What was wrong, and anything you would need to show the platform"></textarea>
      <div class="pmact">
        <button type="button" class="ghost2" onclick="closeCancelModal()">Keep it</button>
        <button type="button" class="danger2" onclick="saveCancel(this)">Cancel the sale</button>
      </div>
    </div>
  </div>

  <div class="pmodal" id="manualmodal" hidden onclick="if(event.target===this)closeManualModal()">
    <div class="pmbox">
      <h3>Add a car by hand</h3>
      <p class="pmsub">For a car bought outside the platforms, a local trader for instance. Nothing ever
        overwrites what you type here, there is no platform behind it to sync from. Only Registration,
        Vehicle and Purchase price (marked *) are required, everything else can be filled in later.</p>

      <h4 class="pmsec">The car</h4>
      <div class="pmgrid">
        <div><label class="pmlab" for="man-reg">Registration <span class="pmreq">*</span></label>
          <input id="man-reg" type="text" placeholder="AB12 CDE"></div>
        <div><label class="pmlab" for="man-name">Vehicle <span class="pmreq">*</span></label>
          <input id="man-name" type="text" placeholder="Make, model and derivative"></div>
        <div class="pmspan"><label class="pmlab" for="man-mileage">Mileage</label>
          <input id="man-mileage" type="number" min="0" placeholder="0">
          <p class="pmhelp">Needed before this car can ever go to DealerKit, but not to add it here.</p></div>
      </div>

      <h4 class="pmsec">The purchase</h4>
      <div class="pmgrid">
        <div><label class="pmlab" for="man-price">Purchase price <span class="pmreq">*</span></label>
          <input id="man-price" type="number" min="0" placeholder="0" oninput="manFeeSummary()"></div>
        <div><label class="pmlab" for="man-date">Purchase date</label>
          <input id="man-date" type="date"></div>
        <div><label class="pmlab" for="man-supplier">Supplier</label>
          <div class="pmdd" id="man-supplier-wrap"></div></div>
        <div><label class="pmlab" for="man-retail">Retail estimate</label>
          <input id="man-retail" type="number" min="0" placeholder="What you will ask for it, if you know yet"></div>
        <div class="pmspan"><label class="pmlab">Fees paid, if any</label>
          <div class="pmfeerow">
            <div class="pmfeecol"><span class="pmsublab">Fee</span>
              <input id="man-fee" type="number" min="0" placeholder="0" oninput="manFeeSummary()">
              <label class="pmvat"><input type="checkbox" id="man-fee-vat" onchange="manFeeSummary()"> plus VAT at 20%</label></div>
            <div class="pmfeecol"><span class="pmsublab">Transport</span>
              <input id="man-transport" type="number" min="0" placeholder="0" oninput="manFeeSummary()">
              <label class="pmvat"><input type="checkbox" id="man-transport-vat" onchange="manFeeSummary()"> plus VAT at 20%</label></div>
          </div>
          <p class="pmtotals" id="man-totals"></p></div>
      </div>

      <h4 class="pmsec">Collection</h4>
      <div class="pmgrid">
        <div><label class="pmlab" for="man-person">Person collecting</label>
          <div class="pmdd" id="man-person-wrap"></div></div>
        <div><label class="pmlab" for="man-collection">Collection arranged</label>
          <input id="man-collection" type="datetime-local"></div>
        <div class="pmspan"><label class="pmlab" for="man-notes">Notes</label>
          <input id="man-notes" type="text" placeholder="Anything worth remembering"></div>
      </div>

      <div class="pmact">
        <button type="button" class="ghost2" onclick="closeManualModal()">Cancel</button>
        <button type="button" class="go2" onclick="saveManual(this)">Add the car</button>
      </div>
    </div>
  </div>


  <div id="gallery-overlay" class="gallery-overlay" hidden onclick="if(event.target===this)closeGallery()">
    <button type="button" class="gallery-close" onclick="closeGallery()" aria-label="Close"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="M6 6l12 12"></path></svg></button>
    <button type="button" class="gallery-nav gallery-prev" onclick="galleryStep(-1)" aria-label="Previous photo"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg></button>
    <img id="gallery-img" class="gallery-img" alt="">
    <button type="button" class="gallery-nav gallery-next" onclick="galleryStep(1)" aria-label="Next photo"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg></button>
    <div id="gallery-count" class="gallery-count"></div>
    <div id="gallery-thumbs" class="gallery-thumbs"></div>
  </div>
</div>
</div>
  {{MORE_DRAWER}}
  <div id="flash"></div>
  <script>{{GUARD_JS}}
    // The working list's own configuration, embedded at render time
    // (Steven 2026-08-27): the dropdown option lists per column, the
    // hidden columns, the journey flow flags and the fee band tables the
    // chip preview needs. The chip actually recorded is always computed
    // server side against the same tables, the embedded copy only drives
    // the live preview.
    // let, not const: adding a supplier from the Add car screen or a row's
    // own dropdown reassigns this from the endpoint's response, so a name
    // you just typed is offered again immediately rather than only after a
    // reload. Everything reads it through optList() below, so this is the
    // whole of that change.
    let DROPDOWNS = {{DROPDOWNS_JSON}};
    // The dealership's default column layout (order, widths, which columns
    // show), as saved. COLS_CFG is the live copy the page draws from; it
    // starts as the default and any view can carry its own (Steven
    // 2026-09-02: resizing, reordering or hiding a column "is supposed to
    // show as a custom view that can be saved and stacked on top of in the
    // same way changing a filter does").
    const COLS_BASE = {{COLS_CFG_JSON}};
    let COLS_CFG = JSON.parse(JSON.stringify(COLS_BASE));
    // One canonical string for a layout, so two layouts compare by value:
    // only the four keys a layout is made of, columns and keys sorted.
    function colsSnapshot(cfg){
      const out = {};
      Object.keys(cfg || {}).sort().forEach(k => {
        const c = cfg[k] || {}, o = {};
        ['pos', 'w', 'table', 'record'].forEach(f => { if(c[f] !== undefined && c[f] !== null) o[f] = c[f]; });
        if(Object.keys(o).length) out[k] = o;
      });
      return JSON.stringify(out);
    }
    const FLOW = {{FLOW_JSON}};
    const FEE_BANDS = {{FEE_BANDS_JSON}};
    const WHO = '{{WHO}}';
    let currentView = 'duein';
    let openRow = null;        // purchase id of the open record
    let pane = 'journey';      // the right panel: journey, timeline or dk
    let recordEvents = [];     // the open record's own timeline, newest first

    function optList(col){ return DROPDOWNS[col] || []; }
    function optFor(col, value){
      return optList(col).find(o => o.value === value) || null;
    }
    const DDCARET = '<svg class="ddcaret" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg>';
    function opill(col, value){
      if(value == null || value === ''){
        // A column with a default flagged option shows it, faded, when
        // nothing has been set by hand yet: an empty Video cell IS
        // "Awaiting confirmation", a dash was hiding the real state
        // (the default flag previously did nothing at all on the page,
        // caught in the 2026-08-27 UX pass).
        const d = optList(col).find(o => o.default && !o.retired);
        if(d) return '<span class="opill oc-' + d.colour + ' asdefault" ' +
          'title="The default, not set by hand yet">' + esc(d.value) + '</span>';
        return '<span class="cellempty">â€“</span>';
      }
      const o = optFor(col, value);
      return '<span class="opill oc-' + (o ? o.colour : 'grey') + '">' + esc(value) + '</span>';
    }
    function esc(s){
      return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
        .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }
    function colShown(col, where){
      const c = COLS_CFG[col];
      if(!c) return true;
      return c[where] !== false;
    }
    // Per view column tailoring (2026-08-27 UX pass): the working list
    // columns (video, who is collecting, paid, collection arranged...)
    // are noise on a car already in stock and worse on a cancelled sale,
    // where they pushed the one column that view exists for, the
    // cancellation reason, off the edge of the screen. Each view shows
    // only the columns that mean something there; the Fields menu's own
    // choices still apply on top within that.
    const VIEW_HIDE = {
      instock: ['duein', 'collection', 'video', 'person', 'paidfor', 'onfinance', 'aucstatus'],
      cancelled: ['stockstatus', 'aucstatus', 'duein', 'collection', 'video', 'person',
                  'paidfor', 'onfinance', 'history', 'v5', 'dk', 'location'],
    };
    function showCol(col){
      if((VIEW_HIDE[currentView] || []).includes(col)) return false;
      if(col === 'video' && !FLOW.video_stage) return false;
      return colShown(col, 'table');
    }
    // Column ORDER is the dealership's own too (Steven 2026-08-27, drag
    // the headers like Airtable): each column's cfg can carry pos (its
    // place in the row) and w (a dragged width, only ever set once a
    // person has actually resized it). Photo, Reg and Vehicle are fixed
    // first, the Cancelled column pinned last, everything between drags.
    function colPos(k){
      const c = COLS_CFG[k];
      if(c && typeof c.pos === 'number') return c.pos;
      const i = COLUMNS.findIndex(x => x[0] === k);
      return i === -1 ? 99 : i;
    }
    function orderedColKeys(){
      return COLUMNS.map(c => c[0]).sort((a, b) => colPos(a) - colPos(b));
    }
    // Fixed table layout needs a width for EVERY column, so a never
    // resized one uses a sane default and a dragged one uses its own
    // saved figure.
    const DEFAULT_W = {photo: 74, reg: 104, name: 230, platform: 112, stockstatus: 112,
      aucstatus: 122, bought: 112, duein: 120, collection: 168, video: 178, person: 152,
      paidfor: 62, onfinance: 132, notes: 200, location: 122, supplier: 148,
      price: 102, chips: 96,
      retail: 112, margin: 104, history: 132, v5: 152, dk: 92, cancelled: 210};
    function colWidth(k){
      const c = COLS_CFG[k];
      return c && typeof c.w === 'number' ? c.w : (DEFAULT_W[k] || 110);
    }
    const COL_LABELS = {platform: 'Platform', stockstatus: 'Status', aucstatus: 'Auc status',
      bought: 'Purchase date', duein: 'Due in date', collection: 'Collection arranged', video: 'Video',
      person: 'Person collecting', paidfor: 'Paid', onfinance: 'Finance', notes: 'Notes',
      location: 'Location', supplier: 'Supplier', price: 'Car price',
      chips: 'Chipped', retail: 'Retail est.',
      margin: 'Margin', history: 'Service history', v5: 'Logbook', dk: 'DealerKit'};
    function feeFor(platform, price){
      const t = FEE_BANDS[platform];
      if(!t || price == null) return null;
      for(const b of t){ if(price >= b[0] && price <= b[1]) return b[2]; }
      return null;
    }
    function bandOf(platform, price){
      const t = FEE_BANDS[platform];
      if(!t || price == null) return null;
      for(const b of t){ if(price >= b[0] && price <= b[1]) return b; }
      return null;
    }
    function rowById(id){ return ALL.find(r => r.id === id) || null; }
    function fmtStamp(iso){
      if(!iso) return '';
      const d = new Date(iso), n = new Date();
      const t = d.toLocaleTimeString('en-GB', {hour: 'numeric', minute: '2-digit'}).toLowerCase();
      if(d.toDateString() === n.toDateString()){
        const mins = Math.round((n - d) / 60000);
        if(mins < 1) return 'just now';
        if(mins < 60) return mins + ' min ago';
        return 'today, ' + t;
      }
      const p2 = x => String(x).padStart(2, '0');
      return p2(d.getDate()) + '/' + p2(d.getMonth() + 1) + '/' + d.getFullYear() + ', ' + t;
    }
    function fmtDT(s){
      if(!s) return '';
      const parts = String(s).split('T');
      const dp = parts[0].split('-');
      const day = dp.length === 3 ? dp[2] + '/' + dp[1] + '/' + dp[0] : parts[0];
      if(parts.length < 2 || !parts[1]) return day;
      const tp = parts[1].split(':');
      let hh = +tp[0]; const mm = tp[1] || '00';
      const ap = hh >= 12 ? 'pm' : 'am'; hh = hh % 12 || 12;
      return day + ', ' + hh + ':' + mm + ap;
    }
    // The one write path for a field edit: the server refuses anything
    // outside its own editable whitelist and logs the change to the
    // timeline, so the page never has a private way to mutate a car.
    function saveField(id, field, value, after){
      fetch('/api/set-purchase-field', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: id, field: field, value: value})
      }).then(r => r.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'Could not save.', false); return; }
        ALL = d.purchases || ALL;
        render();
        if(openRow != null){ loadRecordEvents(openRow); paintDrawer(); }
        if(after) after();
      }).catch(() => toast('Could not save.', false));
    }

    // The full gallery behind a photo thumbnail (Mark 2026-08-21: "apply
    // this to the purchases page"), same shared overlay pattern as the
    // cockpit's own card gallery, reused here for both the exterior photo
    // and the service history thumbnails. imgEl carries the JSON list of
    // urls in data-gallery, and an optional data-gallery-index to start
    // somewhere other than the first photo (a service history thumbnail
    // opens on the document actually clicked, not always the first one).
    let galleryUrls = [];
    let galleryIndex = 0;
    function openGallery(imgEl){
      try { galleryUrls = JSON.parse(imgEl.dataset.gallery || '[]'); } catch(e){ galleryUrls = []; }
      if(!galleryUrls.length) return;
      galleryIndex = parseInt(imgEl.dataset.galleryIndex || '0', 10) || 0;
      paintGallery();
      const ov = bbEl('gallery-overlay');
      if(ov) ov.hidden = false;
    }
    function closeGallery(){ const ov = bbEl('gallery-overlay'); if(ov) ov.hidden = true; }
    function galleryStep(dir){
      if(!galleryUrls.length) return;
      galleryIndex = (galleryIndex + dir + galleryUrls.length) % galleryUrls.length;
      paintGallery();
    }
    function galleryTo(i){ galleryIndex = i; paintGallery(); }
    function paintGallery(){
      const gi = bbEl('gallery-img');
      if(gi) gi.src = galleryUrls[galleryIndex];
      bbSetText('gallery-count', (galleryIndex + 1) + ' / ' + galleryUrls.length);
      bbSetHTML('gallery-thumbs', galleryUrls.map((u, i) =>
        '<img class="gallery-thumb' + (i === galleryIndex ? ' on' : '') + '" src="' + u +
        '" onclick="galleryTo(' + i + ')">').join(''));
    }
    document.addEventListener('keydown', e => {
      const ov = bbEl('gallery-overlay');
      if(!ov || ov.hidden) return;
      if(e.key === 'Escape') closeGallery();
      else if(e.key === 'ArrowLeft') galleryStep(-1);
      else if(e.key === 'ArrowRight') galleryStep(1);
    });
    let ALL = [];
    // Where this page's own numbers live, for the range sliders' bounds.
    // Margin is computed rather than stored, so it is resolved here too.
    function rangeValues(key){
      return ALL.map(r => key === 'margin'
          ? ((r.car_price != null && r.retail_estimate != null)
              ? r.retail_estimate - r.car_price : null)
          : r[key])
        .filter(v => v != null && v !== '')
        .map(Number).filter(n => !isNaN(n));
    }
    let currentTab = 'all';
    // Column sorting (Steven 2026-08-27: "i'd like to be able to sort the
    // columns on that page per column"). One key function per sortable
    // column, returning the real value that column shows so a numeric
    // column sorts numerically and a text one sorts as text, never
    // string-sorting a number by accident (which would put Â£9,000 before
    // Â£800). A row missing the value entirely always sorts to the very
    // end, whichever direction, the same "never let missing data look
    // like the smallest real value" rule the cockpit's own range filters
    // already follow.
    const SORT_KEYS = {
      reg: r => r.reg || null,
      name: r => r.name || null,
      platform: r => r.platform || null,
      stockstatus: r => r.stock_status || null,
      aucstatus: r => r.status || null,
      bought: r => r.bought_date || null,
      duein: r => r.collection_date || null,
      collection: r => r.collection_arranged || null,
      video: r => r.video_status || null,
      person: r => r.person_collecting || null,
      paidfor: r => r.paid_for ? 1 : null,
      onfinance: r => r.on_finance || null,
      notes: r => r.notes || null,
      location: r => r.location || null,
      supplier: r => (r.manual_entry ? r.supplier : r.platform) || null,
      // r.car_price alone, matching exactly what the Car price column
      // itself displays (the canonical figure from list_purchases, the
      // original bid less any chips), no fallback: the same "the sort
      // must match what the column actually shows" lesson the old
      // Winning bid sort learnt live before shipping.
      price: r => r.car_price,
      chips: r => r.chip_total || null,
      retail: r => r.retail_estimate,
      // Strictly both figures or nothing, the same rule the column itself
      // follows: a car with no retail estimate, or none of its own price
      // read yet, sorts last rather than looking like a zero margin.
      margin: r => (r.car_price != null && r.retail_estimate != null)
        ? r.retail_estimate - r.car_price : null,
      history: r => (r.service_history_photos || []).length + (r.document_paths || []).length,
      v5: r => (r.v5_photos || []).length + (r.v5_local_paths || []).length,
      dk: r => (r.dk_check && r.dk_check.vehicle_id) ? 1 : 0,
      cancelled: r => r.cancelled_at || null,
    };
    let sortKey = null, sortDir = 1;
    function compareRows(a, b){
      const key = SORT_KEYS[sortKey];
      const av = key(a), bv = key(b);
      const aMissing = av == null || av === '';
      const bMissing = bv == null || bv === '';
      if(aMissing && bMissing) return 0;
      if(aMissing) return 1;
      if(bMissing) return -1;
      if(typeof av === 'number' && typeof bv === 'number') return (av - bv) * sortDir;
      return String(av).localeCompare(String(bv)) * sortDir;
    }
    // Click to sort, click again to reverse, a THIRD click clears the
    // sort back to the view's own default order (before this there was
    // no way back once a column had been sorted, caught 2026-08-27).
    function onSortHeaderClick(key){
      if(sortKey === key && sortDir === -1){ sortKey = null; sortDir = 1; }
      else if(sortKey === key){ sortDir = -1; }
      else { sortKey = key; sortDir = 1; }
      render();
    }
    // Builds one <th>, carrying the sortable/sorted classes and the
    // direction arrow for whichever column is currently active. render()
    // rebuilds the whole table (thead included) on every call, so the
    // header's own sort state is baked into this string fresh each time
    // rather than patched onto existing DOM.
    function thLabel(key, label){
      const active = sortKey === key;
      const arrow = active ? '<span class="sortarrow">' + (sortDir === 1 ? 'â–¾' : 'â–´') + '</span>' : '';
      // Reg, Vehicle and the pinned Cancelled column resize but never
      // move; everything between drags to reorder, Airtable style.
      const movable = !['reg', 'name', 'cancelled'].includes(key);
      return '<th class="sortable' + (active ? ' sorted' : '') + '" data-col="' + key + '"' +
        (movable ? ' draggable="true" title="Drag to reorder, click to sort"' : '') +
        ' onclick="onSortHeaderClick(&#39;' + key + '&#39;)">' + label + arrow +
        '<span class="thgrip gl" title="Drag to resize"></span>' +
        '<span class="thgrip gr" title="Drag to resize"></span></th>';
    }
    // Header drag to reorder and grip to resize, wired ONCE by
    // delegation on the list container since render() rebuilds the whole
    // table on every paint. Both save through the same purchase_columns
    // config the Fields menu already uses, so order and widths hold on
    // every device.
    let dragCol = null, resizing = null;
    // The column layout belongs to the view you are on and saves to it
    // straight away, the way Airtable does (Steven 2026-09-02: a Custom
    // chip with nothing to reset was worse than no chip; "do we just
    // handle this the same way airtable does and it... just auto saves
    // the layout on that view forever until you change it?"). Filters
    // stay as they were: a change reads Custom until you name it. So the
    // layout never turns the chip Custom: on a named view it writes to
    // that view, on All cars it writes to the dealership's default layout.
    function saveColsCfg(){
      const snap = colsSnapshot(COLS_CFG);
      if(activeBaseView){
        const v = PVIEWS.find(x => x.name === activeBaseView);
        if(v){
          v.criteria = Object.assign({}, v.criteria || {}, {columns: snap});
          savePViews(() => { paintViewsList(); paintFieldsList(); });
          return;
        }
      }
      fetch('/api/purchase-page-settings', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({purchase_columns: COLS_CFG})
      }).then(r => r.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'The column layout did not save.', false); return; }
        Object.keys(COLS_BASE).forEach(k => delete COLS_BASE[k]);
        Object.assign(COLS_BASE, JSON.parse(JSON.stringify(COLS_CFG)));
        paintViewsList(); paintFieldsList();
      }).catch(() => toast('The column layout did not save.', false));
    }
    (function(){
      const list = bbEl('list');
      if(!list){
        // Without this early return a missing #list used to abort the WHOLE
        // remaining script at parse time, taking the bell, sync and every
        // onclick handler down with it (2026-08-28 audit).
        console.error('BidBrain: #list is missing, column drag and resize are off.');
        return;
      }
      // resize: the grip owns the pointer, never the sort click or drag
      list.addEventListener('pointerdown', e => {
        const grip = e.target.closest('.thgrip');
        if(!grip) return;
        e.preventDefault();
        e.stopPropagation();
        const ownTh = grip.closest('th');
        // A right grip resizes its own column, a left grip resizes the
        // PREVIOUS one, so pressing anywhere around a border works.
        const targetTh = grip.classList.contains('gl') ? ownTh.previousElementSibling : ownTh;
        if(!targetTh || !targetTh.dataset.col) return;
        // A press on a grip must never become a column drag: the native
        // drag SOURCE is the draggable th itself, so dragstart fires
        // with the th as its target, never the grip, and a target check
        // alone cannot tell a grip press from a header press (the bug
        // that made real mouse resizing dead while a synthetic pointer
        // test passed). Suspend draggable for the gesture and capture
        // the pointer so the move and release always reach us.
        let suspended = null;
        if(ownTh.getAttribute('draggable') === 'true'){
          ownTh.draggable = false;
          suspended = ownTh;
        }
        try { grip.setPointerCapture(e.pointerId); } catch(x){}
        const key = targetTh.dataset.col;
        const colEl = list.querySelector('col[data-col="' + key + '"]');
        resizing = {key: key, colEl: colEl, startX: e.clientX,
                    startW: targetTh.getBoundingClientRect().width, th: suspended};
        document.body.classList.add('dresizing');
      });
      document.addEventListener('pointermove', e => {
        if(!resizing) return;
        const w = Math.max(48, Math.min(700, Math.round(resizing.startW + e.clientX - resizing.startX)));
        resizing.w = w;
        if(resizing.colEl) resizing.colEl.style.width = w + 'px';
      });
      document.addEventListener('pointerup', () => {
        if(!resizing) return;
        document.body.classList.remove('dresizing');
        if(resizing.th) resizing.th.draggable = true;
        if(resizing.w){
          if(!COLS_CFG[resizing.key]) COLS_CFG[resizing.key] = {};
          COLS_CFG[resizing.key].w = resizing.w;
          saveColsCfg();
        }
        resizing = null;
      });
      document.addEventListener('pointercancel', () => {
        if(!resizing) return;
        document.body.classList.remove('dresizing');
        if(resizing.th) resizing.th.draggable = true;
        resizing = null;
      });
      // a click that started on the grip must never fire the sort
      list.addEventListener('click', e => {
        if(e.target.closest('.thgrip')){ e.stopPropagation(); }
      }, true);
      // reorder
      list.addEventListener('dragstart', e => {
        if(resizing){ e.preventDefault(); return; }
        const th = e.target.closest('th[draggable]');
        if(!th || e.target.closest('.thgrip')){ e.preventDefault(); return; }
        dragCol = th.dataset.col;
        e.dataTransfer.effectAllowed = 'move';
        try { e.dataTransfer.setData('text/plain', dragCol); } catch(x){}
      });
      list.addEventListener('dragover', e => {
        if(!dragCol) return;
        const th = e.target.closest('th[data-col]');
        if(!th || !th.hasAttribute('draggable') || th.dataset.col === dragCol) return;
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        const rect = th.getBoundingClientRect();
        const before = e.clientX < rect.left + rect.width / 2;
        list.querySelectorAll('th.dropbefore, th.dropafter').forEach(x =>
          x.classList.remove('dropbefore', 'dropafter'));
        th.classList.add(before ? 'dropbefore' : 'dropafter');
      });
      list.addEventListener('dragleave', e => {
        const th = e.target.closest('th');
        if(th) th.classList.remove('dropbefore', 'dropafter');
      });
      list.addEventListener('drop', e => {
        if(!dragCol) return;
        const th = e.target.closest('th[data-col]');
        list.querySelectorAll('th.dropbefore, th.dropafter').forEach(x =>
          x.classList.remove('dropbefore', 'dropafter'));
        if(!th || !th.hasAttribute('draggable') || th.dataset.col === dragCol){ dragCol = null; return; }
        e.preventDefault();
        const rect = th.getBoundingClientRect();
        const before = e.clientX < rect.left + rect.width / 2;
        // reorder over the FULL movable list, hidden columns keep their
        // relative places rather than being forgotten
        const full = orderedColKeys().filter(k => k !== dragCol);
        let at = full.indexOf(th.dataset.col);
        if(!before) at++;
        full.splice(at, 0, dragCol);
        full.forEach((k, i) => {
          if(!COLS_CFG[k]) COLS_CFG[k] = {};
          COLS_CFG[k].pos = i;
        });
        dragCol = null;
        render();
        saveColsCfg();
      });
      list.addEventListener('dragend', () => {
        dragCol = null;
        list.querySelectorAll('th.dropbefore, th.dropafter').forEach(x =>
          x.classList.remove('dropbefore', 'dropafter'));
      });
    })();
    // The page's own views (Steven 2026-08-27, the working list rebuild):
    // Due in is the live working list, every car not yet checked in and
    // not cancelled. In stock is the finished archive, checking a car in
    // moves it there and out of the way ("once they are in stock
    // everything else is handled in dealerkit itself, we still need a way
    // to see the old data though"). Cancelled keeps every fallen through
    // sale viewable with its reasons rather than deleted. All shows
    // everything at once. Independent of the platform tabs, which still
    // narrow within whichever view is active.
    // The slice chip in the head bar drives the page's own currentView, so
    // VIEW_HIDE and everything else that reads currentView is untouched.
    // currentView is now just a read of the Show filter, so everything that
    // already depended on it (VIEW_HIDE's column hiding, the Due in default
    // sort, the Cancelled column) carries on untouched.
    function syncViewFromStatus(){
      currentView = bbVal('filterstatus') || 'duein';
    }
    function pounds(n){
      if(n == null) return 'â€“';
      const pence = Math.round(Number(n) * 100) % 100 !== 0;
      return 'Â£' + Number(n).toLocaleString('en-GB',
        {minimumFractionDigits: pence ? 2 : 0, maximumFractionDigits: pence ? 2 : 0});
    }
    function poundsNeg(n){ return 'âˆ’' + pounds(n); }
    // A stored YYYY-MM-DD read out as DD/MM/YYYY, UK on every page.
    function ukDate(s){
      if(!s) return '';
      const dp = String(s).slice(0, 10).split('-');
      return dp.length === 3 ? dp[2] + '/' + dp[1] + '/' + dp[0] : String(s);
    }
    function pct(n){ return n == null ? 'â€“' : Number(n).toFixed(0) + '%'; }
    // Carwow's own status text comes through as a raw snake_case value
    // straight off its own listing state ("collection_arranged"), never
    // tidied up; Motorway's own is already a real sentence ("Checks
    // complete") from its own purchases screen. Only ever reformats the
    // display, never touches the underlying value anything else (like
    // _is_cancelled) still matches against (Mark 2026-08-25: "lets fix
    // the status's for carwow and motorway").
    function humanizeStatus(s){
      if(!s) return s;
      const spaced = s.replace(/_/g, ' ');
      return spaced.charAt(0).toUpperCase() + spaced.slice(1);
    }
    // A headline figure (Winning bid, Retail est.) with its own breakdown
    // tucked behind a small popover, shown on hover, or a tap on a touch
    // screen with no hover to trigger it (Mark 2026-08-25: "put the fee,
    // transport, vat, total paid on a modal that is viewable on hover of
    // winning bid, do the same to cazana 115% & margin modal on hover to
    // Retail Est"), so the table itself carries far fewer columns and
    // actually fits a small screen. rows is [label, value, bold?, cls?].
    function _infoCell(headline, rows){
      const body = rows.map(r => '<div class="info-row' + (r[2] ? ' total' : '') +
        (r[3] ? ' ' + r[3] : '') + '"><span>' + r[0] + '</span><span>' + r[1] + '</span></div>').join('');
      return '<span class="info-trigger" onclick="toggleInfo(this, event)">' + headline +
        '<span class="info-pop">' + body + '</span></span>';
    }
    // Retail est. as its own cell, not a plain _infoCell (Mark 2026-08-26:
    // a settings level cap on this figure, plus "add the ability to edit
    // it from the row if we want to price it manually lower"). Shows a
    // small badge when the settings cap has actually lowered this car's
    // own figure (C) or a manual override is in force (M), and a pencil
    // button that swaps the cell into an inline number input, saving
    // straight to /api/set-retail-estimate, which returns the whole
    // refreshed purchases list so the page repaints from one response.
    function _retailCell(r, margin){
      const overridden = r.retail_estimate_override != null;
      const capped = !!r.retail_estimate_capped;
      const rows = [];
      if(overridden){
        rows.push(['Manually set', 'Yes']);
        if(r.retail_estimate_raw != null) rows.push(['Formula figure', pounds(r.retail_estimate_raw)]);
      } else {
        rows.push(['Cazana 115%', pounds(r.cazana_115)]);
        if(capped) rows.push(['Before the cap', pounds(r.retail_estimate_raw)]);
      }
      rows.push(['Margin', pounds(margin), false, margin == null ? '' : (margin >= 0 ? 'good' : 'bad')]);
      const body = rows.map(x => '<div class="info-row' + (x[2] ? ' total' : '') +
        (x[3] ? ' ' + x[3] : '') + '"><span>' + x[0] + '</span><span>' + x[1] + '</span></div>').join('');
      const badge = overridden ? '<span class="retail-flag" title="Manually set">M</span>'
                  : (capped ? '<span class="retail-flag" title="Capped by the settings retail price cap">C</span>' : '');
      if(r.shell) return pounds(r.retail_estimate);
      return '<span class="retail-wrap" data-id="' + r.id + '">' +
        '<span class="info-trigger" onclick="toggleInfo(this, event)">' + pounds(r.retail_estimate) + badge +
        '<span class="info-pop">' + body + '</span></span>' +
        '<button type="button" class="retail-edit-btn" title="Set Retail est. manually" ' +
        'onclick="event.stopPropagation(); editRetail(this)">' +
        '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
        'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
        '<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg></button></span>';
    }
    function editRetail(btn){
      const wrap = btn.closest('.retail-wrap');
      const id = wrap.dataset.id;
      const current = ALL.find(r => String(r.id) === String(id));
      const val = current && current.retail_estimate_override != null ? current.retail_estimate_override
                : (current && current.retail_estimate != null ? current.retail_estimate : '');
      wrap.innerHTML = '<input type="number" class="retail-edit-input" value="' + val + '">' +
        '<button type="button" class="retail-save-btn" onclick="saveRetail(this,\'' + id + '\')">Save</button>' +
        '<button type="button" class="retail-clear-btn" onclick="saveRetail(this,\'' + id + '\',true)" ' +
        'title="Clear the manual figure, use the formula or cap again">Reset</button> ' +
        '<a href="#" onclick="event.preventDefault(); render();">Cancel</a>';
      wrap.querySelector('input').focus();
    }
    async function saveRetail(btn, id, clear){
      const wrap = btn.closest('.retail-wrap');
      const input = wrap.querySelector('input');
      const value = clear ? null : (input.value === '' ? null : parseFloat(input.value));
      btn.disabled = true;
      try {
        const r = await fetch('/api/set-retail-estimate', {
          method: 'POST', headers: {'Content-Type': 'application/json'},
          body: JSON.stringify({id, value})
        });
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'could not save');
        ALL = d.purchases || [];
        render();
      } catch(e){
        toast(e.message || 'Could not save.', false);
        btn.disabled = false;
      }
    }
    // Due in, editable the same way as Retail est. (Mark 2026-08-27,
    // "make the due in editable like the retail est"): a pencil button
    // swaps the cell into an inline date input, saving straight to
    // /api/set-collection-date, which returns the whole refreshed
    // purchases list so the page repaints from one response. dueInView
    // is the Due in view's own toggle state, declared further up this
    // script, still honoured here exactly as before for a car with
    // genuinely nothing on file at all.
    function _dueInCell(r){
      const overridden = r.collection_date_override != null;
      // dueInView (a plain on/off toggle) was replaced by the four-view
      // system (currentView) during the same day's purchases rebuild;
      // "the Due in view specifically" is now currentView === 'duein'.
      const display = r.collection_date ? fmtDT(r.collection_date)
                     : (currentView === 'duein' ? '<span class="due-unconfirmed">Not confirmed</span>' : 'â€“');
      const badge = overridden ? '<span class="retail-flag" title="Manually set">M</span>' : '';
      if(r.shell) return r.collection_date ? fmtDT(r.collection_date) : '<span class="cellempty">â€“</span>';
      return '<span class="retail-wrap dueid-wrap" data-id="' + r.id + '">' +
        '<span>' + display + badge + '</span>' +
        '<button type="button" class="retail-edit-btn" title="Set Due in date manually" ' +
        'onclick="event.stopPropagation(); editDueIn(this)">' +
        '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
        'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
        '<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg></button></span>';
    }
    function editDueIn(btn){
      const wrap = btn.closest('.dueid-wrap');
      const id = wrap.dataset.id;
      const current = ALL.find(r => String(r.id) === String(id));
      const val = current && current.collection_date ? current.collection_date : '';
      wrap.innerHTML = '<input type="date" class="retail-edit-input" value="' + val + '">' +
        '<button type="button" class="retail-save-btn" onclick="saveDueIn(this,\'' + id + '\')">Save</button>' +
        '<button type="button" class="retail-clear-btn" onclick="saveDueIn(this,\'' + id + '\',true)" ' +
        'title="Clear the manual date, use whatever the platform read again">Reset</button> ' +
        '<a href="#" onclick="event.preventDefault(); render();">Cancel</a>';
      wrap.querySelector('input').focus();
    }
    async function saveDueIn(btn, id, clear){
      const wrap = btn.closest('.dueid-wrap');
      const input = wrap.querySelector('input');
      const value = clear ? null : (input.value === '' ? null : input.value);
      btn.disabled = true;
      try {
        const r = await fetch('/api/set-collection-date', {
          method: 'POST', headers: {'Content-Type': 'application/json'},
          body: JSON.stringify({id, value})
        });
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'could not save');
        ALL = d.purchases || [];
        render();
      } catch(e){
        toast(e.message || 'Could not save.', false);
        btn.disabled = false;
      }
    }
    // The row's own DealerKit push status, a small fraction badge (Mark
    // 2026-08-27: "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... use the
    // traffic light system from red to green eg 1/10 (red) to 10/10
    // (green)"), matching the little job progress badge DealerKit's own
    // UI shows (a coloured pill, a partial ring, "done/total"). Replaces
    // the old plain grey ring/orange tick/green tick three state icon.
    function dkTrafficColor(pct){
      // Red at 0, amber at halfway, green at fully done, plain RGB lerp
      // rather than the page's own oklch tokens (not worth reproducing
      // oklch interpolation in JS for a small badge), amber matches the
      // #d39e00 already used elsewhere on this page for an in progress
      // state, green matches the brand accent's own plain hex fallback.
      const red = [208, 52, 44], amber = [211, 158, 0], green = [31, 138, 91];
      let a, b, t;
      if(pct <= 0.5){ a = red; b = amber; t = pct / 0.5; } else { a = amber; b = green; t = (pct - 0.5) / 0.5; }
      const mix = a.map((v, i) => Math.round(v + (b[i] - v) * t));
      return 'rgb(' + mix.join(',') + ')';
    }
    function dkFractionBadge(done, total){
      if(total <= 0) return '<span class="dk-fraction-badge" style="background:rgb(208,52,44)">0/0</span>';
      const pct = done / total;
      const color = dkTrafficColor(pct);
      const rad = 7, c = 2 * Math.PI * rad;
      const dash = (c * pct).toFixed(2);
      const ring = '<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">' +
        '<circle cx="9" cy="9" r="' + rad + '" fill="none" stroke="rgba(255,255,255,.4)" stroke-width="2.4"></circle>' +
        '<circle cx="9" cy="9" r="' + rad + '" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" ' +
        'stroke-dasharray="' + dash + ' ' + c.toFixed(2) + '" transform="rotate(-90 9 9)"></circle></svg>';
      return '<span class="dk-fraction-badge" style="background:' + color + '">' + ring +
        '<span class="dk-fraction-text">' + done + '/' + total + '</span></span>';
    }
    // Only the first photo shows as a clickable thumbnail (Mark
    // 2026-08-25: "only show one image of v5 and service history, to view
    // click and open modal"), the rest still sit behind it in the same
    // shared gallery modal (openGallery already opens on whichever photo
    // was clicked and pages through the full list), a small count badge
    // says there is more than one without needing a whole row of them.
    function _docCell(photos, galleryAttr, alt, emptyHtml){
      if(!photos.length) return emptyHtml || '<span class="num">â€“</span>';
      return '<span class="doc-thumb-wrap"><img class="thumb history-thumb" src="' + photos[0] +
        '" alt="' + alt + '" data-gallery=\'' + galleryAttr + '\' onclick="openGallery(this)">' +
        (photos.length > 1 ? '<span class="thumb-count">' + photos.length + '</span>' : '') + '</span>';
    }
    // Tap support for the info popovers above (CSS alone only ever covers
    // :hover, no help on a touch screen with no pointer to hover with):
    // toggles the one tapped, closes any other left open, and a tap
    // anywhere else on the page closes whatever is open, the same as a
    // real dropdown would.
    function toggleInfo(el, ev){
      ev.stopPropagation();
      const already = el.classList.contains('show');
      document.querySelectorAll('.info-trigger.show').forEach(t => t.classList.remove('show'));
      if(!already) el.classList.add('show');
    }
    document.addEventListener('click', () => {
      document.querySelectorAll('.info-trigger.show').forEach(t => t.classList.remove('show'));
    });
    // Carwow's own service history and V5 documents are stored as local
    // file paths on this Mac (document_paths, v5_local_paths), not web
    // addresses the way Motorway's own url based fields are (this page is
    // itself served by the same process that saved them, under the
    // project's own data/purchase_docs folder, which serve.py already
    // serves as a plain static file, checked live: a real local path
    // opened straight over HTTP with no extra route needed). This turns
    // one of those absolute local paths into the relative URL this page
    // can actually load, encoding each path segment separately so a
    // filename with spaces or brackets (a real seller's own WhatsApp
    // export, for example) still works, without encoding the slashes
    // between folders. Returns null for anything not under that folder,
    // never guessed.
    function localDocUrl(absPath){
      const marker = '/data/purchase_docs/';
      const i = (absPath || '').indexOf(marker);
      if(i === -1) return null;
      return absPath.slice(i).split('/').map(encodeURIComponent).join('/');
    }
    // Either a real url or a local file path, whichever this field
    // happens to hold, turned into something this page can actually
    // load (2026-08-26, Mark: "the motorway v5 images have a '?' ...
    // they dont load"). Motorway's own photo_url/photo_urls/service_
    // history_photos/v5_photos used to always be real urls; since
    // purchases_run.py now downloads them to a local file the moment
    // they are read (their own signed urls go dead within about an
    // hour), the exact same fields can now hold either, per row,
    // depending only on when that row was last captured.
    function mediaUrl(item){
      if(!item) return null;
      if(item.startsWith('http://') || item.startsWith('https://')) return item;
      return localDocUrl(item);
    }
    function setTab(btn){
      currentTab = btn.dataset.plat;
      // Scoped to #tabs specifically, not every .tab on the page: the page
      // switcher (Live Auctions/Purchases/Lost) reuses the same .tab class,
      // a bare '.tab' selector here would incorrectly clear its own "on"
      // state too (found live 2026-08-27).
      document.querySelectorAll('#tabs .tab').forEach(t => t.classList.toggle('on', t === btn));
      // Below 860px #tabs scrolls inside itself (2026-08-30 review), so the
      // one you just picked can end up off the strip, reloading with
      // DealerWay chosen for instance would leave it out of sight with no
      // clue you have to swipe to find it again.
      btn.scrollIntoView({inline:'nearest', block:'nearest'});
      render();
    }
    // Search (reg, make/model) plus a bought date range (Mark 2026-08-24:
    // "the page should have a filter for search by reg, make, model etc,
    // also date"), same shape as the cockpit's own sidebar search box, just
    // living in this page's control bar instead since a plain table has no
    // real need for a persistent sidebar.

    // Platform column as real logos (Mark 2026-08-27: "under the platform
    // colume use the auction house logos"), same assets and fallback shape
    // as the toggle bar just above. DealerWay has no purchase reader yet,
    // so it never reaches this table; any platform without a captured logo
    // still falls back to the old coloured text pill.
    const PLAT_LOGOS = {
      'Motorway': '{{MW_LOGO}}', 'Carwow': '{{CW_LOGO}}',
      'Auction4Cars': '{{A4C_LOGO}}', 'Dealer Auction': '{{DA_LOGO}}'
    };
    function platCell(platform){
      const logo = PLAT_LOGOS[platform];
      const cls = (platform || '').toLowerCase().replace(/\s+/g, '');
      // still carries the lowercased platform class in the no-logo case
      // (Manual, or any future platform without a captured asset), so
      // .plat.manual and friends keep colouring it rather than falling
      // through to an unstyled grey default.
      if(!logo) return '<span class="plat ' + cls + '">' + (platform || '') + '</span>';
      return '<img class="section-logo plat-logo ' + cls + '" src="' + logo + '" alt="' + platform + '" title="' + platform + '">';
    }
{{FRAME_JS}}
    // This page's own frame config. install_escape is off in _frame_js
    // above: this page owns a whole Escape ladder of its own (gallery,
    // menus, inline edits, modals, the record drawer) and calls
    // frameEscape() from inside it, so one press only ever closes one
    // layer. Declared before the first load call, never after (the TDZ
    // trap this file has hit before).
    function pSel(id, labelFn){
      return { ids: [id],
        label(){ const v = bbVal(id); return v ? bbEsc(labelFn ? labelFn(v) : v) : null; },
        clear(){ bbSetVal(id, ''); } };
    }
    function pRange(label, prefix){
      return { ids: [prefix + 'min', prefix + 'max'],
        label(){
          const lo = bbVal(prefix + 'min'), hi = bbVal(prefix + 'max');
          if(!lo && !hi) return null;
          if(lo && hi) return label + ' ' + pounds(Number(lo)) + ' to ' + pounds(Number(hi));
          return lo ? label + ' from ' + pounds(Number(lo)) : label + ' up to ' + pounds(Number(hi));
        },
        clear(){ bbSetVal(prefix + 'min', ''); bbSetVal(prefix + 'max', ''); } };
    }
    const FRAME = {
      title: 'Filters and fields',
      filters: [
        { ids: ['filterstatus'],
          label(){
            const v = bbVal('filterstatus');
            return (!v || v === 'duein') ? null
              : 'Showing ' + ({instock:'in stock', cancelled:'cancelled', all:'all'}[v] || v);
          },
          clear(){ bbSetVal('filterstatus', 'duein'); syncViewFromStatus(); } },
        { ids: ['filtercancelreason'],
          label(){ const v = bbVal('filtercancelreason');
            return v ? 'Cancelled: ' + v : null; },
          clear(){ bbSetVal('filtercancelreason', ''); } },
        { ids: ['datefrom', 'dateto'],
          label(){
            const f = bbVal('datefrom'), t = bbVal('dateto');
            if(!f && !t) return null;
            // "Purchased", not the old "Bought from 12 Aug": now that
            // Supplier exists as a real field, a chip reading "Bought
            // from ..." followed by a date would read as a trader's name.
            if(f && t) return 'Purchased ' + ukDate(f) + ' to ' + ukDate(t);
            return f ? 'Purchased on or after ' + ukDate(f) : 'Purchased up to ' + ukDate(t);
          },
          clear(){ bbSetVal('datefrom', ''); bbSetVal('dateto', ''); } },
        { ids: ['collfrom', 'collto'],
          label(){
            const f = bbVal('collfrom'), t = bbVal('collto');
            if(!f && !t) return null;
            if(f && t) return 'Collection ' + ukDate(f) + ' to ' + ukDate(t);
            return f ? 'Collection from ' + ukDate(f) : 'Collection up to ' + ukDate(t);
          },
          clear(){ bbSetVal('collfrom', ''); bbSetVal('collto', ''); } },
        pSel('filtervideo'), pSel('filterperson'), pSel('filterfinance'),
        pSel('filterlogbook'),
        pSel('filterpaid', v => v === 'yes' ? 'Paid for' : 'Not paid yet'),
        pSel('filterdocs', v => v === 'complete' ? 'Paperwork complete' : 'Paperwork missing'),
        pRange('Car price', 'filterprice'), pRange('Retail est.', 'filterretail'),
        pRange('Margin', 'filtermargin'),
        { ids: ['filtermilesmin', 'filtermilesmax'],
          label(){
            const lo = bbVal('filtermilesmin'), hi = bbVal('filtermilesmax');
            if(!lo && !hi) return null;
            const f = v => Number(v).toLocaleString('en-GB') + ' miles';
            if(lo && hi) return 'Mileage ' + Number(lo).toLocaleString('en-GB') + ' to ' + f(hi);
            return lo ? 'Mileage from ' + f(lo) : 'Mileage up to ' + f(hi);
          },
          clear(){ bbSetVal('filtermilesmin', ''); bbSetVal('filtermilesmax', ''); } },
        { ids: ['search'], q: true,
          label(){
            const q = (bbVal('search') || '').trim();
            return q ? 'â€œ' + bbEsc(q) + 'â€�' : null;
          },
          clear(){ bbSetVal('search', ''); } },
      ],
      apply(){ syncViewFromStatus(); render(); },
      // Fields, Views and the Sort mirror are painted when their own
      // section opens rather than on every table repaint.
      onSection(k){
        if(k === 'fields') paintFieldsList();
        else if(k === 'sort') paintSortList();
      },
    };
    // The drawer's Sort section mirrors the column headers: same sortKey
    // and sortDir, so whichever way it is set the two always agree.
    function paintSortList(){
      const el = bbEl('sortlist');
      if(!el) return;
      const cols = [['reg', 'Reg'], ['name', 'Vehicle']].concat(
        COLUMNS.filter(c => SORT_KEYS[c[0]]).map(c => [c[0], COL_LABELS[c[0]] || c[1]]));
      el.innerHTML = cols.map(c => {
        const on = sortKey === c[0];
        const arrow = on ? (sortDir === 1 ? ' â–´' : ' â–¾') : '';
        return '<button type="button" class="sortrow' + (on ? ' on' : '') + '" ' +
          'onclick="onSortHeaderClick(&#39;' + c[0] + '&#39;); paintSortList();">' +
          bbEsc(c[1]) + '<span class="rt">' + arrow + '</span></button>';
      }).join('') +
        '<p class="moredr-note">Click once for lowest first, again to reverse, ' +
        'a third time to go back to this viewâ€™s own order. The column ' +
        'headers do the same thing.</p>';
    }
    // The filter chain, lifted out of render and given its own value getter
    // (2026-08-30). Reading straight off the boxes meant it could only answer
    // "what matches what is typed in now", so a saved view's own count could
    // not be worked out without setting the boxes to it and putting them back.
    // get(id) supplies the values, so the live list and any view go through
    // one code path with no DOM writes at all.
    function filterRows(rows, get){
      const cr = get('filtercancelreason');
      if(cr) rows = rows.filter(r => cancelCategory(r) === cr);
      const q = (get('search') || '').trim().toLowerCase();
      if(q){
        rows = rows.filter(r => ((r.reg || '') + ' ' + (r.name || '')).toLowerCase().includes(q));
      }
      const from = get('datefrom');
      const to = get('dateto');
      // bought_date is a plain YYYY-MM-DD string on every row (both readers
      // build it that shape), so a straight string comparison sorts and
      // ranges correctly with no parsing needed. A row with no bought_date
      // yet (still pending) never matches an active date filter, same "never
      // match a filter on a value that was never read" rule the cockpit's
      // own range filters already follow.
      if(from){ rows = rows.filter(r => r.bought_date && r.bought_date >= from); }
      if(to){ rows = rows.filter(r => r.bought_date && r.bought_date <= to); }
      // Every other data point the working list carries. A car with the
      // field simply not filled in yet never matches an active filter on
      // it, so "Video requested" means genuinely requested, never "we have
      // not said". The date and money ranges follow the same rule.
      const collFrom = get('collfrom'), collTo = get('collto');
      if(collFrom || collTo){
        rows = rows.filter(r => {
          const d = (r.collection_arranged || r.collection_date || '').slice(0, 10);
          if(!d) return false;
          if(collFrom && d < collFrom) return false;
          if(collTo && d > collTo) return false;
          return true;
        });
      }
      const isFn = (field, val) => { if(!val) return; rows = rows.filter(r => (r[field] || '') === val); };
      isFn('video_status', get('filtervideo'));
      isFn('person_collecting', get('filterperson'));
      isFn('on_finance', get('filterfinance'));
      isFn('logbook_state', get('filterlogbook'));
      const fPaid = get('filterpaid');
      if(fPaid) rows = rows.filter(r => fPaid === 'yes' ? !!r.paid_for : !r.paid_for);
      const fDocs = get('filterdocs');
      if(fDocs){
        const hasBoth = r => ((r.service_history_photos || []).length + (r.document_paths || []).length) > 0
          && ((r.v5_photos || []).length + (r.v5_local_paths || []).length) > 0;
        rows = rows.filter(r => fDocs === 'complete' ? hasBoth(r) : !hasBoth(r));
      }
      const numRange = (val, prefix) => {
        const lo = get(prefix + 'min'), hi = get(prefix + 'max');
        if(!lo && !hi) return true;
        if(val == null) return false;   // never let a missing figure into a range
        if(lo && Number(val) < Number(lo)) return false;
        if(hi && Number(val) > Number(hi)) return false;
        return true;
      };
      const marginOf = r => (r.car_price != null && r.retail_estimate != null)
        ? r.retail_estimate - r.car_price : null;
      rows = rows.filter(r => numRange(r.car_price, 'filterprice')
        && numRange(r.retail_estimate, 'filterretail')
        && numRange(marginOf(r), 'filtermargin')
        && numRange(r.mileage, 'filtermiles'));
      // Show (Steven 2026-08-27, made a real filter 2026-08-30). Due in is the
      // working list, driven by the record's OWN stock_status and
      // cancellation, never inferred from the platform's coarse status word.
      // Checking a car in is what archives it, and a cancelled sale leaves
      // every view except Cancelled and All.
      const st = get('filterstatus') || 'duein';
      if(st === 'duein'){
        rows = rows.filter(r => !r.cancelled_at && r.stock_status !== 'In stock');
      } else if(st === 'instock'){
        rows = rows.filter(r => !r.cancelled_at && r.stock_status === 'In stock');
      } else if(st === 'cancelled'){
        rows = rows.filter(r => !!r.cancelled_at);
      }
      return rows;
    }
    // Which of the dealership's OWN cancel reasons a row counts as
    // (Steven 2026-08-30). The three come from the same configured list the
    // Cancel this sale dialog offers, so the filter and the dialog can never
    // drift. A cancellation that came from the platform is always the
    // customer's: "they only ever cancel because the customer told them to".
    function platformCancelReason(){
      const l = optList('cancelled_reason').filter(o => !o.retired).map(o => o.value);
      return l.find(v => /^customer/i.test(v)) || l[l.length - 1] || 'Customer cancelled before collection';
    }
    function cancelCategory(r){
      if(!r || !r.cancelled_at) return '';
      const own = (r.cancelled_reason || '').split(' Â· ')[0].trim();
      const known = optList('cancelled_reason').map(o => o.value);
      // set by hand through the Cancel this sale dialog, so it is already
      // one of the three; anything else came off a platform read
      return known.includes(own) ? own : platformCancelReason();
    }
    let cancelReasonsFilled = false;
    function fillCancelReasons(){
      if(cancelReasonsFilled) return;
      const sel = bbEl('filtercancelreason');
      if(!sel) return;
      const opts = optList('cancelled_reason').filter(o => !o.retired).map(o => o.value);
      if(!opts.length) return;
      sel.innerHTML = '<option value="">All</option>' +
        opts.map(v => '<option value="' + esc(v) + '">' + esc(v) + '</option>').join('');
      cancelReasonsFilled = true;
    }
    // Only meaningful while you are looking at cancelled cars, so it is only
    // offered then (Steven 2026-08-30). Cleared on the way out so a stale
    // choice cannot silently narrow another view.
    function syncCancelReasonField(){
      const f = bbEl('cancelreasonfield'), sel = bbEl('filtercancelreason');
      const on = bbVal('filterstatus') === 'cancelled';
      if(f) f.hidden = !on;
      if(!on && sel && sel.value){ sel.value = ''; return true; }
      return false;
    }
    // The double win warning, drawn from ALL rather than the filtered
    // view: a car won twice must not be hidden by the Motorway tab or a
    // price filter. One line per car, the sentence written once on the
    // server (db.double_wins) so this page, the DealerKit refusal and
    // Dealer OS all say the same thing. It clears itself: cancel one
    // sale and the pair stops being a pair.
    function paintDoubleWins(){
      const bar = bbEl('dupbar');
      if(!bar) return;
      const seen = new Set(), lines = [];
      (ALL || []).forEach(r => {
        const d = r.double_win;
        if(!d || seen.has(d.reg)) return;
        seen.add(d.reg);
        lines.push('<li>' + bbEsc(d.advice || (d.reg + ' was won twice.')) + '</li>');
      });
      if(!lines.length){ bar.hidden = true; bar.innerHTML = ''; return; }
      bar.hidden = false;
      bar.innerHTML = '<h4>' + (lines.length === 1 ? 'One car has been won twice'
        : lines.length + ' cars have been won twice') +
        '</h4><ul>' + lines.join('') + '</ul>' +
        '<ul><li>Cancel the sale you are dropping on the platform itself, then cancel it here. ' +
        'Until then neither car goes to accounts or to DealerKit.</li></ul>';
    }
    function render(){
      fillCancelReasons();
      syncCancelReasonField();
      syncViewFromStatus();
      paintDoubleWins();
      let rows = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab);
      rows = filterRows(rows, bbVal);
      // After filtering, so the chip's own count is the real number of rows
      // on screen rather than one worked out again from the same inputs.
      paintViewsList(rows.length);
      // The working list's own default order: soonest collection first,
      // undated cars at the end (nothing real to sort them by), unless a
      // column sort has been chosen explicitly.
      if(currentView === 'duein' && !sortKey){
        rows = rows.slice().sort((a, b) => {
          const ad = a.collection_arranged || a.collection_date;
          const bd = b.collection_arranged || b.collection_date;
          if(!ad && !bd) return 0;
          if(!ad) return 1;
          if(!bd) return -1;
          return ad < bd ? -1 : ad > bd ? 1 : 0;
        });
      }
      const list = bbEl('list');
      if(!list){
        console.error('BidBrain: #list is missing, the purchases table has nowhere to draw.');
        return;
      }
      if(!rows.length){
        // The empty state names what is actually narrowing the list.
        const act = activeFrameFilters().map(x => x.label);
        let why = 'No purchases recorded yet.';
        if(ALL.length){
          why = 'No purchases match ' + (act.length
            ? 'these filters: ' + act.join(', ') + '.'
            : 'the current view.');
        }
        list.innerHTML = '<p class="empty">' + bbEsc(why) + '</p>';
        paintFrame(0);
        return;
      }
      if(sortKey){ rows = rows.slice().sort(compareRows); }
      const activeCols = orderedColKeys().filter(showCol);
        const body = rows.map(r => {
        // The canonical money figures, computed ONCE in db.list_purchases
        // (2026-08-27) and only ever read here: car_price is the original
        // bid less any chips (what the car itself costs), all_in is
        // car_price plus every fee, what actually leaves the bank,
        // falling back to the platform's own total for an old row with no
        // breakdown. This page never re derives either from the raw
        // columns again.
        const paid = r.all_in;
          // Margin: retail est. less the car's own price (post chip), fees
        // are cost of sale, not part of what the car itself made.
        const bid = r.car_price != null ? r.car_price : paid;
        let margin = null;
        if(bid != null && r.retail_estimate != null){
          margin = r.retail_estimate - bid;
          }
        // Bias bid (Mark 2026-08-25: "add the ai suggested bid (Bias) to
        // the winning bid modal and show % of bias bid compared to
        // winning bid"): what CompMatch would suggest for this exact car
        // today (r.bias_bid, computed server side in serve.py against the
        // real current comp history, see _purchases_with_bias), and what %
        // of that the real winning bid actually came to, under 100% means
        // the car was won for LESS than CompMatch's own suggestion, a good
        // outcome. None, never guessed, when there is not enough real
        // comparable history for this car's model yet.
        let biasPct = null;
        if(bid != null && r.bias_bid){
          biasPct = (bid / r.bias_bid) * 100;
        }
        // The full exterior gallery sits behind this thumbnail the same way
        // it does on the cockpit (Mark 2026-08-21: "apply this to the
        // purchases page"), opened in the same shared modal. Falls back to
        // just the one known photo_url if the fuller gallery was never
        // read (an older purchase whose original listing has since come
        // off the platform), so the thumbnail is still clickable rather
        // than dead. The reverse fallback also matters (Mark 2026-08-24,
        // "3 pictures arent showing in the purchase section"): Carwow's
        // own purchases only ever capture the full gallery, never a
        // separate single photo_url the way Motorway's own reader does,
        // so the thumbnail itself used to stay blank despite the gallery
        // genuinely being there and clickable once opened.
        const gallery = ((r.photo_urls && r.photo_urls.length) ? r.photo_urls : (r.photo_url ? [r.photo_url] : []))
          .map(mediaUrl).filter(Boolean);
        const thumbSrc = mediaUrl(r.photo_url) || (gallery.length ? gallery[0] : null);
        const thumb = thumbSrc
          ? '<img class="thumb' + (gallery.length > 1 ? ' gallery-open' : '') + '" src="' + thumbSrc +
            '" alt="" data-gallery=\'' + JSON.stringify(gallery).replace(/'/g, '&#39;') + '\'' +
            (gallery.length > 1 ? ' title="Click for all ' + gallery.length + ' photos"' : '') +
            (gallery.length ? ' onclick="openGallery(this)"' : '') + '>'
          : '<span class="thumb blank">No photo</span>';
        // Auc Status as a 3 stage progress tracker (Mark 2026-08-26:
        // "under the auc status change this to 3 icons for biding bid,
        // Documents, delivery"), replacing the old single traffic light
        // dot plus plain status text. Derived purely from the real
        // status word already on file for each platform, no new data
        // capture needed, checked against the real values actually seen
        // (Motorway: Awaiting confirmation, Reviewing documents, Checks
        // complete, Delivery requested, Delivered, Complete; Carwow:
        // quote_accepted, collection_arranged, collected). Bid always
        // shows done, every row here already represents a won purchase,
        // the fixed starting point of the tracker; Documents and
        // Delivery are the two real stages that actually progress.
        // Dealer Auction added 2026-08-27: its own real status word here
        // is just "Complete", a single flat state with no finer Documents/
        // Delivery sub-stages the way Motorway's and Carwow's own status
        // vocabularies have, so it is deliberately NOT funnelled through
        // Carwow's branch below (found live: "complete" is not "quote_
        // accepted" so Documents would have silently read done, a
        // misleading guess, not a real signal), reads honestly "Not read
        // yet" for both stages instead, same as an unrecognised platform.
        const auLower = (r.status || '').toLowerCase();
        // Bid was always shown done until Mark caught a real counter
        // example (2026-08-27): "BL11HAM is showing in our purchases but
        // the bid hasnt been accepted yet ... we know the bid has been
        // accepted when the MW status on its portal changes to reviewing
        // docuements". This is the purchases page's own status, scoped
        // to THIS page only: Motorway's "Awaiting confirmation" genuinely
        // means the win has not actually been accepted yet even though
        // the car already sits in the completed purchases screen; anything
        // past it counts as accepted. Carwow's own real flow starts
        // already accepted (quote_accepted), and no earlier real status
        // has ever been seen for it or any other platform here, so
        // everything else still defaults to done, same as before. (A
        // second, separate idea, showing this same "Awaiting confirmation"
        // set of cars on the Under Offer page too, was tried and then
        // reverted the same day, Mark: "BL11HAM need to be removed from
        // the under offer section as its not matching the mw portal", the
        // Under Offer page shows only what Motorway's and Carwow's own
        // real Under Offer screens show, nothing from the purchases
        // table.)
        const auBidDone = r.platform === 'Motorway' ? (auLower !== 'awaiting confirmation') : true;
        const auDocsDone = (!auLower || r.platform === 'Dealer Auction') ? null :
          (r.platform === 'Motorway' ? !(auLower === 'awaiting confirmation' || auLower.includes('review'))
                                      : auLower !== 'quote_accepted');
        // A real collection_date on file (2026-08-26, Mark: "i dont
        // think the MW delivery is wired up correctly... KF66GJZ &
        // AY66WFW for examples have delivery dates arranged but are
        // orange") is a far more direct, more current signal than the
        // coarse status word alone: Motorway's own main purchases list
        // status can still read "Checks complete" for a while after its
        // OWN delivery timeline has already scheduled a real collection
        // date, the two are read from separate pages and do not update
        // in lock step. So collection_date, when known, wins outright
        // regardless of platform; the status word is only a fallback
        // for the (now rarer) case where no collection_date has been
        // captured yet at all.
        const auDeliveryDone = r.collection_date ? true :
          ((!auLower || r.platform === 'Dealer Auction') ? null :
          (r.platform === 'Motorway' ? (auLower.includes('deliver') || auLower === 'complete')
                                      : (auLower === 'collection_arranged' || auLower === 'collected')));
        // A distinct glyph per stage (Mark 2026-08-26: "change the bid
        // won to icon Â£... deliver to a document icon... delivery to a
        // car icon"), the same filled circle badge shape dkTickSvg
        // already uses elsewhere on this page, just with a per stage
        // glyph in place of the tick and, new here, a genuine third
        // colour: green (var(--icon), matching every other confirmed
        // mark on this page) once reached, ORANGE (#d39e00, the same
        // real hex the old traffic light dot used for "still waiting on
        // something") while known but not yet reached, same dim grey as
        // everywhere else on an unread status, never guessed.
        const auColor = (done) => done === true ? 'var(--icon)' : (done === false ? '#d39e00' : 'var(--line)');
        const auBadge = (glyph, color) => '<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">' +
          '<circle cx="12" cy="12" r="10" fill="' + color + '"></circle>' + glyph + '</svg>';
        const auPoundGlyph = '<text x="12" y="16.5" text-anchor="middle" font-size="12" font-weight="800" ' +
          'font-family="Arial, Helvetica, sans-serif" fill="#fff">Â£</text>';
        const auDocGlyph = '<path d="M9 6.5h4l2 2v8.5a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V7.5a1 1 0 0 1 1-1z" ' +
          'fill="none" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"></path>' +
          '<path d="M9.6 12h4.8M9.6 14.7h4.8" stroke="#fff" stroke-width="1.2" stroke-linecap="round"></path>';
        const auCarGlyph = '<path d="M5.5 15.5h13M7 15.5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8zM17 15.5a1.4 1.4 0 1 1 0-2.8 1.4 1.4 0 0 1 0 2.8z" ' +
          'fill="none" stroke="#fff" stroke-width="1.2"></path>' +
          '<path d="M5.8 13v-2.6a1 1 0 0 1 1-1h1.1l1.2-1.9h5.8l1.2 1.9h1.1a1 1 0 0 1 1 1V13" ' +
          'fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"></path>';
        // No native hover tooltip on the icons themselves any more (Mark
        // 2026-08-27, "i want to remove the rollover on the status and
        // make the tool tip infos appear like this"): the whole group is
        // wrapped in the same click/tap opened popover already used for
        // Winning bid and Retail est. on this page (_infoCell), so the
        // detail is a deliberate click away, not an accidental hover.
        const auStage = (glyph, done) => '<span class="au-stage">' + auBadge(glyph, auColor(done)) + '</span>';
        const auStageNote = (done, doneNote, checkingNote) =>
          done === true ? doneNote : (done === false ? checkingNote : 'Not read yet');
        const statusHead = '<span class="au-stages">' +
          auStage(auPoundGlyph, auBidDone) +
          auStage(auDocGlyph, auDocsDone) +
          auStage(auCarGlyph, auDeliveryDone) +
          '</span>';
        const auRows = [
          ['Bid', auStageNote(auBidDone, 'Bid Accepted', 'Awaiting confirmation'), false,
            auBidDone === true ? 'good' : 'est'],
          ['Documents', auStageNote(auDocsDone, 'Documents Checked', 'Checking'), false,
            auDocsDone === true ? 'good' : (auDocsDone === false ? 'est' : '')],
          ['Delivery', auStageNote(auDeliveryDone, 'Delivery arranged', 'Delivery needs arranging'), false,
            auDeliveryDone === true ? 'good' : (auDeliveryDone === false ? 'est' : '')],
          ['Status', r.status ? humanizeStatus(r.status) : 'Not read yet'],
        ];
        if(r.payment_estimated) auRows.push(['Payment', 'Estimated breakdown', false, 'est']);
        const statusCell = _infoCell(statusHead, auRows);
        // Service history document photos (Mark 2026-08-21, "grab the
        // service history images and store in the database"), small
        // clickable thumbnails opening in the same shared gallery modal
        // as the exterior photo above. Motorway's own are already web
        // urls (service_history_photos); Carwow's are local file paths
        // on this Mac (document_paths, its own signed document bundle
        // downloaded and extracted at capture time rather than kept as a
        // short lived link, see CLAUDE.md), turned into a real loadable
        // url by localDocUrl so both platforms show real thumbnails here,
        // not just Motorway's (Mark 2026-08-24: "you havent pulled the
        // service history in from carwow purchases", found live to
        // already be captured and stored, just never shown, this column
        // only ever read the url based field). "-" when the car has none
        // on file either way.
        const historyPhotos = (r.service_history_photos || []).map(mediaUrl).filter(Boolean)
          .concat((r.document_paths || []).map(localDocUrl).filter(Boolean));
        const historyGalleryAttr = JSON.stringify(historyPhotos).replace(/'/g, '&#39;');
        // an honest absence, never a blank (Steven 2026-08-27: "if a car
        // has no service history, instead of being blank it needs to say
        // No Service History")
        const historyCell = _docCell(historyPhotos, historyGalleryAttr, 'Service history page',
          '<span class="noval">No service history</span>');
        // V5 (Mark 2026-08-24: "add a column for V5 and pull that in
        // ready to push to DK when we map that"), same shape as service
        // history above: Motorway's own are urls (v5_photos), Carwow's
        // are local paths (v5_local_paths, the same document bundle),
        // both now shown as real clickable thumbnails rather than just a
        // page count now that a local path is known to be loadable here.
        const v5Photos = (r.v5_photos || []).map(mediaUrl).filter(Boolean)
          .concat((r.v5_local_paths || []).map(localDocUrl).filter(Boolean));
        const v5GalleryAttr = JSON.stringify(v5Photos).replace(/'/g, '&#39;');
        const v5Cell = _docCell(v5Photos, v5GalleryAttr, 'V5 page');
        // DealerKit push status per row (Mark 2026-08-24, redone
        // 2026-08-26: "the roll over on the row sync status, should have
        // the green tick not 'Pushed' when its present on DK, it should
        // also have Car, Purchase Price, Auction Fee, Delievey,
        // Indemity, Photos, Service, V5, Retail price, Due In viewable
        // as if they are present on the DK record"). A tick per item
        // when a real Check DK record has confirmed it is there, a dash
        // when checked and genuinely not there, a "?" for the five items
        // this build cannot yet tell apart safely (auction_fee/delivery/
        // indemnity/service/v5 are always null until DealerKit's own
        // field names for expense categories and document types are
        // verified live, see check_dealerkit_record's own docstring).
        // Falls back to the old push-attempt flags only to colour the
        // row's own icon before any check has ever been run; once
        // r.dk_check exists it is the one source of truth shown here,
        // "present on DK" not "did BidBrain try".
        const dkItems = ['price', 'photos', 'documents', 'retail', 'due_in_date'];
        const dc = r.dk_check;
        const dkOnFile = dc && dc.vehicle_id;
        // The green circle tick (Mark 2026-08-26: "use the same green
        // circle tick as completed"), a filled circle plus white tick,
        // used wherever this popover shows something as genuinely
        // confirmed present (the row's own master status moved to a
        // coloured fraction badge on 2026-08-27, dkFractionBadge, these
        // per item icons inside the popover are unrelated and unchanged).
        // dkRingSvg is the same shape with no fill and no tick, just an
        // outline in whatever colour is passed, used for a confirmed
        // absence (Mark 2026-08-26, on V5: "the circle outline should be
        // red") so every state in this popover reads as one consistent
        // circle language rather than a mix of icons, dashes and text.
        function dkTickSvg(){
          return '<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="var(--icon)"></circle>' +
            '<path d="M7.5 12.5l3 3 6-6.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
        }
        function dkRingSvg(color){
          return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="' + color + '" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle></svg>';
        }
        // checkbox, when given, replaces the plain present/absent/unknown
        // mark with a real tick box (Mark 2026-08-26: "make the upload
        // tick boxes appear where the tick for uploaded is"). Ticking it
        // now goes BLUE rather than green (Mark: "when i select the
        // missing purchase price its selected green this looks confusing
        // ... this should go blue", green was already taken by "confirmed
        // present" everywhere else in this popover, ticking something new
        // to push is a different idea and needed its own colour); left
        // unticked it still shows what it actually is, an amber outline
        // when BidBrain has something to offer that is not yet confirmed
        // on DealerKit ("if its present in BB then make the circle
        // outline orange"), or a green outline when it is already there
        // (ticking again would be a deliberate re push, not the default),
        // both via the checkbox's own data-ondk attribute, see dkBox.
        function dkRow(label, present, detail, checkbox, note){
          // Not applicable to this purchase at all (2026-08-26, the
          // indemnity fee: "if this is not present on the purchase ...
          // dont show it as out standing"), a genuinely different idea
          // to "missing"; a plain dim dash, excluded from the row's own
          // master icon (see doneVals below), never counted against it.
          if(present === 'na'){
            const nt = note ? ' title="' + String(note).replace(/"/g, '&quot;') + '"' : '';
            return '<div class="info-row dkpresence-row dim"' + nt + '><span>' + label + '</span><span class="dkmark">â€“</span></div>';
          }
          const cls = present === true ? 'good' : (present === false ? 'absent' : 'dim');
          let mark;
          if(checkbox){
            const checked = checkbox.checked ? ' checked' : '';
            const dis = checkbox.disabled ? ' disabled' : '';
            const ondk = checkbox.onDk ? ' data-ondk="1"' : '';
            mark = '<input type="checkbox" class="dkcheck-toggle dkupload-check" value="' + checkbox.key + '"' +
              checked + dis + ondk + ' title="' + String(checkbox.title).replace(/"/g, '&quot;') +
              '" onclick="event.stopPropagation()">';
          } else {
            // A tool tip on every status, not just the ones with a real
            // figure to show (Mark 2026-08-26: "add a tool tip for each
            // status on the ticks on roll over"). A genuinely confirmed
            // absence and a never-checked "?" both get the same red ring
            // (Mark, on V5 sitting unverified: "which seems to be missing
            // the circle outline should be red"), from where you are
            // looking at this row both mean "not confirmed on DealerKit",
            // the real difference between the two stays in the tool tip
            // rather than a second colour to learn. note, when given,
            // overrides that generic wording (used by Due in below, where
            // a red ring can mean something BidBrain itself is missing,
            // never actually about what DealerKit says).
            const statusTitle = note || (present === true ? 'Confirmed on DealerKit.' + (detail ? ' ' + detail + '.' : '') :
              (present === false ? 'Not found on DealerKit.' : 'Not checked yet, use Check DK record or Upload to DK.'));
            mark = present === true ? dkTickSvg() : dkRingSvg('var(--no)');
            detail = statusTitle;
          }
          const t = detail ? ' title="' + String(detail).replace(/"/g, '&quot;') + '"' : '';
          return '<div class="info-row dkpresence-row ' + cls + '"' + t + '><span>' + label + '</span><span class="dkmark">' + mark + '</span></div>';
        }
        // BidBrain's own local data per pushable category, and whether the
        // last real Check DK record (if any) found it already there.
        // Ticked by default exactly when BidBrain has something of that
        // kind to push AND it is not already confirmed present; disabled
        // when BidBrain has nothing of that kind to push at all. No check
        // has ever been run yet (dc is null), or checked and genuinely
        // absent (dc but no vehicle_id), both read as "missing", the same
        // safe default a fresh row starts from.
        const dkPriceBB = r.winning_bid != null || r.price != null;
        const dkPhotosBB = !!((r.photo_urls && r.photo_urls.length) || r.photo_url);
        const dkDocsBB = !!((r.service_history_photos && r.service_history_photos.length) ||
                            (r.v5_photos && r.v5_photos.length) ||
                            (r.document_paths && r.document_paths.length) ||
                            (r.v5_local_paths && r.v5_local_paths.length));
        const dkPriceOnDk = dkOnFile ? (dc.purchase_cost != null) : (dc ? false : null);
        const dkPhotosOnDk = dkOnFile ? (dc.images > 0) : (dc ? false : null);
        const dkDocsOnDk = dkOnFile ? (dc.documents > 0) : (dc ? false : null);
        // Retail Price, BidBrain's own retail_estimate (Mark 2026-08-26:
        // "the Retail Est is the retail price that should be mapped to
        // DK", "BB calculates the retail est based on cazana 115%, this
        // is the retail price that is then used in DK"), a genuine
        // reversal of the old "always manual, never touched" rule.
        const dkRetailBB = r.retail_estimate != null;
        const dkRetailOnDk = dkOnFile ? (dc.retail_price != null) : (dc ? false : null);
        // Due in reads red whenever BidBrain itself has no due in date on
        // file at all (2026-08-26, Mark: "update the DK status to reflect
        // that if no due in date is in BB then its red"), regardless of
        // what a Check DK record happens to say about DealerKit's own
        // life cycle stage, there is nothing real behind that tick until
        // BidBrain actually knows a date to give it.
        const dkDueDateKnown = !!r.collection_date;
        const dkDueInPresent = dkDueDateKnown ? (dkOnFile ? dc.due_in : (dc ? false : null)) : false;
        // Whether DealerKit's own Due In / In-stock On date field (2026-08-26,
        // Mark: "lets now wire up the due in date in BB to DK", found live on
        // the Miscellaneous edit panel) is set, separate from dkDueInPresent
        // above, which is about the vehicle's own current stock STAGE, not
        // this date figure. Unlike Retail Price, pushing this always keeps
        // it in sync with BidBrain's own latest collection_date rather than
        // refusing once DealerKit has any value (a due in date is a plain
        // fact, not a market judgement call), so no blockOnDk here.
        const dkDueInDateOnDk = dkOnFile ? dc.due_in_date : (dc ? false : null);
        // Motorway and Carwow both read a collection date now (2026-08-26),
        // no equivalent reader exists for any other platform yet (see
        // db.py's own collection_date migration comment), so the note only
        // ever points at the one real action that could actually fix this.
        const dkDueInNote = dkDueDateKnown ? null : (r.platform === 'Motorway' || r.platform === 'Carwow'
          ? 'No due in date known in BidBrain yet, use Check ' + r.platform + ' to read it.'
          : 'BidBrain does not read a due in date for ' + r.platform + ' purchases yet.');
        // The indemnity fee (Motorway Protect) is genuinely optional, not
        // every Motorway purchase takes it, and Carwow has no equivalent
        // at all (2026-08-26, Mark: "if this is not present on the
        // purchase in on the auction platform dont show it as out
        // standing ... we do pay this for motorway but not carwow so if
        // you see it on the purchase record then add it and mark it
        // off"). Confirmed live against the real data: protect_fee is
        // null for every one of 23 Carwow purchases, and genuinely split
        // on Motorway, 40 with it, 44 without. Applicable only when
        // BidBrain's own payment breakdown actually shows a real
        // protect_fee for THIS purchase; otherwise it is not a missing
        // item at all, "na" below, excluded from the row's own master
        // icon so a car that simply never took Protect is never counted
        // against, the same reasoning Documents already gets for not
        // double counting Service and V5.
        const dkIndemnityApplicable = r.protect_fee != null;
        // A tool tip on every tick state (Mark 2026-08-26: "add a tool tip
        // for each status on the ticks on roll over"), one per the four
        // real states a tick box can actually be in: nothing to offer,
        // ticked to go up, already there but not ticked, or genuinely
        // missing and not yet ticked.
        // blockOnDk (Retail Price only, 2026-08-26): unlike Price/Photos/
        // Documents, dealerkit_write.push_purchase deliberately refuses
        // to overwrite a Retail Price DealerKit already has, so ticking
        // it again once confirmed there would silently do nothing;
        // disabled here too rather than offer a tick that can never
        // actually work.
        const dkBox = (label, key, haveBB, onDk, blockOnDk) => {
          const already = onDk === true;
          const disabled = !haveBB || (blockOnDk && already);
          const checked = haveBB && !already && !disabled;
          let title;
          if(blockOnDk && already) title = label + ' is already on DealerKit, left as your own figure.';
          else if(disabled) title = 'BidBrain has no ' + label.toLowerCase() + ' to push yet.';
          else if(checked) title = 'Ticked to push ' + label.toLowerCase() + ' to DealerKit.';
          else if(already) title = label + ' is already on DealerKit. Tick to push it again.';
          else title = label + ' is on file here, not yet confirmed on DealerKit. Tick to push it.';
          return {key: key, checked: checked, disabled: disabled, onDk: already, title: title};
        };
        // Never create a listing on DealerKit with no mileage on file
        // (Mark 2026-08-26: "this causes DK to estimate the mileage which
        // is dangerous"), a real hard gate in dealerkit_write.add_due_in,
        // which runs for ANY push, not just a price one, since photos and
        // documents alone still need a real vehicle_id to upload to and so
        // still create the record. "this is only for the first push if
        // its already present in DK dont over write": dkOnFile means a
        // real Check DK record has already confirmed the reg is there, in
        // which case add_due_in returns early and the gate never fires;
        // with no check on file yet this stays cautious and still warns,
        // the same safe default a fresh row starts from everywhere else
        // on this row. The warning shows here as a visible option in the
        // rollover; pushDealerkitChecked below is what actually asks.
        const dkNoMileage = !r.mileage && !dkOnFile;
        const dkMileageWarn = dkNoMileage
          ? '<div class="info-row bad"><span>No mileage on file, enter it before any push</span><span></span></div>'
          : '';
        // DealerKit's own stored purchase price against BidBrain's
        // canonical car_price (2026-08-27): a chip recorded here moves
        // car_price, DealerKit keeps whatever was pushed before it, so a
        // real disagreement is exactly what this row exists to surface
        // rather than leave the two silently different. Only shown when
        // both figures are genuinely known and more than a pound apart.
        const dkPriceMismatch = (dkOnFile && dc.purchase_cost_value != null && r.car_price != null &&
                                 Math.abs(dc.purchase_cost_value - r.car_price) > 1)
          ? '<div class="info-row dkmismatch"><span>DealerKit price ' + pounds(dc.purchase_cost_value) +
            ', BidBrain ' + pounds(r.car_price) + '</span><span></span></div>'
          : '';
        // The presence rows, always rendered in the same order regardless
        // of whether a check has ever run, so the tick box for a pushable
        // category always sits at the same place ("appear where the tick
        // for uploaded is"): confirmed present or absent once dc.vehicle_
        // id is known, "?" (unknown, never guessed) beforehand, matching
        // dkState's own three states below exactly. Documents sits
        // alongside Service and V5 for display, but is deliberately left
        // out of the doneCount used for the row's own master icon, it
        // would otherwise double count the same underlying fact those two
        // already carry on their own.
        const dkPresenceRows = [
          dkRow('Car', dkOnFile ? true : (dc ? false : null), null, null),
          dkRow('Purchase price', dkPriceOnDk,
                dkOnFile && dc.purchase_cost != null ? pounds(dc.purchase_cost) : null,
                dkBox('Purchase price', 'price', dkPriceBB, dkPriceOnDk)),
          dkRow('Auction fee', dkOnFile ? dc.auction_fee : (dc ? false : null), null, null),
          dkRow('Delivery', dkOnFile ? dc.delivery : (dc ? false : null), null, null),
          dkRow('Indemnity', dkIndemnityApplicable ? (dkOnFile ? dc.indemnity : (dc ? false : null)) : 'na',
                null, null, dkIndemnityApplicable ? null : 'Not charged on this purchase, nothing to check.'),
          dkRow('Photos', dkPhotosOnDk,
                dkOnFile && dc.images != null ? dc.images + ' photo(s)' : null,
                dkBox('Photos', 'photos', dkPhotosBB, dkPhotosOnDk)),
          dkRow('Documents', dkDocsOnDk,
                dkOnFile && dc.documents != null ? dc.documents + ' document(s)' : null,
                dkBox('Documents', 'documents', dkDocsBB, dkDocsOnDk)),
          dkRow('Service', dkOnFile ? dc.service : (dc ? false : null), null, null),
          dkRow('V5', dkOnFile ? dc.v5 : (dc ? false : null), null, null),
          dkRow('Retail price', dkRetailOnDk,
                dkOnFile && dc.retail_price != null ? pounds(dc.retail_price) : null,
                dkBox('Retail price', 'retail', dkRetailBB, dkRetailOnDk, true)),
          dkRow('Due in', dkDueInPresent, null, null, dkDueInNote),
          dkRow('Due in date', dkDueInDateOnDk, null,
                dkBox('Due in date', 'due_in_date', dkDueDateKnown, dkDueInDateOnDk)),
        ].join('');
        let dkCheckHead, dkState, dkDoneCount, dkTotal;
        if(dc && dc.vehicle_id){
          const dkWhen = (dc.checked_at || '').replace('T', ' ').slice(0, 16);
          dkCheckHead = 'On DealerKit (checked ' + dkWhen + ')';
          // A green badge means every real, applicable category was
          // actually confirmed present, never just the ones BidBrain
          // happened to check (Mark 2026-08-26, KF66GJZ: "the check DK
          // ran and its missing all the data, this should be an orange
          // tick", a genuinely unchecked/unconfirmed category must never
          // be silently excluded from the count, or a row missing real
          // data could still read green just because the few fields
          // BidBrain CAN check happened to all be present). Indemnity is
          // the one deliberate exception: when it does not apply to this
          // purchase at all (dkIndemnityApplicable false, Carwow always,
          // Motorway when Protect was not taken), it is left out of the
          // count entirely rather than counted as missing, the same
          // reasoning Documents already gets for not double counting
          // Service and V5.
          const doneVals = [true, dkPriceOnDk, dc.auction_fee, dc.delivery, dkPhotosOnDk,
                            dc.service, dc.v5, dkRetailOnDk, dkDueInPresent, dkDueInDateOnDk];
          if(dkIndemnityApplicable) doneVals.push(dc.indemnity);
          const doneCount = doneVals.filter(v => v === true).length;
          dkState = doneCount === 0 ? 'none' : (doneCount === doneVals.length ? 'all' : 'some');
          dkDoneCount = doneCount;
          dkTotal = doneVals.length;
        } else if(dc){
          const dkWhen = (dc.checked_at || '').replace('T', ' ').slice(0, 16);
          dkCheckHead = 'No DealerKit record found (checked ' + dkWhen + ')';
          dkState = 'none';
          // Confirmed nothing is there at all, the same real checklist
          // size the found branch above would use (indemnity included
          // only when it would actually apply to this purchase), just
          // every item genuinely missing rather than merely unchecked.
          dkDoneCount = 0;
          dkTotal = dkIndemnityApplicable ? 11 : 10;
        } else {
          dkCheckHead = 'Not checked yet, use Check DK record';
          const dkDone = dkItems.filter(k => r['dk_' + k + '_pushed_at']);
          dkState = dkDone.length === 0 ? 'none' : (dkDone.length === dkItems.length ? 'all' : 'some');
          // No real DealerKit check has ever run for this row, so only
          // BidBrain's own smaller push tracking list is known yet, never
          // the fuller 10/11 item checklist a real Check DK record would
          // reveal.
          dkDoneCount = dkDone.length;
          dkTotal = dkItems.length;
        }
        // The mileage rule is a hard block, never a confirm (Steven
        // 2026-08-27: "NEVER ALLOW Dealerkit to manually guess the
        // mileage, there should be a rule that a car cant be sent to
        // dealerkit without having the mileage"): a car with no mileage
        // and no DealerKit record yet gets a disabled button, not a
        // warning to click through. The server refuses it independently
        // too, this is just the honest face of that rule.
        const dkUploadBtn = dkNoMileage
          ? '<button type="button" class="dkupload-btn" disabled title="Enter the mileage first, ' +
            'DealerKit guesses it on a record created without one">Upload to DK</button>'
          : '<button type="button" class="dkupload-btn" onclick="event.stopPropagation(); pushDealerkitChecked(this)">Upload to DK</button>';
        const dkUploadBlock = '<div class="info-row dkcheck-head"><span>' + dkCheckHead + '</span><span></span></div>' +
          dkMileageWarn + dkPriceMismatch + dkPresenceRows + dkUploadBtn;
        const dkReg = (r.reg || '').replace(/"/g, '&quot;');
        // Re reading the source platform (Mark 2026-08-26: "remove the
        // options to push on the kebab as not needed in two places but
        // as a check motorway or carwow button", the new checkbox plus
        // Upload button above replaces the kebab's old push options, this
        // is a genuinely new capability replacing that space rather than
        // another way to push). Only Motorway and Carwow purchases have a
        // re read built; a future platform with none yet just gets no
        // second menu item, never a button that can only ever fail.
        const dkSourceItem = (r.platform === 'Motorway' || r.platform === 'Carwow')
          ? '<button type="button" class="dkrow-menuitem" onclick="checkPurchaseSource(this)">Check ' + r.platform + '</button>'
          : '';
        const dkCell = '<span class="dkrow" data-reg="' + dkReg + '" data-state="' + dkState + '">' +
          '<span class="info-trigger dkrow-status" onclick="toggleInfo(this, event)">' + dkFractionBadge(dkDoneCount, dkTotal) +
          '<span class="info-pop dkrow-pop">' + dkUploadBlock + '</span></span>' +
          '<span class="dkrow-kebabwrap">' +
          '<button type="button" class="dkrow-kebab" onclick="toggleDkMenu(this, event)" aria-label="More DealerKit checks">' +
          '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">' +
          '<circle cx="12" cy="5" r="2"></circle><circle cx="12" cy="12" r="2"></circle><circle cx="12" cy="19" r="2"></circle></svg></button>' +
          '<div class="dkrow-menu">' +
          '<button type="button" class="dkrow-menuitem" onclick="checkDealerkitRecord(this)">Check DK record</button>' +
          dkSourceItem +
          '</div></span></span>';
        // Car price's own popover carries the full canonical breakdown
        // (2026-08-27): the original bid, each chip, the effective fees
        // after any re band, and the all in total, all straight from
        // list_purchases, never re derived here. Bias bid rides along the
        // way it always did on the old Winning bid popover.
        const priceRows = [];
        if(r.chip_total){
          priceRows.push(['Original bid', pounds(r.bid_original)]);
          (r.chips || []).forEach(c => priceRows.push(
            ['Chip' + (c.reason ? ' Â· ' + esc(c.reason) : ''), 'âˆ’' + pounds(c.amount), false, 'good']));
          priceRows.push(['Car price now', pounds(r.car_price), true]);
        }
        priceRows.push(['Fee', pounds(r.effective_fee)], ['Transport', pounds(r.transport_fee)]);
        if(r.protect_fee != null) priceRows.push(['Indemnity', pounds(r.protect_fee)]);
        priceRows.push(['VAT', pounds(r.effective_vat)], ['All in', pounds(r.all_in), true],
          ['Bias bid', pounds(r.bias_bid)],
          ['vs Bias bid', pct(biasPct), false, biasPct == null ? '' : (biasPct <= 100 ? 'good' : 'bad')]);
        const priceCell = _infoCell(pounds(r.car_price != null ? r.car_price : paid), priceRows);
        const chipCell = r.chip_total
          ? _infoCell('<span class="chipcell">' + poundsNeg(r.chip_total) +
              ((r.chips || []).length > 1 ? ' Ã—' + r.chips.length : '') + '</span>',
              (r.chips || []).map(c => [esc(c.reason || 'Chip') + ' Â· ' + fmtDT(c.created_at),
                poundsNeg(c.amount)]))
          : '<span class="cellempty">â€“</span>';
        const retailCell = _retailCell(r, margin);
        // A "View advert" link sits in the raw description on Auction4Cars
        // and Dealer Auction's own captured pages, pointing back at the
        // original listing, complete with its photos (Mark 2026-08-27:
        // "theres a view advert link in the description to see the
        // original listing with the images"), the one way to see real
        // photos for those two platforms until a gallery reader exists for
        // them the way Motorway and Carwow already have. Shown for every
        // platform, not just those two, whenever listing_url is known.
        const viewLink = r.listing_url
          ? '<a class="vlink" href="' + r.listing_url + '" target="_blank" rel="noopener" title="View the original listing" aria-label="View the original listing">{{EXT_LINK_SVG}}</a>'
          : '';
        // Every editable cell goes through the same two editors: a
        // floating option menu for a dropdown column, a floating input
        // for free text or a date, both saving through saveField (the
        // server's own whitelist plus timeline logging).
        // The caret is the affordance (Steven 2026-08-27: "it isnt
        // clear to the user that they are actual dropdowns").
        // A shell row (a cancelled car we never captured as a purchase, see
        // db.cancelled_shell_rows) has no record to open or edit, so every
        // interactive cell falls back to a plain one and it opens no drawer.
        // Declared HERE, above the first cell that calls it: several of these
        // consts evaluate their ternary at definition time, so a declaration
        // further down is the temporal dead zone trap this file has now hit
        // three times (see CLAUDE.md, the card display prefs and the sort
        // direction flag).
        const plainCell = v => '<td>' + (v ? esc(String(v)) : '<span class="cellempty">â€“</span>') + '</td>';
        const selCell = (field) => r.shell ? plainCell(r[field])
          : '<td class="editable ddcell" onclick="openSelMenu(event,' + r.id +
          ',&#39;' + field + '&#39;)">' + opill(field, r[field]) + DDCARET + '</td>';
        // A cancelled sale's own stock status is meaningless (it read
        // "Due in" on every cancelled car, nonsense caught in the UX
        // pass), so the Status cell says the one true thing instead and
        // is not editable there.
        const statusColCell = r.cancelled_at
          ? '<td><span class="opill oc-red">Cancelled</span></td>'
          : selCell('stock_status');
        const notesCell = r.shell ? plainCell(r.notes)
          : '<td class="editable notescell tedit" onclick="openCellEdit(event,' + r.id +
          ',&#39;notes&#39;,&#39;text&#39;)">' + (r.notes ? esc(r.notes) : '<span class="cellempty">â€“</span>') + '</td>';
        const collCell = r.shell ? plainCell(r.collection_arranged)
          : '<td class="editable tedit" onclick="openCellEdit(event,' + r.id +
          ',&#39;collection_arranged&#39;,&#39;datetime&#39;)">' +
          (r.collection_arranged ? fmtDT(r.collection_arranged) : '<span class="cellempty">â€“</span>') + '</td>';
        // Supplier: editable only on a hand added row, the one kind of
        // car where a person is the source of truth (db refuses the edit
        // on a platform row anyway, so offering it would be a lie). A
        // platform car's supplier IS its platform, so it reads that
        // plainly rather than an empty dash.
        const supplierCell = (r.manual_entry && !r.shell) ? selCell('supplier')
          : plainCell(r.manual_entry ? r.supplier : r.platform);
        const paidCell = '<td class="editable"><span class="pfcheck' + (r.paid_for ? ' on' : '') +
          '" role="checkbox" aria-checked="' + (r.paid_for ? 'true' : 'false') +
          '" title="Paid for" onclick="event.stopPropagation(); saveField(' + r.id +
          ',&#39;paid_for&#39;,' + (r.paid_for ? 0 : 1) + ')"></span></td>';
        // The logbook cell: the V5 scans where they exist, plus the
        // logbook_state pill underneath (Steven 2026-08-27: "keep that in
        // the design but also have an option somehow inside it to
        // designate if the logbook is here or not"), one cell, two facts.
        const logbookCell = '<td><span class="v5wrap">' + v5Cell +
          '<span class="editable lbstate ddcell" onclick="openSelMenu(event,' + r.id +
          ',&#39;logbook_state&#39;)">' + opill('logbook_state', r.logbook_state) + DDCARET + '</span></span></td>';
        // cancelled_reason holds "type Â· free text" as one field; the pill
        // shows the type, the whole reason lives in the tooltip and on the
        // record's own cancel banner.
        const cancelledCell = currentView === 'cancelled' || currentView === 'all'
          ? '<td>' + (r.cancelled_at
              ? '<span class="opill oc-red" title="' + esc(r.cancelled_reason || '') + '">' +
                esc(cancelCategory(r) || 'Cancelled') + '</span>'
              : '<span class="cellempty">â€“</span>') + '</td>'
          : '';
        const openAttr = r.shell ? '' : ' class="opener" onclick="openDrawer(' + r.id + ')"';
        const rowCls = r.cancelled_at ? ' class="cancelled-row"' : '';
        const manualTag = r.manual_entry ? ' <span class="manualtag" title="Added by hand, not read from a platform">M</span>' : '';
        // The double win tag, on the registration where it belongs:
        // green KEEP on the cheaper sale, red CANCEL on the dearer one,
        // and a plain red WON TWICE when the app cannot tell them apart
        // (a missing price, or the two costing the same). The whole
        // sentence is the tooltip.
        const dw = r.double_win;
        const dupTag = dw
          ? ' <span class="duptag' + (dw.keep === true ? ' dupkeep' : '') + '" title="' +
            (dw.advice || '').replace(/"/g, '&quot;') + '">' +
            (dw.keep === true ? 'Keep' : dw.keep === false ? 'Cancel' : 'Won twice') + '</span>'
          : '';
        return '<tr' + rowCls + '>' +
          '<td>' + thumb + '</td>' +
          '<td' + openAttr + '><span class="reg">' + (r.reg || 'no plate') + '</span>' + dupTag + '</td>' +
          '<td' + openAttr + '><span class="vname" title="' + (r.name || '').replace(/"/g, '&quot;') + '">' + (r.name || '') + manualTag + '</span>' + viewLink + '</td>' +
          activeCols.map(k => {
            if(k === 'platform') return '<td>' + platCell(r.platform) + '</td>';
            if(k === 'stockstatus') return statusColCell;
            // Auc status and Video are both platform only concepts
            // (2026-08-31, "on a manual car there is no video to be
            // requested at all"): a hand added car has no platform status
            // word to read, and nobody films a car you were standing next
            // to. A plain dash, never the faded default option, which on
            // an untouched Video cell read "Awaiting confirmation" and so
            // implied a video was pending on a car that will never have one.
            if(k === 'aucstatus') return r.manual_entry
              ? '<td class="status"><span class="cellempty">â€“</span></td>'
              : '<td class="status">' + statusCell + '</td>';
            if(k === 'bought') return '<td>' + (r.bought_date ? fmtDT(r.bought_date) : '') + '</td>';
            if(k === 'duein') return '<td>' + _dueInCell(r) + '</td>';
            if(k === 'collection') return collCell;
            if(k === 'video') return r.manual_entry
              ? '<td><span class="cellempty">â€“</span></td>' : selCell('video_status');
            if(k === 'person') return selCell('person_collecting');
            if(k === 'paidfor') return paidCell;
            if(k === 'onfinance') return selCell('on_finance');
            if(k === 'notes') return notesCell;
            if(k === 'location') return selCell('location');
            if(k === 'supplier') return supplierCell;
            if(k === 'price') return '<td class="num">' + priceCell + '</td>';
            if(k === 'chips') return '<td class="num">' + chipCell + '</td>';
            if(k === 'retail') return '<td class="num">' + retailCell + '</td>';
            if(k === 'margin') return '<td class="num">' + (margin == null
              ? '<span class="cellempty">â€“</span>'
              : '<span class="margin ' + (margin >= 0 ? 'good' : 'bad') + '">' +
                pounds(margin) + '</span>') + '</td>';
            if(k === 'history') return '<td>' + historyCell + '</td>';
            if(k === 'v5') return logbookCell;
            if(k === 'dk') return '<td class="dktd">' + dkCell + '</td>';
            return '<td></td>';
          }).join('') +
          cancelledCell +
          '</tr>';
      }).join('');
      const cancelledTh = currentView === 'cancelled' || currentView === 'all'
        ? thLabel('cancelled', 'Cancelled') : '';
      // Column rollups at the foot of the table (Steven: "both chips and
      // cancellations need to have rollups at the bottom of the columns"),
      // built with the SAME showCol calls as the header and every row so
      // the three can never disagree about which columns exist.
      const sums = {price: 0, chips: 0, retail: 0, nRetail: 0, paid: 0, margin: 0, nMargin: 0};
      rows.forEach(r => {
        if(r.car_price != null) sums.price += r.car_price;
        if(r.chip_total) sums.chips += r.chip_total;
        if(r.retail_estimate != null){ sums.retail += r.retail_estimate; sums.nRetail++; }
        if(r.paid_for) sums.paid++;
        // Only rows carrying BOTH figures, never sums.retail less
        // sums.price: that would quietly credit one car's retail estimate
        // against another car's price that has no estimate of its own.
        if(r.car_price != null && r.retail_estimate != null){
          sums.margin += r.retail_estimate - r.car_price;
          sums.nMargin++;
        }
      });
      const ftd = (v, cls) => '<td class="' + (cls || '') + '">' + v + '</td>';
      const footVals = {
        paidfor: sums.paid ? sums.paid + ' paid' : '',
        price: sums.price ? pounds(sums.price) : '',
        chips: sums.chips ? poundsNeg(sums.chips) : '',
        retail: sums.nRetail ? pounds(sums.retail) : '',
        margin: sums.nMargin ? pounds(sums.margin) : '',
      };
      const tfoot = '<tfoot><tr>' +
        ftd('') + ftd(rows.length + ' car' + (rows.length === 1 ? '' : 's')) + ftd('') +
        activeCols.map(k => ftd(footVals[k] || '', ['price', 'chips', 'retail', 'margin'].includes(k) ? 'num' : '')).join('') +
        (cancelledTh ? ftd('') : '') +
        '</tr></tfoot>';
      const allKeys = ['photo', 'reg', 'name'].concat(activeCols).concat(cancelledTh ? ['cancelled'] : []);
      const colgroup = '<colgroup>' + allKeys.map(k =>
        '<col data-col="' + k + '" style="width:' + colWidth(k) + 'px">').join('') + '</colgroup>';
      list.innerHTML = '<table>' + colgroup + '<thead><tr><th data-col="photo">Photo</th>' +
        thLabel('reg', 'Reg') + thLabel('name', 'Vehicle') +
        activeCols.map(k => thLabel(k, COL_LABELS[k] || k)).join('') +
        cancelledTh +
        '</tr></thead><tbody>' + body + '</tbody>' + tfoot + '</table>';
      paintFrame(rows.length);
      sizeTableArea();
    }
    // The table scrolls INSIDE its own area (Steven 2026-08-27,
    // screenshot: "everything in this screenshot including the titles
    // should be fixed and not affected by the scroll of the table"): the
    // wrapper is capped to the viewport, so the view buttons and toolbar
    // above never move, and the column titles and the rollup footer are
    // sticky within it. Sized by measurement, not a guessed calc, since
    // the run bar and notices above it come and go.
    function sizeTableArea(){
      const tw = bbEl('list');
      if(!tw) return;
      const top = tw.getBoundingClientRect().top;
      tw.style.maxHeight = Math.max(240, window.innerHeight - top - 16) + 'px';
    }
    window.addEventListener('resize', sizeTableArea);
    async function loadPurchases(){
      // Two separated failure domains (2026-08-29 hardening): a fetch or
      // parse failure keeps the friendly network message, but a crash while
      // DRAWING surfaces as itself with the stack in the console, instead
      // of the old single catch reporting every render bug as a file path
      // problem, which actively misled during debugging.
      let d;
      try {
        d = await (await fetch('/api/purchases')).json();
      } catch(e) {
        bbSetHTML('list', '<p class="empty">Could not load. Open this page at its web address, not from a file.</p>');
        return;
      }
      if(!d || !d.ok){ bbSetHTML('list', '<p class="empty">Could not load.</p>'); return; }
      ALL = d.purchases || [];
      resetRangeBounds();
      try {
        render();
        // A push or check finishing while the record is open must show
        // there too, not just in the table behind it.
        if(openRow != null){ paintDrawer(); loadRecordEvents(openRow); }
      } catch(e) {
        console.error('BidBrain: the purchases list loaded but failed to draw.', e);
        bbSetHTML('list', '<p class="empty">The list loaded but could not be drawn. Refresh the page. If it happens again the page itself has a fault, check the browser console.</p>');
      }
    }
    // The old "hide cars bought before June" date pre fill is gone
    // (2026-08-27): the Due in view already keeps finished and cancelled
    // cars out of the working list by their own real status, so nothing
    // needs hiding by date any more. The date boxes stay as a plain
    // filter a person sets themselves.
    loadPurchases();

    // Stats dropdown (Mark 2026-08-26: "move the 107 car bought etc...
    // data in to a button with a floating nav"), same open/close/outside
    // click mechanism as the notification bell just below.

    // Notification bell, same design and behaviour as the cockpit's own
    // (Mark 2026-08-25: "standardise the nav to icons for favorites,
    // purchases, notifications, lost bids and settings"), just fed by a
    // live fetch here rather than baked in at render time, since this page
    // has no run data of its own. A "Run Glass's checks"/"Run DealerKit
    // stock" notice has no Run modal to open on this page, so it deep
    // links back to the cockpit with ?openrun=1 instead (server side, see
    // bidbrain.render._bell_markup).

    // Sync modal (Mark 2026-08-24: "the sync now button should also open
    // a modal to allow for full syncing of motorway or carwow together or
    // individually, this will allow for later auctions to be done the
    // same way"). Same All-plus-per-platform checkbox shape as the
    // cockpit's own Run modal.
    // Purchases sync and the bulk DealerKit check now start from the shared
    // Run modal (their own cards); this page only refreshes its table.
    function onRunFinish(key, s){
      if(['purchases', 'dealerkit_check', 'dealerkit_push_row', 'dealerkit_check_row', 'purchase_check'].includes(key)) loadPurchases();
    }
    function toggleDkMenu(btn, ev){
      ev.stopPropagation();
      const menu = btn.nextElementSibling;
      const already = menu.classList.contains('show');
      document.querySelectorAll('.dkrow-menu.show').forEach(m => m.classList.remove('show'));
      if(!already) menu.classList.add('show');
    }
    document.addEventListener('click', () => {
      document.querySelectorAll('.dkrow-menu.show').forEach(m => m.classList.remove('show'));
    });
    // Per row DealerKit push from the row's own ticked boxes (Mark
    // 2026-08-26). One car's job: its outcome shows on this page's bar and
    // toast, never on the app wide light.
    async function pushDealerkitChecked(btn){
      const row = btn.closest('.dkrow');
      const reg = row.dataset.reg;
      const items = Array.from(row.querySelectorAll('.dkupload-check:checked')).map(c => c.value);
      if(!items.length){ toast('Tick at least one thing to upload.', false); return; }
      const what = items.join(' and ');
      // The mileage rule is a hard block with no escape hatch (Steven
      // 2026-08-27): the button is already disabled for a no mileage car,
      // and the server refuses it again independently either way.
      if(btn.disabled) return;
      if(row.dataset.state !== 'none' &&
         !confirm(reg + ' has already had some data pushed to DealerKit. Push ' + what + ' now? ' +
                  'This re-checks and re-uploads it.')){
        return;
      }
      const kebab = row.querySelector('.dkrow-kebab');
      btn.disabled = true; btn.textContent = 'Uploading...';
      kebab.disabled = true; kebab.classList.add('pushing');
      const ok = await startRun('dealerkit_push_row', {reg, items},
        {confirm: '', starting: 'Uploading ' + what + ' for ' + reg + ' to DealerKit...',
         toast: 'Uploading ' + what + ' for ' + reg + '...', onFinish: () => loadPurchases()});
      if(!ok){
        btn.disabled = false; btn.textContent = 'Upload to DK';
        kebab.disabled = false; kebab.classList.remove('pushing');
      }
    }
    // "Check DK record" (Mark 2026-08-26), a genuine live read of one car's
    // DealerKit record, never a re push.
    async function checkDealerkitRecord(el){
      const row = el.closest('.dkrow');
      const reg = row.dataset.reg;
      document.querySelectorAll('.dkrow-menu.show').forEach(m => m.classList.remove('show'));
      const kebab = row.querySelector('.dkrow-kebab');
      kebab.disabled = true; kebab.classList.add('pushing');
      const ok = await startRun('dealerkit_check_row', {reg},
        {confirm: '', starting: 'Checking ' + reg + ' on DealerKit...',
         toast: 'Checking ' + reg + ' on DealerKit...', onFinish: () => loadPurchases()});
      if(!ok){ kebab.disabled = false; kebab.classList.remove('pushing'); }
    }
    // "Check Motorway"/"Check Carwow" (Mark 2026-08-26), a re read of one
    // already known purchase straight from its own platform.
    async function checkPurchaseSource(el){
      const row = el.closest('.dkrow');
      const reg = row.dataset.reg;
      const label = el.textContent || 'the platform';
      document.querySelectorAll('.dkrow-menu.show').forEach(m => m.classList.remove('show'));
      const kebab = row.querySelector('.dkrow-kebab');
      kebab.disabled = true; kebab.classList.add('pushing');
      const msg = (label.startsWith('Check ') ? 'Re reading ' + reg + ' on ' + label.slice(6) : 'Re reading ' + reg) + '...';
      const ok = await startRun('purchase_check', {reg},
        {confirm: '', starting: msg, toast: msg, onFinish: () => loadPurchases()});
      if(!ok){ kebab.disabled = false; kebab.classList.remove('pushing'); }
    }
    // ------------------------------------------------------------------
    // Inline cell editors (Steven 2026-08-27, "very much like airtable"):
    // one floating option menu for every dropdown column, one floating
    // input for free text, a number or a date, both position:fixed at
    // the clicked cell and both saving through saveField's single write
    // path. A click anywhere else closes whatever is open.
    // ------------------------------------------------------------------
    // Declared ABOVE the functions that touch them, not below: the same
    // temporal dead zone trap this file has hit twice before (see the
    // card display preference entry in CLAUDE.md), never a third time.
    let selAnchor = null, suppressReopen = false;
    function closeMenus(){
      document.querySelectorAll('.selmenu').forEach(x => x.remove());
      const am = bbEl('actmenu');
      if(am) am.hidden = true;
      selAnchor = null;
    }
    function closeFloaters(){
      closeMenus();
      // an open inline editor is blurred, never removed: blur commits
      // the edit through its own handler, removal would throw it away
      document.querySelectorAll('.inline-edit').forEach(el => el.blur());
    }
    document.addEventListener('click', () => closeFloaters());
    // Menus dismiss on ANY press outside them (Steven 2026-08-27: "when
    // you click a dropdown you cant click off it properly"). A capture
    // phase pointerdown cannot be swallowed by another handler's own
    // stopPropagation the way the plain click above can, which was
    // exactly the hole: several cells stop the click to protect their
    // own action, and a menu opened earlier just stayed up. Pressing
    // the SAME cell again now closes its menu rather than blinking it
    // shut and straight back open. The floating text editor is left
    // alone here on purpose, its own blur commits the edit first.
    document.addEventListener('pointerdown', e => {
      if(e.target.closest('.selmenu, .inline-edit, #moredrawer, #actmenu, .thgrip')) return;
      const hadMenu = !!document.querySelector('.selmenu');
      const anchor = selAnchor;
      closeMenus();
      if(hadMenu && anchor && anchor.contains(e.target)) suppressReopen = true;
    }, true);
    // A scroll under a floating menu leaves it hanging at a stale spot,
    // so any scroll closes the menus and commits an open text editor
    // through its own blur.
    window.addEventListener('scroll', e => {
      // a textarea scrolling ITSELF (long notes wrapping, or the caret
      // jump when a long value opens) is not the page moving under a
      // floating element: blurring on it kicked the user out mid word
      // the moment a note wrapped (Steven 2026-08-27, "you can only
      // type so many characters and then it kicks you out")
      if(e.target && e.target.closest && e.target.closest('.inline-edit')) return;
      document.querySelectorAll('.inline-edit').forEach(el => el.blur());
      closeMenus();
    }, true);
    // Escape backs out one layer at a time: an open editor or menu
    // first, then whichever dialog is up, then the record drawer. The
    // photo gallery keeps its own Escape handling, untouched here.
    document.addEventListener('keydown', e => {
      if(e.key !== 'Escape') return;
      const gal = bbEl('gallery-overlay');
      if(gal && !gal.hidden) return;
      if(document.querySelector('.selmenu')){ closeFloaters(); return; }
      if(document.querySelector('.inline-edit')) return; // its own handler cancels cleanly
      // The More drawer is the topmost layer on this page, so it backs out
      // first, one layer per press (the frame installs no handler of its
      // own here, this page owns the whole Escape ladder).
      if(frameEscape()) return;
      const am = bbEl('actmenu');
      if(am && !am.hidden){ am.hidden = true; return; }
      for(const id of ['chipmodal', 'cancelmodal', 'manualmodal']){
        const m = bbEl(id);
        if(m && !m.hidden){ m.hidden = true; return; }
      }
      const dr = bbEl('drawer');
      if(dr && dr.classList.contains('open')) closeDrawer();
    });
    function openSelMenu(ev, id, field){
      ev.stopPropagation();
      if(suppressReopen){ suppressReopen = false; return; }
      closeFloaters();
      const r = rowById(id); if(!r) return;
      selAnchor = ev.currentTarget;
      const menu = document.createElement('div');
      menu.className = 'selmenu';
      menu.addEventListener('click', e => e.stopPropagation());
      // A retired option stays readable on rows that already carry it,
      // but is never offered for a new pick.
      optList(field).filter(o => !o.retired).forEach(o => {
        const b = document.createElement('button');
        b.type = 'button';
        b.innerHTML = '<span class="opill oc-' + o.colour + '">' + esc(o.value) + '</span>';
        b.onclick = () => { closeFloaters(); if(r[field] !== o.value) saveField(id, field, o.value); };
        menu.appendChild(b);
      });
      // Supplier and Person collecting can both be created from here too,
      // not just the Add car screen (2026-08-31): being able to name a new
      // one while adding a car but not while correcting one would be
      // exactly the friction this exists to remove. An inline name box
      // replaces the menu's own list, matching the modal's own control
      // rather than a native prompt() dialog, which used to be here and
      // reads as a different, older piece of the app mid flow.
      if(field === 'supplier' || field === 'person_collecting'){
        const n = document.createElement('button');
        n.type = 'button'; n.className = 'clearopt';
        n.textContent = '+ Add a new ' + PMDD_NOUN[field] + 'â€¦';
        n.onclick = () => selMenuAddNew(menu, id, field);
        menu.appendChild(n);
      }
      const c = document.createElement('button');
      c.type = 'button'; c.className = 'clearopt'; c.textContent = 'Clear';
      c.onclick = () => { closeFloaters(); if(r[field]) saveField(id, field, ''); };
      menu.appendChild(c);
      document.body.appendChild(menu);
      const rect = ev.currentTarget.getBoundingClientRect();
      menu.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 12)) + 'px';
      menu.style.top = Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 10) + 'px';
    }
    // "+ Add a new ..." inside the floating menu (2026-08-31): swaps the
    // menu's own list for a name box IN PLACE, the same replace-never-join
    // shape as the modal's own pmdd control. Escape and Enter both
    // stopPropagation, since without it the SAME keypress would also hit
    // the page's own Escape ladder (openSelMenu's menu already closed by
    // then) and back the drawer out too, one press closing two things.
    function selMenuAddNew(menu, id, field){
      const r = rowById(id);
      menu.innerHTML = '<div class="pmdd-add"><input type="text" id="selmenu-add-inp" placeholder="' +
        esc(PMDD_PLACEHOLDER[field]) + '"><button type="button" class="pmdd-ok" id="selmenu-add-ok" ' +
        'title="Save this name" onmousedown="event.preventDefault()">' + PMDD_TICK + '</button>' +
        '<button type="button" class="pmdd-cancel" title="Cancel" ' +
        'onmousedown="event.preventDefault()" onclick="closeFloaters()">&times;</button></div>';
      const inp = bbEl('selmenu-add-inp');
      let done = false;
      const commit = async () => {
        if(done) return; done = true;
        const name = inp.value.trim();
        if(!name){ closeFloaters(); return; }
        inp.disabled = true;
        try {
          const d = await addDropdownOption(field, name);
          if(r[field] !== d.value) saveField(id, field, d.value);
        } catch(e){ toast(e.message || 'Could not save that name.', false); }
        closeFloaters();
      };
      inp.onkeydown = e => {
        if(e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); commit(); }
        else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); done = true; closeFloaters(); }
      };
      inp.onblur = () => commit();
      bbEl('selmenu-add-ok').onclick = () => commit();
      inp.focus();
    }
    // The editor lives INLINE in the clicked cell or record row, never a
    // floating box near it (Steven 2026-08-27: "it should just be the
    // date and time picker, no typing, and it should be inline, its
    // behaving funny... the same can be said for notes"). A date field
    // opens the browser's own picker immediately (showPicker), notes
    // save on Enter with Shift Enter for a new line, Escape backs out,
    // and clicking anywhere else saves through blur.
    function openCellEdit(ev, id, field, type){
      ev.stopPropagation();
      closeFloaters();
      const r = rowById(id); if(!r) return;
      const host = ev.currentTarget;
      // a table cell edits in the cell itself, a record row edits in
      // its own value slot so the label stays put
      // A TD edits in itself; a record row edits in its own value slot so
      // the label stays put. .v is the money receipt's own value span
      // (2026-08-31), which has no <b> of its own but works the same way.
      const slot = host.tagName === 'TD' ? host
        : (host.querySelector('b') || host.querySelector('.v') || host);
      if(slot.querySelector('.inline-edit')) return;
      let el;
      if(type === 'text'){
        el = document.createElement('textarea');
        el.rows = 2;
      } else {
        el = document.createElement('input');
        // 'plain' is a one line string ('text' is the textarea above)
        el.type = type === 'datetime' ? 'datetime-local' : (type === 'plain' ? 'text' : type);
      }
      // datetime-local wants YYYY-MM-DDTHH:MM exactly; the stored value
      // may carry seconds, or be a bare date, both trimmed or padded here
      // so the picker actually shows the stored value rather than blank.
      let v = r[field] == null ? '' : String(r[field]);
      if(type === 'datetime' && v){
        if(v.length === 10) v = v + 'T09:00';
        v = v.slice(0, 16);
      }
      el.value = v;
      el.className = 'inline-edit';
      ['click', 'pointerdown'].forEach(t => el.addEventListener(t, e => e.stopPropagation()));
      // while editing, the cell may open out taller than its row, so
      // its own clipping is lifted for the duration
      host.classList.add('editing');
      slot.innerHTML = '';
      slot.appendChild(el);
      el.focus();
      if(type === 'text'){
        // grows with the text, capped, then scrolls inside itself
        const grow = () => {
          el.style.height = 'auto';
          el.style.height = Math.min(220, el.scrollHeight + 2) + 'px';
        };
        el.addEventListener('input', grow);
        grow();
        try { el.setSelectionRange(el.value.length, el.value.length); } catch(x){}
        // a visible tick to finish the note (Steven 2026-08-27:
        // "currently its awkward to click out of it"); pointerdown is
        // prevented so the press never blurs the textarea first, the
        // click is the one clean commit path
        const ok = document.createElement('button');
        ok.type = 'button';
        ok.className = 'inline-ok';
        ok.title = 'Save the note (Enter does too, Shift Enter for a new line)';
        ok.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#fff" ' +
          'stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5L19.5 7"></path></svg>';
        ok.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); });
        ok.addEventListener('click', e => { e.stopPropagation(); finish(true); });
        slot.appendChild(ok);
      }
      if(type === 'datetime' && el.showPicker){
        // straight into the browser's own date and time picker, no
        // typing needed; a browser without showPicker still gets the
        // focused input with its own calendar control
        try { el.showPicker(); } catch(x){}
      }
      let settled = false;
      const finish = (save) => {
        if(settled) return; settled = true;
        const nv = (el.value || '').trim();
        const oldV = r[field] == null ? '' : String(r[field]);
        const unchanged = nv === oldV || (type === 'datetime' && oldV.slice(0, 16) === nv);
        if(save && !unchanged){
          saveField(id, field, type === 'number' ? (nv === '' ? null : Number(nv)) : nv);
        } else {
          // put the cell or row back exactly as it was
          render();
          if(openRow != null) paintDrawer();
        }
      };
      el.addEventListener('blur', () => finish(true));
      el.addEventListener('keydown', e => {
        if(e.key === 'Escape'){
          e.preventDefault();
          e.stopPropagation();
          finish(false);
        } else if(e.key === 'Enter' && !(type === 'text' && e.shiftKey)){
          e.preventDefault();
          finish(true);
        }
      });
    }

    // ------------------------------------------------------------------
    // The record drawer: the journey stepper and Next up card above the
    // Car details / Timeline / DealerKit tabs, the always visible
    // comments box underneath, and a drag handle whose width choice is
    // remembered per device (Steven: "each user to be able to drag that
    // out to be as big or as small as they choose... it should stay
    // save that state").
    // ------------------------------------------------------------------
    function openDrawer(id){
      openRow = id; pane = 'journey';
      const dr = bbEl('drawer');
      if(!dr) return;
      // Always wide enough for the record and journey split (Steven:
      // "force it to always be wide so its always side by side"): a
      // remembered narrower width from the old single column drawer is
      // clamped up, never trusted to break the layout.
      let w = 940;
      try { const sw = parseInt(localStorage.getItem('bb_drawer_w'), 10); if(sw) w = sw; } catch(e){}
      dr.style.width = Math.min(Math.max(860, w), Math.max(340, window.innerWidth - 40)) + 'px';
      const sc = bbEl('drawer-scrim');
      if(sc) sc.hidden = false;
      dr.classList.add('open');
      recordEvents = [];
      paintDrawer();
      loadRecordEvents(id);
    }
    function closeDrawer(){
      openRow = null;
      const dr = bbEl('drawer');
      if(dr) dr.classList.remove('open');
      const sc = bbEl('drawer-scrim');
      if(sc) sc.hidden = true;
    }
    function setPane(p){
      pane = p;
      document.querySelectorAll('.rtab').forEach(t => t.classList.toggle('on', t.dataset.pane === p));
      paintRightPane();
    }
    function loadRecordEvents(id){
      fetch('/api/purchase-events?id=' + id).then(r => r.json()).then(d => {
        if(openRow !== id) return;
        if(d && d.ok) recordEvents = d.events || [];
        paintTimelineCount();
        if(pane === 'timeline') paintRightPane();
        paintComments();
      }).catch((err) => { console.warn('BidBrain: the record timeline could not load.', err); });
    }
    function paintTimelineCount(){
      const el = bbEl('tlcount');
      if(el) el.textContent = recordEvents.length;
    }
    function paintDrawer(){
      if(openRow == null) return;
      const r = rowById(openRow);
      if(!r){ closeDrawer(); return; }
      paintHead(r);
      paintStepper(r);
      paintSplit();
      paintComments();
    }
    // Row one is the identity cluster (reg, platform, manual tag) with
    // the actions on the right of the SAME flex row; the vehicle name
    // gets its own full width line under it. Never absolute positioning
    // for the buttons: a first version anchored the three dots inside an
    // inline span whose zero size box swallowed the right offset and
    // dropped the button under the platform pill, caught live.
    function paintHead(r){
      const el = bbEl('dhead');
      if(!el) return;
      el.innerHTML =
        '<div class="dh-top">' +
        '<span class="reg">' + esc(r.reg || 'no plate') + '</span>' +
        '<span class="plat ' + (r.platform || '').toLowerCase() + '">' + esc(r.platform || '') + '</span>' +
        (r.manual_entry ? '<span class="manualtag" title="Added by hand, not read from a platform">Manual</span>' : '') +
        '<span class="dh-actions">' +
        '<button type="button" class="ddots" onclick="openActMenu(event)" aria-label="Actions" title="Actions">' +
        '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
        '<circle cx="5" cy="12" r="2"></circle><circle cx="12" cy="12" r="2"></circle><circle cx="19" cy="12" r="2"></circle></svg></button>' +
        '<button type="button" class="dclose" onclick="closeDrawer()" aria-label="Close">' +
        '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
        'stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg></button>' +
        '<div class="actmenu" id="actmenu" hidden>' +
        (r.cancelled_at
          ? '<div class="actnote">This sale is cancelled. It stays here for the record.</div>'
          : '<button type="button" onclick="openChipModal()">Record a chip</button>' +
            '<div class="actsep"></div>' +
            '<button type="button" class="bad" onclick="openCancelModal()">Cancel this sale</button>' +
            '<div class="actnote">A chip can be recorded at any point, and more than once.</div>') +
        // Delete is only ever offered on a car added by hand, never a real
        // platform purchase, which is a true historical fact and only
        // ever leaves the list through Cancel (2026-08-31, "is there a way
        // i can delete a manually added car?"). Shown even once cancelled,
        // since deleting is still the right fix for a genuine mistake
        // (a duplicate, a mis-typed reg) whatever state it is in.
        (r.manual_entry
          ? '<div class="actsep"></div>' +
            '<button type="button" class="bad" onclick="deleteManualCar(' + r.id + ')">Delete this car</button>' +
            '<div class="actnote">Removes it and its whole history for good. Only ever offered on a car added by hand.</div>'
          : '') +
        '</div></span></div>' +
        '<h2 class="dh-name" title="' + esc(r.name || '') + '">' + esc(r.name || '') + '</h2>';
    }
    function openActMenu(ev){
      ev.stopPropagation();
      const am = bbEl('actmenu');
      if(!am) return;
      const was = am.hidden;
      closeFloaters();
      am.hidden = !was;
    }
    // The journey (Steven's approved design): Bought, then the video
    // check when this dealership uses one (FLOW.video_stage, off for
    // Mark), then Collection which Paid for closes, then Arrived, closed
    // by Check in which is also what moves the car to In stock.
    // The option flagged gate in settings is what closes the video stage
    // ("Video signed off" out of the box). Falling back to the last non
    // retired option only when no gate flag is set anywhere: an earlier
    // version always used the last option, which would have silently
    // moved the gate if the list was ever reordered in settings.
    function videoGateValue(){
      const opts = optList('video_status').filter(o => !o.retired);
      if(!opts.length) return null;
      const g = opts.find(o => o.gate);
      return (g || opts[opts.length - 1]).value;
    }
    function videoDone(r){
      const gate = videoGateValue();
      if(gate == null) return !!r.video_status;
      return r.video_status === gate;
    }
    function stageList(r){
      const stages = [{id: 'bought', label: 'Bought', done: true}];
      // The video stage is platform only (2026-08-31, "on a manual car
      // there is no video to be requested at all"). It exists because the
      // platform arranges a walkaround for a car you have never seen; on a
      // hand added buy you were standing next to it. So this is not the
      // dealership's own FLOW.video_stage preference, which stays as it
      // is, it is that the stage does not apply to this car at all.
      if(FLOW.video_stage && !r.manual_entry){
        stages.push({id: 'video', label: 'Video check', done: videoDone(r)});
      }
      stages.push({id: 'collection', label: 'Collection', done: !!r.paid_for});
      stages.push({id: 'arrived', label: 'Arrived', done: r.stock_status === 'In stock'});
      return stages;
    }
    function paintStepper(r){
      const el = bbEl('jstepwrap');
      if(!el) return;
      if(r.cancelled_at){
        const parts = (r.cancelled_reason || '').split(' Â· ');
        el.innerHTML = '<div class="cancelbanner"><div class="cbtop">Sale cancelled</div>' +
          '<div class="cbwhen">' + fmtDT(r.cancelled_at) + (parts[0] ? ' Â· ' + esc(parts[0]) : '') + '</div>' +
          (parts.length > 1 ? '<div class="cbreason">' + esc(parts.slice(1).join(' Â· ')) + '</div>' : '') +
          '</div>';
        return;
      }
      const stages = stageList(r);
      let cur = stages.findIndex(s => !s.done);
      const steps = stages.map((s, i) => {
        const cls = s.done ? 'done' : (i === cur ? 'cur' : '');
        return '<div class="js ' + cls + '"><span class="ln"></span><span class="jdot"></span>' +
          '<span class="jlab">' + s.label + '</span></div>';
      }).join('');
      el.innerHTML = '<div class="jstep">' + steps + '</div>' +
        '<div class="jsum">Car price <b>' + pounds(r.car_price != null ? r.car_price : r.all_in) + '</b>' +
        (r.chip_total ? ' <span class="warn">after ' + pounds(r.chip_total) + ' chipped</span>' : '') +
        ' Â· All in <b>' + pounds(r.all_in) + '</b>' +
        (r.bought_date ? ' Â· Purchased ' + fmtDT(r.bought_date) : '') + '</div>';
    }
    function nextUpCard(r, stages, cur){
      if(cur === -1){
        return '<div class="nextup alldone"><div class="nulab">All done</div>' +
          '<div class="nuwhat">Checked in and in stock.</div>' +
          '<div class="nuwhy">Everything else lives in DealerKit from here.</div></div>';
      }
      const s = stages[cur];
      let what = '', why = '', act = '';
      if(s.id === 'video'){
        what = 'Get the video walkaround signed off.';
        why = 'Currently: <b>' + esc(r.video_status || (optList('video_status')[0] || {}).value || 'not started') +
          '</b>. Move the Video field along as it happens.';
      } else if(s.id === 'collection'){
        if(r.collection_arranged){
          what = 'Collection booked for <b>' + fmtDT(r.collection_arranged) + '</b>.';
          why = 'Mark Paid for once the money has actually gone, that closes this stage.';
        } else {
          what = 'Arrange the collection.';
          why = 'Set Collection arranged and who is collecting, then mark Paid for when the money has gone.';
        }
      } else if(s.id === 'arrived'){
        what = 'Waiting for the car to land.';
        why = 'When it arrives on site, press Check in. That moves it to In stock and off this list.';
        act = '<div class="nuact"><button type="button" class="checkinbtn" onclick="checkInPurchase()">Check in</button></div>';
      }
      return '<div class="nextup action"><div class="nulab">Next up</div>' +
        '<div class="nuwhat">' + what + '</div><div class="nuwhy">' + why + '</div>' + act + '</div>';
    }
    function recShown(col){ return colShown(col, 'record'); }
    // One editable row inside a stage card, wired to the same editors the
    // table cells use.
    function sgSel(r, label, field){
      return '<div class="sgr edit ddcell" onclick="openSelMenu(event,' + r.id + ',&#39;' + field + '&#39;)">' +
        '<span>' + label + '</span><b>' + opill(field, r[field]) + DDCARET + '</b></div>';
    }
    function sgEdit(r, label, field, type, shown){
      return '<div class="sgr edit" onclick="openCellEdit(event,' + r.id + ',&#39;' + field + '&#39;,&#39;' + type + '&#39;)">' +
        '<span>' + label + '</span><b>' + (shown || '<span class="cellempty">â€“</span>') + '</b></div>';
    }
    // The approved layout (Steven 2026-08-27, "record left, journey
    // right"): the record is permanent on the left, never a tab, and the
    // right panel carries the three tabs, Journey, Timeline, DealerKit,
    // so none of them ever hides the car.
    function paintSplit(){
      if(openRow == null) return;
      const r = rowById(openRow);
      if(!r) return;
      bbSetHTML('dscroll',
        '<div class="csplit">' +
        '<div class="cleft"><div class="seclab"><b>Car details</b><span class="ln2"></span></div>' +
        recordHtml(r) + '</div>' +
        '<div class="cright"><div class="rtabs">' +
        rtabBtn('journey', 'Journey') + rtabBtn('timeline', 'Timeline') + rtabBtn('dk', 'DealerKit') +
        '</div><div class="rbody" id="rbody"></div></div></div>');
      paintRightPane();
    }
    function rtabBtn(p, label){
      return '<button type="button" class="rtab' + (pane === p ? ' on' : '') + '" data-pane="' + p +
        '" onclick="setPane(&#39;' + p + '&#39;)">' + label +
        (p === 'timeline' ? ' <span class="n" id="tlcount">' + recordEvents.length + '</span>' : '') +
        '</button>';
    }
    function paintRightPane(){
      if(openRow == null) return;
      const r = rowById(openRow);
      if(!r) return;
      const el = bbEl('rbody');
      if(!el) return;
      el.innerHTML = pane === 'timeline' ? timelineHtml()
        : pane === 'dk' ? dkSyncPaneHtml(r) : journeyPaneHtml(r);
      if(pane === 'dk') dkDrawerCount();
    }
    // The record, permanent on the left: every fact in one of four
    // groups, Car, Money, Collection and logistics, Notes and documents.
    // The one rule that fixed the old confusion: the record owns the
    // facts, the journey owns only what moves the car forward, and no
    // field is editable in two places.
    function recordHtml(r){
      const statusRow = r.cancelled_at
        ? '<div class="sgr"><span>Status</span><b><span class="opill oc-red">Cancelled</span></b></div>'
        : sgSel(r, 'Status', 'stock_status');
      const car = '<div class="rgroup"><h4 class="rgh">Car</h4>' +
        sgEdit(r, 'Mileage', 'mileage', 'number',
          r.mileage != null && r.mileage !== '' ? Number(r.mileage).toLocaleString() + ' miles' : null) +
        // The plate underneath a kept private plate (2026-09-20), which is
        // the other plate every DealerKit lookup searches on. Always shown,
        // empty on most cars, because a car whose listing never stated it
        // has no other way to get one and reads as missing on DealerKit
        // until somebody types it here.
        sgEdit(r, 'Sells on plate', 'selling_vrm', 'plain',
          r.selling_vrm ? esc(r.selling_vrm) : null) +
        // The purchase date is a fact the platform reported, except on a
        // hand added car where the person typed it and is the only one who
        // can correct it. Same rule for the money receipt below.
        (r.manual_entry
          ? sgEdit(r, 'Purchase date', 'bought_date', 'date', r.bought_date ? fmtDT(r.bought_date) : null)
          : '<div class="sgr"><span>Purchase date</span><b>' + (r.bought_date ? fmtDT(r.bought_date) : 'â€“') + '</b></div>') +
        '<div class="sgr"><span>Platform</span><b>' + esc(r.platform || '') + '</b></div>' +
        (r.manual_entry ? sgSel(r, 'Supplier', 'supplier') : '') +
        statusRow +
        (recShown('aucstatus') && r.status ? '<div class="sgr"><span>Auction status</span><b>' + esc(humanizeStatus(r.status)) + '</b></div>' : '') +
        '</div>';
      const money = '<div class="rgroup"><h4 class="rgh">Money</h4>' + moneyReceiptHtml(r) +
        (recShown('retail') ? '<div class="sgr"><span>Retail est.</span><b>' + pounds(r.retail_estimate) + '</b></div>' : '') +
        '</div>';
      const logi = '<div class="rgroup"><h4 class="rgh">Collection and logistics</h4>' +
        (recShown('collection') ? sgEdit(r, 'Collection arranged', 'collection_arranged', 'datetime',
          r.collection_arranged ? fmtDT(r.collection_arranged) : null) : '') +
        (recShown('person') ? sgSel(r, 'Person collecting', 'person_collecting') : '') +
        (recShown('onfinance') ? sgSel(r, 'On finance', 'on_finance') : '') +
        (recShown('location') ? sgSel(r, 'Location', 'location') : '') +
        sgSel(r, 'Logbook', 'logbook_state') +
        (recShown('duein') ? '<div class="sgr"><span>Platform due in date</span><b>' +
          (r.collection_date ? fmtDT(r.collection_date) : 'â€“') + '</b></div>' : '') +
        '</div>';
      // the real uploaded documents, the same clickable thumbnails and
      // shared gallery as the table view (Steven 2026-08-27: "in the car
      // overlay you should be able to see the uploaded service history
      // pics as well as the v5 pics the same way you can in the table")
      const histPics = (r.service_history_photos || []).map(mediaUrl).filter(Boolean)
        .concat((r.document_paths || []).map(localDocUrl).filter(Boolean));
      const v5Pics = (r.v5_photos || []).map(mediaUrl).filter(Boolean)
        .concat((r.v5_local_paths || []).map(localDocUrl).filter(Boolean));
      const docs = '<div class="rgroup"><h4 class="rgh">Notes and documents</h4>' +
        (recShown('notes') ? sgEdit(r, 'Notes', 'notes', 'text', r.notes ? esc(r.notes) : null) : '') +
        (recShown('history') ? '<div class="sgr"><span>Service history</span><b>' +
          _docCell(histPics, JSON.stringify(histPics).replace(/'/g, '&#39;'), 'Service history page',
            '<span class="noval">No service history</span>') + '</b></div>' : '') +
        '<div class="sgr"><span>V5</span><b>' +
        _docCell(v5Pics, JSON.stringify(v5Pics).replace(/'/g, '&#39;'), 'V5 page') + '</b></div>' +
        (r.cancelled_at ? '<div class="sgr"><span>Cancelled</span><b>' + fmtDT(r.cancelled_at) + '</b></div>' : '') +
        '</div>';
      return '<div class="recgrid">' + car + money + logi + docs + '</div>';
    }
    // A receipt, one figure per line: the original bid and each chip
    // only appear once a chip exists, Car price and All in rule off and
    // read larger (class tot). Shared by the Bought stage and a
    // cancelled car's own record, which keeps its full money history.
    function moneyReceiptHtml(r){
      // On a hand added car every figure here was typed by a person and
      // nothing upstream will ever correct a typo, so each one stays
      // editable, through the same openCellEdit and the same timeline
      // logging as any other field (2026-08-31). On a platform car they
      // stay read only: the platform said them, and db refuses the edit.
      // Car price is frozen once a chip exists, since a chip's own
      // bid_before was captured against the price as it stood then, so no
      // pencil is offered there rather than one that always errors.
      // vatFlag is the per fee VAT flag this row's amount belongs to, when
      // it has one: on a hand added car it renders as a small clickable
      // +VAT tag (2026-08-31), so a mistake made at add time is correctable
      // here rather than baked in for good, the same rule as every other
      // figure on this receipt. Its click stops propagation so it never
      // also opens the row's own amount editor underneath it.
      const mRow = (label, field, shown, cls, vatFlag) => {
        let tag = '';
        if(vatFlag && r.manual_entry && !r.shell){
          tag = '<span class="vattag' + (r[vatFlag] ? ' on' : '') + '" title="' +
            (r[vatFlag] ? 'Carries VAT at 20%, click to remove it' : 'No VAT, click to add it at 20%') +
            '" onclick="event.stopPropagation(); saveField(' + r.id + ',&#39;' + vatFlag + '&#39;,' +
            (r[vatFlag] ? 0 : 1) + ')">+VAT</span>';
        } else if(vatFlag && r[vatFlag]){
          tag = '<span class="vattag on">+VAT</span>';
        }
        const body = '<span class="l">' + label + tag + '</span><span class="v' +
          (cls ? ' ' + cls : '') + '">' + shown + '</span>';
        if(!r.manual_entry || r.shell) return '<div' + (cls === 'win' ? ' class="tot"' : '') + '>' + body + '</div>';
        return '<div class="' + (cls === 'win' ? 'tot ' : '') + 'medit" onclick="openCellEdit(event,' +
          r.id + ',&#39;' + field + '&#39;,&#39;number&#39;)">' + body + '</div>';
      };
      let money = '<div class="sgmoney">';
      if(r.chip_total){
        money += '<div><span class="l">Original bid</span><span class="v">' + pounds(r.bid_original) + '</span></div>';
        (r.chips || []).forEach(c => {
          money += '<div><span class="l">Chip' + (c.reason ? ' Â· ' + esc(c.reason) : '') +
            '</span><span class="v win">' + poundsNeg(c.amount) + '</span></div>';
        });
      }
      money += (r.chip_total
          ? '<div class="tot"><span class="l">Car price</span><span class="v win">' + pounds(r.car_price) + '</span></div>'
          : mRow('Car price', 'price', pounds(r.car_price), 'win')) +
        mRow('Fee', 'motorway_fee', pounds(r.effective_fee), '', 'fee_has_vat') +
        mRow('Transport', 'transport_fee', pounds(r.transport_fee), '', 'transport_has_vat') +
        (r.protect_fee != null ? '<div><span class="l">Indemnity</span><span class="v">' + pounds(r.protect_fee) + '</span></div>' : '') +
        // VAT itself is only hand editable on a manual row from BEFORE per
        // fee VAT existed (both flags NULL). Once a car carries the flags,
        // its VAT is maintained from them and typing over it would just be
        // overwritten by the next fee edit, so it reads plainly instead.
        ((r.manual_entry && (r.fee_has_vat != null || r.transport_has_vat != null))
          ? '<div><span class="l">VAT</span><span class="v">' + pounds(r.effective_vat) + '</span></div>'
          : mRow('VAT', 'vat_total', pounds(r.effective_vat))) +
        '<div class="tot"><span class="l">All in</span><span class="v">' + pounds(r.all_in) + '</span></div></div>';
      return money;
    }
    // The Journey tab: the Next up card plus a vertical rail, one row
    // per stage, each carrying ONLY its single closing control. The
    // record on the left owns the facts a stage may quote.
    const VTICK = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3.4" ' +
      'stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5L19.5 7"></path></svg>';
    function journeyPaneHtml(r){
      if(r.cancelled_at){
        return '<div class="nextup stopped"><div class="nulab">Cancelled</div>' +
          '<div class="nuwhat">The journey ended when this sale was cancelled.</div>' +
          '<div class="nuwhy">The record on the left stays for good, the full story is on the Timeline tab.</div></div>';
      }
      const stages = stageList(r);
      const cur = stages.findIndex(x => !x.done);
      const rows = stages.map((st, i) => {
        const cls = st.done ? '' : (i === cur ? ' cur' : ' todo');
        const dot = '<span class="vdot">' + (st.done ? VTICK : '') + '</span>';
        let sum = '', act = '';
        if(st.id === 'bought'){
          // "Won on Manual" was wrong twice over on a hand added car
          // (2026-08-31): nothing was won at auction, and "Manual" is not
          // a place. It reads from the supplier now, falling back to a
          // plain "Added by hand" when none was recorded.
          sum = (r.manual_entry
              ? (r.supplier ? 'Bought from ' + esc(r.supplier) : 'Added by hand')
              : 'Won on ' + esc(r.platform || 'the platform')) +
            (r.bought_date ? ', ' + fmtDT(r.bought_date) : '') +
            (r.chip_total ? ', ' + pounds(r.car_price) + ' after the chip' : '');
        } else if(st.id === 'video'){
          act = '<span class="opill-btn ddcell" onclick="openSelMenu(event,' + r.id + ',&#39;video_status&#39;)">' +
            opill('video_status', r.video_status) + DDCARET + '</span>';
        } else if(st.id === 'collection'){
          const bits = [];
          if(r.collection_arranged) bits.push('Booked ' + fmtDT(r.collection_arranged));
          if(r.person_collecting) bits.push(esc(r.person_collecting) + ' collecting');
          sum = bits.join(', ') || 'Set the booking and who is collecting on the left.';
          act = '<label class="paidtog" onclick="saveField(' + r.id +
            ',&#39;paid_for&#39;,' + (r.paid_for ? 0 : 1) + ')">' +
            '<span class="pfcheck' + (r.paid_for ? ' on' : '') + '"></span>Paid for</label>';
        } else if(st.id === 'arrived'){
          if(r.checked_in_at){
            sum = 'Checked in ' + fmtDT(r.checked_in_at) + ', in stock.';
          } else if(st.done){
            sum = 'In stock, handled in DealerKit from here.';
          } else {
            const ready = i === cur;
            if(ready) sum = 'When the car lands on site, check it in. That moves it to In stock.';
            act = '<button type="button" class="checkinbtn vcheckin"' +
              (ready ? '' : ' disabled title="Finish the earlier stages first"') +
              ' onclick="checkInPurchase()">Check in</button>';
          }
        }
        return '<div class="vrow' + cls + '">' + dot + '<div class="vname">' + st.label + '</div>' +
          (sum ? '<div class="vsum">' + sum + '</div>' : '') +
          (act ? '<div class="vact">' + act + '</div>' : '') + '</div>';
      }).join('');
      return nextUpCard(r, stages, cur) + '<div class="vrail">' + rows + '</div>';
    }
    function timelineHtml(){
      if(!recordEvents.length) return '<p class="empty">Nothing on the timeline yet.</p>';
      const items = recordEvents.map(e => {
        const kind = e.kind || 'field';
        let line = esc(e.text || '');
        if(kind === 'field' && !e.text) line = esc(e.field || 'Field') + ' changed';
        let detail = '';
        if(kind === 'field' && (e.was != null || e.now != null)){
          // Raw stored values read badly on the timeline (paid_for is
          // 1/0, a collection date is a bare ISO string), so a couple of
          // fields get their human shape here, display only.
          const tlVal = v => {
            if(v == null || v === '') return 'empty';
            if(e.field === 'paid_for') return String(v) === '1' ? 'Yes' : 'No';
            if(e.field === 'collection_arranged') return fmtDT(v);
            if(e.field === 'mileage' && !isNaN(v)) return Number(v).toLocaleString() + ' miles';
            return v;
          };
          detail = '<span class="tlwas">' + esc(tlVal(e.was)) + '</span> â†’ ' +
            '<span class="tlnow">' + esc(tlVal(e.now)) + '</span>';
        }
        const body = e.body ? '<div class="tlbody">' + esc(e.body) + '</div>' : '';
        const who = e.who ? ' Â· ' + esc(e.who) : '';
        return '<li class="k-' + kind + '"><div>' + line + (detail ? ' &nbsp;' + detail : '') + '</div>' + body +
          '<div class="tlwhen">' + fmtStamp(e.ts) + who + '</div></li>';
      }).join('');
      return '<ul class="tl">' + items + '</ul>';
    }
    // The DealerKit tab: the FULL sync picture (Steven 2026-08-27, "it
    // needs to have all the dealerkit sync options showing whats missing
    // and whats not synced"), the same states as the table row's own
    // rollover with room to breathe: a green tick means on DealerKit, a
    // red ring means missing there, a blue ticked box means BidBrain has
    // it and it is ticked to push, and not applicable reads dim, never
    // as missing. Upload and Check DK record run from right here.
    function dkSyncPaneHtml(r){
      const dc = r.dk_check;
      const onFile = !!(dc && dc.vehicle_id);
      const dkTick = '<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">' +
        '<circle cx="12" cy="12" r="10" fill="var(--icon)"></circle>' +
        '<path d="M7.5 12.5l3 3 6-6.5" fill="none" stroke="#fff" stroke-width="2.2" ' +
        'stroke-linecap="round" stroke-linejoin="round"></path></svg>';
      const ring = unk => '<span class="dkring' + (unk ? ' unk' : '') + '"' +
        ' title="' + (unk ? 'Not checked yet, run Check DK record' : 'Not found on DealerKit') + '"></span>';
      const box = (item, title) => '<input type="checkbox" class="dkdrawer-check" value="' + item +
        '" checked title="' + title + '" onchange="dkDrawerCount()">';
      const rows = [];
      const row = (state, label, detail, mark) => rows.push(
        '<div class="dkr ' + state + '"><span class="lab">' + label + '</span>' +
        (detail ? '<span class="det">' + detail + '</span>' : '') +
        '<span class="mk">' + mark + '</span></div>');
      // a read only item BidBrain cannot push on its own, tick / ring / unknown
      const roRow = (label, onDk, detail) => {
        if(onDk === true) row('good', label, detail || '', dkTick);
        else if(onDk === false) row('absent', label, detail || 'Not found on DealerKit', ring(false));
        else row('unk', label, detail || 'Not checked yet', ring(true));
      };
      let head;
      // The plate it was actually found under, when that is not the one
      // this purchase is recorded on (2026-09-20): a car whose seller kept
      // a private plate reaches DealerKit on the plate underneath, and a
      // match on that plate should read as the real thing it is, not as an
      // ordinary match on the plate shown at the top of this drawer.
      const dkPlate = dc && dc.matched_plate ? ', found under ' + esc(dc.matched_plate) : '';
      if(onFile) head = 'On DealerKit as vehicle <b>' + esc(String(dc.vehicle_id)) + '</b>' + dkPlate +
        ' Â· last checked ' + fmtStamp(dc.checked_at);
      else if(dc) head = 'No DealerKit record found when last checked, ' + fmtStamp(dc.checked_at) +
        (r.selling_vrm ? ' (' + esc(r.reg) + ' and ' + esc(r.selling_vrm) + ' were both tried)' : '');
      else head = 'Not checked against DealerKit yet.';
      // BidBrain's own side per pushable item
      const bbPrice = r.car_price != null || r.price != null;
      const bbPhotos = !!((r.photo_urls && r.photo_urls.length) || r.photo_url);
      const bbPhotoCount = (r.photo_urls && r.photo_urls.length) || (r.photo_url ? 1 : 0);
      const bbDocCount = (r.service_history_photos || []).length + (r.v5_photos || []).length +
        (r.document_paths || []).length + (r.v5_local_paths || []).length;
      const bbRetail = r.retail_estimate != null;
      const bbDate = !!r.collection_date;
      const priceOnDk = onFile ? dc.purchase_cost != null : (dc ? false : null);
      const mismatch = onFile && dc.purchase_cost_value != null && r.car_price != null &&
        Math.abs(dc.purchase_cost_value - r.car_price) > 1;
      roRow('Car', onFile ? true : (dc ? false : null), onFile ? 'On DealerKit' : null);
      if(priceOnDk === true && !mismatch){
        row('good', 'Purchase price', dc.purchase_cost_value != null ? pounds(dc.purchase_cost_value) : '', dkTick);
      } else if(bbPrice){
        const det = mismatch
          ? pounds(dc.purchase_cost_value) + ' on DK, ' + pounds(r.car_price) + ' here'
          : pounds(r.car_price != null ? r.car_price : r.price) +
            (priceOnDk === false ? ' here, not on DK' : ' here, not checked yet');
        row('push', 'Purchase price', det, box('price', 'Ticked to push the price to DealerKit'));
      } else {
        row('absent', 'Purchase price', 'No price on file here either', ring(priceOnDk == null));
      }
      roRow('Auction fee', onFile ? dc.auction_fee : (dc ? false : null));
      roRow('Delivery', onFile ? dc.delivery : (dc ? false : null));
      if(r.protect_fee != null) roRow('Indemnity', onFile ? dc.indemnity : (dc ? false : null));
      else row('na', 'Indemnity', 'Not charged on this purchase', '<span class="det">â€“</span>');
      if(onFile && dc.images > 0){
        row('good', 'Photos', dc.images + ' photo' + (dc.images === 1 ? '' : 's'), dkTick);
      } else if(bbPhotos){
        row('push', 'Photos', bbPhotoCount + ' here' + (onFile ? ', none on DK' : ''),
            box('photos', 'Ticked to push the photos to DealerKit'));
      } else {
        row('absent', 'Photos', 'None on file here either', ring(!dc));
      }
      if(onFile && dc.documents > 0){
        row('good', 'Documents', dc.documents + ' document' + (dc.documents === 1 ? '' : 's'), dkTick);
      } else if(bbDocCount){
        row('push', 'Documents', bbDocCount + ' here' + (onFile ? ', none on DK' : ''),
            box('documents', 'Ticked to push the documents to DealerKit'));
      } else {
        row('absent', 'Documents', 'None on file here either', ring(!dc));
      }
      roRow('Service history', onFile ? dc.service : (dc ? false : null));
      roRow('V5', onFile ? dc.v5 : (dc ? false : null));
      if(onFile && dc.retail_price != null){
        row('good', 'Retail price', pounds(dc.retail_price) + ', left as your own figure', dkTick);
      } else if(bbRetail){
        row('push', 'Retail price', pounds(r.retail_estimate) + ' here',
            box('retail', 'Ticked to push the retail estimate to DealerKit'));
      } else {
        row('absent', 'Retail price', 'No retail estimate here yet', ring(!dc));
      }
      roRow('Due in stage', onFile ? dc.due_in : (dc ? false : null));
      if(onFile && dc.due_in_date){
        row('good', 'Due in date', '', dkTick);
      } else if(bbDate){
        row('push', 'Due in date', fmtDT(r.collection_date) + ' here' + (onFile ? ', blank on DK' : ''),
            box('due_in_date', 'Ticked to push the due in date to DealerKit'));
      } else {
        row('absent', 'Due in date', 'No due in date known here yet', ring(!dc));
      }
      const noMileage = !r.mileage && !onFile;
      const warn = noMileage
        ? '<div class="dkmismatch">No mileage on file. DealerKit guesses the mileage on a record created ' +
          'without one, so nothing can be pushed until it is entered on the left.</div>'
        : '';
      const mism = mismatch
        ? '<div class="dkmismatch">DealerKit holds ' + pounds(dc.purchase_cost_value) +
          ', BidBrain&#39;s own car price is ' + pounds(r.car_price) +
          '. A chip never moves DealerKit on its own, the Purchase price above is ticked to push the new figure.</div>'
        : '';
      return '<div class="dkpane"><div class="dkchk">' + head + '</div>' + rows.join('') + warn + mism +
        '<div class="dklegend">' +
        '<span>' + dkTick + ' on DealerKit</span>' +
        '<span><span class="dkring"></span> missing on DealerKit</span>' +
        '<span><input type="checkbox" checked disabled style="accent-color:#2563eb;"> ticked to push from here</span></div>' +
        '<div class="dkbtns"><button type="button" class="dkupload" id="dkdrawerupload"' +
        ' data-nomileage="' + (noMileage ? '1' : '') + '" onclick="dkDrawerPush(this)">Upload to DK</button>' +
        '<button type="button" class="dkghost" onclick="dkDrawerCheck(this)">Check DK record</button></div>' +
        '</div>';
    }
    // Keeps the Upload button honest about what is actually ticked, and
    // keeps the mileage rule a hard block here exactly as it is
    // everywhere else, no escape hatch.
    function dkDrawerCount(){
      const btn = bbEl('dkdrawerupload');
      if(!btn) return;
      const n = document.querySelectorAll('#rbody .dkdrawer-check:checked').length;
      if(btn.dataset.nomileage === '1'){
        btn.disabled = true;
        btn.textContent = 'Enter the mileage first';
        return;
      }
      btn.disabled = n === 0;
      btn.textContent = n ? 'Upload ' + n + ' ticked item' + (n === 1 ? '' : 's') + ' to DK'
                          : 'Nothing ticked to upload';
    }
    async function dkDrawerPush(btn){
      if(openRow == null || btn.disabled) return;
      const r = rowById(openRow);
      if(!r || !r.reg) return;
      const items = Array.from(document.querySelectorAll('#rbody .dkdrawer-check:checked')).map(c => c.value);
      if(!items.length) return;
      const what = items.join(' and ');
      btn.disabled = true;
      btn.textContent = 'Uploading...';
      const ok = await startRun('dealerkit_push_row', {reg: r.reg, items},
        {confirm: '', starting: 'Uploading ' + what + ' for ' + r.reg + ' to DealerKit...',
         toast: 'Uploading ' + what + ' for ' + r.reg + '...', onFinish: () => loadPurchases()});
      if(!ok) dkDrawerCount();
    }
    async function dkDrawerCheck(btn){
      if(openRow == null) return;
      const r = rowById(openRow);
      if(!r || !r.reg) return;
      btn.disabled = true;
      const ok = await startRun('dealerkit_check_row', {reg: r.reg},
        {confirm: '', starting: 'Checking ' + r.reg + ' on DealerKit...',
         toast: 'Checking ' + r.reg + ' on DealerKit...', onFinish: () => loadPurchases()});
      if(!ok) btn.disabled = false;
    }
    function paintComments(){
      if(openRow == null) return;
      const el = bbEl('dcomments');
      if(!el) return;
      const notes = recordEvents.filter(e => e.kind === 'comment').slice().reverse();
      const list = notes.length
        ? notes.map(e => '<div class="cnote">' + esc(e.body || '') +
            '<div class="cmeta">' + esc(e.who || '') + ' Â· ' + fmtStamp(e.ts) + '</div></div>').join('')
        : '<div class="cnote" style="opacity:.6">No comments yet.</div>';
      el.innerHTML = '<h4>Comments</h4><div class="clist">' + list + '</div>' +
        '<div class="cinput"><textarea id="cmtinput" rows="2" placeholder="Add a comment, it lands on the timeline too"></textarea>' +
        '<button type="button" onclick="postComment()">Post</button></div>';
    }
    function postComment(){
      const ta = bbEl('cmtinput');
      if(!ta) return;
      const t = (ta.value || '').trim();
      if(!t || openRow == null) return;
      fetch('/api/add-purchase-comment', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: openRow, text: t})
      }).then(r => r.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'Could not post.', false); return; }
        recordEvents = d.events || recordEvents;
        paintTimelineCount();
        paintComments();
        if(pane === 'timeline') paintRightPane();
      }).catch(() => toast('Could not post.', false));
    }
    function checkInPurchase(){
      if(openRow == null) return;
      const r = rowById(openRow);
      if(!r) return;
      if(!confirm('Check ' + (r.reg || 'this car') + ' in? That stamps the arrival time and moves it to In stock, ' +
                  'off the working list.')) return;
      fetch('/api/check-in-purchase', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: openRow})
      }).then(x => x.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'Could not check in.', false); return; }
        ALL = d.purchases || ALL;
        render();
        paintDrawer();
        loadRecordEvents(openRow);
      }).catch(() => toast('Could not check in.', false));
    }
    // Drawer resize: drag the left edge, the chosen width is remembered
    // in this browser (localStorage, per device by design, a phone and
    // the office Mac genuinely want different widths).
    (function(){
      const grab = bbEl('dgrab');
      const dr = bbEl('drawer');
      if(!grab || !dr) return;
      let dragging = false;
      grab.addEventListener('mousedown', e => {
        dragging = true;
        e.preventDefault();
        document.body.style.userSelect = 'none';
      });
      document.addEventListener('mousemove', e => {
        if(!dragging) return;
        const w = Math.max(780, Math.min(window.innerWidth - 40, window.innerWidth - e.clientX));
        dr.style.width = w + 'px';
      });
      document.addEventListener('mouseup', () => {
        if(!dragging) return;
        dragging = false;
        document.body.style.userSelect = '';
        try { localStorage.setItem('bb_drawer_w', parseInt(dr.style.width, 10) || 560); } catch(e){}
      });
    })();

    // ------------------------------------------------------------------
    // Chip dialog. The preview is advisory, computed against the same
    // embedded fee tables the server uses; the figures actually RECORDED
    // are always computed server side at save time and frozen onto the
    // chip (serve._record_chip), never trusted from this preview.
    // ------------------------------------------------------------------
    function openChipModal(){
      closeFloaters();
      if(openRow == null) return;
      bbSetVal('chipamt', '');
      bbSetVal('chipwhy', '');
      paintChipPreview();
      const cm = bbEl('chipmodal');
      if(cm) cm.hidden = false;
      const ca = bbEl('chipamt');
      if(ca) ca.focus();
    }
    function closeChip(){ const cm = bbEl('chipmodal'); if(cm) cm.hidden = true; }
    function paintChipPreview(){
      const el = bbEl('chippreview');
      if(!el) return;
      const r = openRow == null ? null : rowById(openRow);
      const amt = Number(bbVal('chipamt'));
      if(!r || !amt || amt <= 0 || r.car_price == null){
        el.innerHTML = r && r.car_price == null
          ? '<div class="chiphint">This car has no price on file to chip against.</div>' : '';
        return;
      }
      const newPrice = r.car_price - amt;
      let html = '<div class="cprow"><span>Car price</span><b><span class="was">' + pounds(r.car_price) +
        '</span> â†’ ' + pounds(newPrice) + '</b></div>';
      const rebands = (FLOW.fee_rebands || {})[r.platform];
      if(rebands && r.effective_fee != null){
        const candidate = feeFor(r.platform, newPrice);
        if(candidate != null && candidate !== r.effective_fee){
          html += '<div class="cprow"><span>Fee moves</span><b class="goodm"><span class="was">' +
            pounds(r.effective_fee) + '</span> â†’ ' + pounds(candidate) + '</b></div>' +
            '<div class="chiphint">Saved with the chip, the platform fee follows the new price on ' +
            esc(r.platform) + '.</div>';
        } else if(candidate != null){
          const b = bandOf(r.platform, newPrice);
          if(b && b[0] > 0){
            const lowerFee = feeFor(r.platform, b[0] - 1);
            if(lowerFee != null && lowerFee < candidate){
              const extra = newPrice - (b[0] - 1);
              html += '<div class="chiphint">Fee stays at ' + pounds(candidate) + '. Chip Â£' +
                Number(extra).toLocaleString('en-GB') + ' more and it drops to ' + pounds(lowerFee) + '.</div>';
            }
          }
        }
      } else if(r.platform === 'Carwow'){
        html += '<div class="chiphint">Carwow fees follow the original price, the fee does not move.</div>';
      }
      el.innerHTML = html;
    }
    function saveChip(btn){
      if(openRow == null) return;
      if(!bbEl('chipamt')){ toast('This page is broken (the chip amount box is missing), nothing was saved. Refresh the page.', false); return; }
      const amt = Number(bbVal('chipamt'));
      if(!amt || amt <= 0){ toast('Enter how much was chipped.', false); return; }
      btn.disabled = true;
      fetch('/api/record-chip', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: openRow, amount: amt,
          reason: (bbVal('chipwhy') || '').trim()})
      }).then(r => r.json()).then(d => {
        btn.disabled = false;
        if(!d || !d.ok){ toast((d && d.error) || 'Could not record the chip.', false); return; }
        ALL = d.purchases || ALL;
        closeChip();
        render();
        paintDrawer();
        loadRecordEvents(openRow);
      }).catch((err) => { console.warn('BidBrain: record chip failed.', err); btn.disabled = false; toast('Could not record the chip.', false); });
    }

    // Cancel dialog. The reason list comes from this dealership's own
    // configured options; the free text is required, it is what gets
    // shown to the platform if they ask for proof.
    function openCancelModal(){
      closeFloaters();
      if(openRow == null) return;
      const box = bbEl('cancelreasons');
      if(!box) return;
      box.innerHTML = optList('cancelled_reason').filter(o => !o.retired).map((o, i) =>
        '<label class="pmradio"><input type="radio" name="cancelreason" value="' + esc(o.value) + '"' +
        (i === 0 ? ' checked' : '') + '><b>' + esc(o.value) + '</b></label>').join('');
      bbSetVal('cancelwhy', '');
      const cm = bbEl('cancelmodal');
      if(cm) cm.hidden = false;
    }
    function closeCancelModal(){ const cm = bbEl('cancelmodal'); if(cm) cm.hidden = true; }
    function saveCancel(btn){
      if(openRow == null) return;
      const picked = document.querySelector('input[name="cancelreason"]:checked');
      if(!picked){ toast('Pick what happened.', false); return; }
      if(!bbEl('cancelwhy')){ toast('This page is broken (the reason box is missing), nothing was cancelled. Refresh the page.', false); return; }
      const why = (bbVal('cancelwhy') || '').trim();
      if(!why){ toast('A reason is required, it is kept for the record.', false); return; }
      btn.disabled = true;
      fetch('/api/cancel-purchase', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: openRow, reason_type: picked.value, reason_text: why})
      }).then(r => r.json()).then(d => {
        btn.disabled = false;
        if(!d || !d.ok){ toast((d && d.error) || 'Could not cancel.', false); return; }
        ALL = d.purchases || ALL;
        closeCancelModal();
        render();
        paintDrawer();
        loadRecordEvents(openRow);
      }).catch((err) => { console.warn('BidBrain: cancel failed.', err); btn.disabled = false; toast('Could not cancel.', false); });
    }
    // Deletes a hand added car for good (2026-08-31, "is there a way i can
    // delete a manually added car?"). A plain confirm(), matching the
    // existing "Delete this saved view?" pattern, since there is nothing
    // to type, unlike Cancel which needs a real reason for the record.
    // Closes the drawer afterwards rather than repainting it, the record
    // it was showing no longer exists.
    function deleteManualCar(id){
      const r = rowById(id);
      if(!r) return;
      if(!confirm('Delete ' + (r.reg || 'this car') + ' for good? This removes it and its whole ' +
                  'history, there is no undo.')) return;
      closeFloaters();
      fetch('/api/delete-manual-purchase', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({id: id})
      }).then(r2 => r2.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'Could not delete this car.', false); return; }
        ALL = d.purchases || ALL;
        closeDrawer();
        render();
      }).catch((err) => { console.warn('BidBrain: delete failed.', err); toast('Could not delete this car.', false); });
    }

    // Add a car by hand (a local trader buy, nothing to sync from).
    function openManualModal(){
      closeFloaters();
      const mm = bbEl('manualmodal');
      if(mm) mm.hidden = false;
      // The purchase date is the one field you always know, so it starts at today
      // rather than empty. Only when blank, so a half filled form kept
      // after a failed save kicks nothing out.
      const md = bbEl('man-date');
      if(md && !md.value) md.value = new Date().toLocaleDateString('en-CA');
      pmddPaint('supplier', 'man-supplier-wrap', 'man-supplier');
      pmddPaint('person_collecting', 'man-person-wrap', 'man-person');
      manFeeSummary();
      const mr = bbEl('man-reg');
      if(mr) mr.focus();
    }
    function closeManualModal(){ const mm = bbEl('manualmodal'); if(mm) mm.hidden = true; }
    // The live line under the fees (2026-08-31). The amounts typed are
    // always NET, matching every platform invoice and what DealerKit's own
    // expense lines expect ("Unit Net Â£'s" plus a tariff), so showing the
    // VAT and the real all in figure as you type is what makes that plain
    // without another control to read.
    const MAN_VAT_RATE = 0.20;
    function manFeeSummary(){
      const out = bbEl('man-totals');
      if(!out) return;
      const num = id => { const n = parseFloat(bbVal(id)); return isFinite(n) ? n : 0; };
      const fee = num('man-fee'), transport = num('man-transport');
      const on = id => { const el = bbEl(id); return !!(el && el.checked); };
      const vat = (on('man-fee-vat') ? fee * MAN_VAT_RATE : 0) +
                  (on('man-transport-vat') ? transport * MAN_VAT_RATE : 0);
      const price = num('man-price');
      if(!fee && !transport && !price){ out.textContent = ''; return; }
      out.innerHTML = 'VAT <b>' + pounds(vat) + '</b> Â· Fees <b>' + pounds(fee + transport + vat) +
        '</b> Â· All in <b>' + pounds(price + fee + transport + vat) + '</b>';
    }
    // Save one new dropdown option, shared by every dropdown-with-add
    // control on the page (this modal's own, and the table/drawer's own
    // openSelMenu). Reassigns DROPDOWNS from the response so the new name
    // is offered immediately rather than only after a reload. A name that
    // already exists (any case) is treated as a quiet pick of the existing
    // one, not an error (2026-08-31, Steven: it should "just quietly use the
    // existing one"); d.value is always the option's own real casing.
    async function addDropdownOption(column, value){
      const r = await fetch('/api/add-purchase-dropdown-option', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({column: column, value: value})});
      const d = await r.json();
      if(!d || !d.ok) throw new Error((d && d.error) || 'Could not save that name.');
      if(d.dropdowns) DROPDOWNS = d.dropdowns;
      return d;
    }
    // One control, three states, shared by every field on the Add car
    // modal that offers "pick from a list, or add a new one" (2026-08-31,
    // Steven: "the supplier should still be a dropdown... with the ability
    // to add a new one from the modal, the same should be for person
    // collecting"). PMDD_NOUN/PMDD_PLACEHOLDER are the only per-field bit.
    const PMDD_TICK = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#fff" ' +
      'stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5L19.5 7"></path></svg>';
    const PMDD_NOUN = {supplier: 'supplier', person_collecting: 'person'};
    const PMDD_PLACEHOLDER = {supplier: 'Name the trader you bought it from',
                              person_collecting: 'Name the person'};
    function pmddPaint(col, wrapId, selId, pick){
      const wrap = bbEl(wrapId);
      if(!wrap) return;
      const keep = pick != null ? pick : (bbEl(selId) ? bbEl(selId).value : '');
      wrap.innerHTML = '<select id="' + selId + '" onchange="pmddChange(\'' + col + '\',\'' +
        wrapId + '\',\'' + selId + '\')"><option value="">Not recorded</option>' +
        optList(col).filter(o => !o.retired)
          .map(o => '<option value="' + esc(o.value) + '">' + esc(o.value) + '</option>').join('') +
        '<option value="__new__">+ Add a new ' + PMDD_NOUN[col] + '&hellip;</option></select>';
      if(keep && keep !== '__new__') bbEl(selId).value = keep;
    }
    // Picking "+ Add a new ..." REPLACES the select with a name box, never
    // joins it: the two-controls-disagreeing confusion ("theres no way to
    // save it and it still says supplier not recorded") only ever happened
    // because a second box sat next to a dropdown that still read "Not
    // recorded". Committing (Enter, or moving on to the next field) saves
    // the name and repaints straight back to the select with it chosen, so
    // the answer visibly lands rather than needing a Save button at all.
    function pmddChange(col, wrapId, selId){
      const sel = bbEl(selId);
      if(!sel || sel.value !== '__new__') return;
      const wrap = bbEl(wrapId);
      // The tick and the cancel both prevent their own mousedown, so
      // pressing either never blurs the box first (which would commit
      // through onblur before the click ever landed, cancelling in
      // particular saving the very name you were backing out of). Same
      // guard the notes editor's own tick already uses.
      wrap.innerHTML = '<div class="pmdd-add"><input type="text" id="' + selId + '-new" placeholder="' +
        esc(PMDD_PLACEHOLDER[col]) + '"><button type="button" class="pmdd-ok" title="Save this name" ' +
        'onmousedown="event.preventDefault()" onclick="pmddCommit(\'' + col + '\',\'' + wrapId +
        '\',\'' + selId + '\')">' + PMDD_TICK + '</button>' +
        '<button type="button" class="pmdd-cancel" title="Cancel" ' +
        'onmousedown="event.preventDefault()" onclick="pmddCancel(\'' + col + '\',\'' + wrapId +
        '\',\'' + selId + '\')">&times;</button></div>';
      const inp = bbEl(selId + '-new');
      inp.onkeydown = e => {
        if(e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); pmddCommit(col, wrapId, selId); }
        else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); pmddCancel(col, wrapId, selId); }
      };
      inp.onblur = () => pmddCommit(col, wrapId, selId);
      inp.focus();
    }
    function pmddCancel(col, wrapId, selId){ pmddPaint(col, wrapId, selId, ''); }
    async function pmddCommit(col, wrapId, selId){
      const inp = bbEl(selId + '-new');
      if(!inp) return; // already handled by the other path (Enter vs blur)
      const name = inp.value.trim();
      if(!name){ pmddCancel(col, wrapId, selId); return; }
      inp.onblur = null; // one commit only, however it was triggered
      inp.disabled = true;
      try {
        const d = await addDropdownOption(col, name);
        pmddPaint(col, wrapId, selId, d.value);
      } catch(e){
        toast(e.message || 'Could not save that name.', false);
        pmddPaint(col, wrapId, selId, '');
      }
    }
    async function saveManual(btn){
      // WRITE PATH: a missing field box means a broken page, abort rather
      // than quietly saving a car with blank fields.
      const need = ['man-reg', 'man-name', 'man-price', 'man-mileage', 'man-date',
                    'man-fee', 'man-transport', 'man-fee-vat', 'man-transport-vat',
                    'man-notes', 'man-supplier',
                    'man-person', 'man-collection', 'man-retail'];
      // A dropdown mid "add a new name" swaps its <select> for an input
      // with the same id plus '-new', which is a legitimate state, not a
      // broken page: clicking Add the car with a typed-but-uncommitted
      // supplier used to false-alarm "This page is broken ... Refresh"
      // on the flow's most natural gesture, and the pending() safety net
      // written for exactly this case sat below this guard and could
      // never run (2026-09-01 sweep).
      if(need.some(x => !document.getElementById(x) && !document.getElementById(x + '-new'))){
        toast('This page is broken (an Add car field is missing), nothing was saved. Refresh the page.', false);
        return;
      }
      const v = id => (bbVal(id) || '').trim();
      btn.disabled = true;
      // A safety net for a name typed into a still-open add box that was
      // never committed (Add the car clicked before Enter or a blur): the
      // real save path is pmddCommit, this only ever fires if that never
      // ran, same idea as the field's own placeholder text describes.
      const pending = async (col, wrapId, selId) => {
        // Plain getElementById: the add box only exists mid "add a new
        // name", so bbEl's missing-element warning would be noise here.
        const box = document.getElementById(selId + '-new');
        if(!box) return v(selId);
        const name = box.value.trim();
        if(!name) return '';
        const d = await addDropdownOption(col, name);
        return d.value;
      };
      let supplier, person;
      try {
        supplier = await pending('supplier', 'man-supplier-wrap', 'man-supplier');
        person = await pending('person_collecting', 'man-person-wrap', 'man-person');
      } catch(e){ btn.disabled = false; toast(e.message || 'Could not save that name.', false); return; }
      const payload = {reg: v('man-reg'), name: v('man-name'), price: v('man-price'),
        mileage: v('man-mileage'), bought_date: v('man-date'), fee: v('man-fee'),
        transport: v('man-transport'), notes: v('man-notes'),
        // VAT is never typed any more, it is computed server side from
        // these two flags at the standard rate (2026-08-31).
        fee_has_vat: bbEl('man-fee-vat').checked ? 1 : 0,
        transport_has_vat: bbEl('man-transport-vat').checked ? 1 : 0,
        supplier: supplier, person_collecting: person, collection_arranged: v('man-collection'),
        retail_estimate: v('man-retail')};
      fetch('/api/add-manual-purchase', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(payload)
      }).then(r => r.json()).then(d => {
        btn.disabled = false;
        if(!d || !d.ok){ toast((d && d.error) || 'Could not add the car.', false); return; }
        ALL = d.purchases || ALL;
        ['man-reg', 'man-name', 'man-price', 'man-mileage', 'man-date', 'man-fee',
         'man-transport', 'man-notes', 'man-collection', 'man-retail'].forEach(x => bbSetVal(x, ''));
        ['man-fee-vat', 'man-transport-vat'].forEach(x => { const e = bbEl(x); if(e) e.checked = false; });
        manFeeSummary();
        pmddPaint('supplier', 'man-supplier-wrap', 'man-supplier', '');
        pmddPaint('person_collecting', 'man-person-wrap', 'man-person', '');
        closeManualModal();
        render();
        if(d.id) openDrawer(d.id);
      }).catch((err) => { console.warn('BidBrain: add car failed.', err); btn.disabled = false; toast('Could not add the car.', false); });
    }

    // The Fields menu: which columns show on the table and on the record,
    // saved server side so the choice holds on every device (unlike the
    // drawer width, which is deliberately per device).
    const COLUMNS = [
      ['platform', 'Platform'], ['stockstatus', 'Status'], ['aucstatus', 'Auc status'],
      ['bought', 'Purchase date'], ['duein', 'Due in date'], ['collection', 'Collection arranged'],
      ['video', 'Video'], ['person', 'Person collecting'], ['paidfor', 'Paid for'],
      ['onfinance', 'On finance'], ['notes', 'Notes'], ['location', 'Location'],
      ['supplier', 'Supplier'],
      ['price', 'Car price'], ['chips', 'Chipped'], ['retail', 'Retail est.'],
      ['margin', 'Margin'],
      ['history', 'Service history'], ['v5', 'Logbook'], ['dk', 'DealerKit'],
    ];
    function paintFieldsList(){
      const list = bbEl('fieldlist');
      if(!list) return;

      list.innerHTML = COLUMNS.slice()
        .sort((a, b) => colPos(a[0]) - colPos(b[0]))
        .filter(c => c[0] !== 'video' || FLOW.video_stage)
        .map(c => {
          const cfg = COLS_CFG[c[0]] || {};
          return '<div class="fmrow"><span class="fname2">' + c[1] + '</span>' +
            '<span class="cl"><input type="checkbox" data-col="' + c[0] + '" data-where="table"' +
            (cfg.table === false ? '' : ' checked') + ' onchange="saveFieldsCfg(this)"></span>' +
            '<span class="cl"><input type="checkbox" data-col="' + c[0] + '" data-where="record"' +
            (cfg.record === false ? '' : ' checked') + ' onchange="saveFieldsCfg(this)"></span></div>';
        }).join('');
    }
    function saveFieldsCfg(box){
      const col = box.dataset.col, where = box.dataset.where;
      if(!COLS_CFG[col]) COLS_CFG[col] = {};
      COLS_CFG[col][where] = box.checked;
      render();
      if(openRow != null) paintDrawer();
      saveColsCfg();
    }

    // Saved views (Steven's original ask: "...saved as a view"): a view
    // remembers the page exactly as it is, the view tab, platform tab,
    // search, dates and sort, saved server side for the dealership the
    // same way the column choices are.
    let PVIEWS = {{PVIEWS_JSON}};
    const PIN_SVG = '{{PIN_SVG}}';
    const VIEWX_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="M6 6l12 12"></path></svg>';
    function toggleViewMenu(ev){
      if(ev) ev.stopPropagation();
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(!m) return;
      const open = m.hidden;
      m.hidden = !open;
      if(c) c.setAttribute('aria-expanded', open);
      if(open) paintViewsList();
    }
    function closeViewMenu(){
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(m) m.hidden = true;
      if(c) c.setAttribute('aria-expanded', false);
    }
    document.addEventListener('click', () => closeViewMenu());

    // Same derived label as the cockpit: the name of the view you are exactly
    // on, "All cars" when nothing is applied, or "Custom" the moment you
    // deviate. Compared over PV_IDS plus the sort and nothing else, treating a
    // missing key as its default, so an older view with fewer keys still
    // matches rather than reading Custom the instant you click it.
    const PV_DEFAULTS = {filterstatus: 'duein'};
    function pvVal(o, k){
      let v = (o && o[k] !== undefined && o[k] !== null) ? String(o[k]) : '';
      if(k === 'filterstatus' && !v && o && o.view) v = String(o.view);
      if(k === 'datefrom' && !v && o && o.from) v = String(o.from);
      if(k === 'dateto' && !v && o && o.to) v = String(o.to);
      return v === '' ? (PV_DEFAULTS[k] || '') : v;
    }
    function samePView(a, b){
      if(String(a && a.sortKey || '') !== String(b && b.sortKey || '')) return false;
      if(Number((a && a.sortDir) || 1) !== Number((b && b.sortDir) || 1)) return false;
      // Layout is deliberately not compared: it saves itself to the view
      // you are on, so it can never make that view read Custom.
      return PV_IDS.every(k => pvVal(a, k) === pvVal(b, k));
    }
    function activePViewName(){
      const c = currentCriteria();
      if(!c) return 'All cars';
      // A saved name wins over "All cars": someone who saved the default
      // state under their own name clicks it and must see that name back,
      // or the click reads as having done nothing (2026-08-30, Steven's
      // real 'default' view is exactly this case).
      const hit = PVIEWS.find(v => samePView(c, v.criteria || {}));
      if(hit) return hit.name;
      return samePView(c, {}) ? 'All cars' : 'Custom';
    }
    // How many rows you would see if you clicked this view, in the platform
    // you are actually looking at.
    function pViewCount(criteria){
      const base = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab);
      return filterRows(base, k => pvVal(criteria, k)).length;
    }
    // Same shape as the cockpit's paintViewChip: the derived label, an All
    // cars row, then your views pinned-first, each with a live count.
    function paintViewsList(shownNow){
      const name = activePViewName();
      bbSetText('viewchiplabel', name);
      const chip = bbEl('viewchip');
      if(chip) chip.classList.toggle('custom', name === 'Custom');
      // Same as the cockpit: the chip carries the count of what you are
      // actually looking at, the figure render() has just worked out.
      bbSetText('viewchipn', shownNow === undefined
        ? pViewCount(currentCriteria() || {}) : shownNow);
      const list = bbEl('viewslist');
      if(list){
        const order = PVIEWS.map((v, i) => ({v: v, i: i}))
          .sort((a, b) => (b.v.starred ? 1 : 0) - (a.v.starred ? 1 : 0));
        list.innerHTML = order.length ? order.map(o => (
'<div class="viewrow' + (name === o.v.name ? ' on' : '') + '" onclick="applySavedView(' + o.i + ')">' +
          '<button type="button" class="viewpin' + (o.v.starred ? ' on' : '') +
          '" onclick="event.stopPropagation();toggleViewStar(' + o.i + ')" title="' +
          (o.v.starred ? 'Unpin' : 'Pin to the top') + '">' + PIN_SVG + '</button>' +
          '<span class="viewname-btn">' + esc(o.v.name) + '</span>' +
          '<span class="viewn">' + pViewCount(o.v.criteria || {}) + '</span>' +
          '<button type="button" class="viewdel" title="Delete this view" ' +
          'onclick="event.stopPropagation();deleteSavedView(' + o.i + ')">' + VIEWX_SVG + '</button></div>'
        )).join('') : '<p class="viewsempty">No saved views yet.</p>';
      }
      const allRow = bbEl('viewallrow');
      if(allRow){
        allRow.classList.toggle('on', name === 'All cars');
        bbSetText('viewalln', pViewCount({}));
      }
      const save = bbEl('viewsaverow');
      if(save) save.classList.toggle('hot', name === 'Custom');
      const nameBox = bbEl('viewname');
      if(nameBox) nameBox.placeholder = name === 'Custom' ? 'Name this view' : 'Name a view';
    }
    function toggleViewStar(i){
      const v = PVIEWS[i];
      if(!v) return;
      v.starred = !v.starred;
      savePViews(() => paintViewsList());
    }
    // Every filter this page actually has. A view used to remember 7 of them,
    // so a derived chip label would have lied the moment you touched one of
    // the other 16 (2026-08-30). Platform is deliberately NOT here: which cars
    // you are looking at is never part of a view, same as the cockpit.
    const PV_IDS = ['filterstatus','filtercancelreason','search','datefrom','dateto','collfrom','collto',
      'filtervideo','filterperson','filterfinance','filterlogbook',
      'filterpaid','filterdocs',
      'filterpricemin','filterpricemax','filterretailmin','filterretailmax',
      'filtermarginmin','filtermarginmax','filtermilesmin','filtermilesmax'];
    function currentCriteria(){
      // WRITE PATH feeder: a missing filter box means a broken page, so
      // refuse (null) rather than save a view with silently blank fields.
      if(PV_IDS.some(id => !bbEl(id))) return null;
      const c = {sortKey: sortKey, sortDir: sortDir};
      PV_IDS.forEach(id => { c[id] = bbVal(id) || ''; });
      // The layout is read back as part of the criteria (so a saved view
      // carries it), but never decides Custom: see samePView.
      c.columns = colsSnapshot(COLS_CFG);
      return c;
    }
    // Which view the column layout currently belongs to: a saved view's
    // name, or null for All cars (the dealership's default layout).
    let activeBaseView = null;
    function applySavedView(i){
      const v = PVIEWS[i];
      if(!v) return;
      activeBaseView = v.name;
      applyPCriteria(v.criteria || {});
    }
    // The views chip's "All cars" row calls this, so every page carrying the
    // chip has to define it (this page did not, and the row threw a
    // ReferenceError and silently did nothing, caught in review 2026-08-30).
    // It means "no view applied", which is every filter cleared AND the sort
    // back to this page's default: the sort is part of a view, so clearing
    // the filters alone would leave the derived label reading Custom the
    // moment you clicked All cars. clearAllFilters re renders, so the sort is
    // reset first and the page is painted once.
    function resetFilters(){
      sortKey = null;
      sortDir = 1;
      activeBaseView = null;
      COLS_CFG = JSON.parse(JSON.stringify(COLS_BASE));
      paintFieldsList();
      clearAllFilters();
    }
    // Restores a view exactly, so the state it produces is the state that was
    // saved and the derived label can be trusted. The platform tab is left
    // alone: it is not part of a view.
    function applyPCriteria(c){
      PV_IDS.forEach(id => bbSetVal(id, c[id] || ''));
      // 'view' was the old key for this before it became a real filter.
      bbSetVal('filterstatus', c.filterstatus || c.view || 'duein');
      syncViewFromStatus();
      // 'from'/'to' were the old key names for the bought date pair, kept so
      // a view saved before 2026-08-30 still restores its dates.
      if(c.from !== undefined) bbSetVal('datefrom', c.from || '');
      if(c.to !== undefined) bbSetVal('dateto', c.to || '');
      sortKey = c.sortKey || null;
      sortDir = c.sortDir === -1 ? -1 : 1;
      // The view's own column layout, or the default when it has none.
      let cols = null;
      try { cols = c.columns ? JSON.parse(c.columns) : null; } catch(e){ cols = null; }
      COLS_CFG = JSON.parse(JSON.stringify(cols || COLS_BASE));
      paintRanges();
      closeFloaters();
      render();
      paintFieldsList();
    }
    function savePViews(after){
      fetch('/api/purchase-page-settings', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({purchase_views: PVIEWS})
      }).then(r => r.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'The view did not save.', false); return; }
        if(after) after();
      }).catch((err) => { console.warn('BidBrain: view save failed.', err); toast('The view did not save.', false); });
    }
    function saveCurrentView(){
      const inp = bbEl('viewname');
      if(!inp) return;
      const name = (inp.value || '').trim();
      if(!name){ inp.focus(); return; }
      const crit = currentCriteria();
      if(!crit){
        toast('This page is broken (a filter box is missing), the view was not saved. Refresh the page.', false);
        return;
      }
      const existing = PVIEWS.findIndex(v => v.name.toLowerCase() === name.toLowerCase());
      const entry = {name: name, criteria: crit,
                     starred: existing >= 0 ? !!PVIEWS[existing].starred : false};
      if(existing >= 0) PVIEWS[existing] = entry; else PVIEWS.push(entry);
      activeBaseView = name;
      savePViews(() => { inp.value = ''; paintViewsList(); });
    }
    function deleteSavedView(i){
      const v = PVIEWS[i];
      if(!v) return;
      if(!confirm('Delete the view ' + v.name + '?')) return;
      PVIEWS.splice(i, 1);
      savePViews(() => paintViewsList());
    }
  </script>
</body>
</html>
F)Úvideo_stageÚfee_rebandsÚpurchase_flowr   ÚDEALER_NAMEz{{DROPDOWNS_JSON}}z{{COLS_CFG_JSON}}z{{FLOW_JSON}}z{{FEE_BANDS_JSON}}z{{PVIEWS_JSON}}Úpurchase_viewsz{{WHO}}ÚYouú{{THEME_BG}}ú{{THEME_PRICE}}ú{{THEME_PILL}}ú{{THEME_ICON}}ú{{THEME_SIDEBAR}}ú{{THEME_FAVICON}}ú{{MW_LOGO}}ú{{CW_LOGO}}z{{A4C_LOGO}}z{{DA_LOGO}}ú{{EXT_LINK_SVG}}ú{{SEARCH_SVG}}ú{{GUARD_JS}}ú{{FRAME_CSS}}ú{{FRAME_JS}}©r¥   ú{{VIEWS_CHIP}}ú{{MORE_BTN}}ú{{FILTER_CHIPS}}ú{{MORE_DRAWER}})ÚsortÚSortz<div id="sortlist"></div>ÚfiltersÚFiltersú¡<div class="moredr-panelhead"><span>Narrow the list</span><button type="button" class="moredr-reset" onclick="clearAllFilters()">Clear all filters</button></div>ÚfilterstatusÚShow))ÚdueinzDue in)ÚinstockzIn stock)Ú	cancelledÚ	Cancelled)ÚallÚAllúframeApply()rÔ  )ÚonchangeÚdefaultÚfiltercancelreasonzCancelled reasonÚcancelreasonfield)rÛ  Úfield_idr”  zPurchase dateÚcollzCollection dateÚfiltervideoÚVideoÚvideo_statusrk   ©rÛ  ÚfilterpersonzPerson collectingÚperson_collectingÚfilterfinanceÚFinanceÚ
on_financeÚfilterlogbookÚLogbookÚlogbook_stateÚ
filterpaidzPaid for)ÚyesÚPaid)ÚnozNot paid yetÚ
filterdocsÚ	Paperwork)Úcompletez(Service history and logbook both on file)ÚmissingzSomething still missingÚfilterpricez	Car priceÚ	car_price©r•   ÚfilterretailzRetail est.Úretail_estimateÚfiltermarginÚMarginrš  Úfiltermilesr  rÖ  éè  Úmiles©r•   r–   r—   )ÚfieldsÚFieldsa°  <div class="fmhead"><span class="sp">Column</span><span class="cl">Table</span><span class="cl">Record</span></div><div id="fieldlist"></div><p class="moredr-note">Set separately, so a column you rarely scan can stay off the table while still living on the car's record. Column order, widths and this list belong to the view you are on and save to it as you change them; on All cars they are the default layout for every device.</p>zFilters and fields©rœ   ú{{PIN_SVG}}ú{{SETTINGS_NAVLINK}}ú{{PAGE_SWITCHER}}r¬   ú{{RUN_MODAL}}ú{{NAVLOGO}})'rÝ   r  r¦  rÄ   r   ÚRD_SMILEÚdealer_configr”  ra   rv   rÅ   rÆ   Úget_purchase_dropdownsÚget_purchase_columnsÚget_fee_bandsrs   ró   rô   rõ   ÚDA_LOGOr;  Ú_SEARCH_SVGrn   r}   r§   Ú_views_chipr‚   r„   r    Ú_filter_select_fixedÚ_filter_selectr‰   r«  r˜   Ú_PIN_SVGÚ_settings_navlinkÚ_page_switcherrØ   Ú_navlogo)rG  Ú_saved_themer­  r®  r¯  r°  r±  r²  r³  r´  rq   Ú_flowÚ_whoÚ_dcrª  s                  r   Úrender_purchasesr  à  s€  € õ8 #Ø×#Ñ#Ó%€LØ×Ñ 
Ó+×KÔ/?À
Ñ/K€HØ×"Ñ" =Ó1×TÔ5EÀmÑ5T€KØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ ×$Ñ$ _Ó5×ZÔ9IÈ/Ñ9Z€MØ"×&Ñ&Ð'8Ó9×A¸T€OØ#×'Ñ'Ð(:Ó;×C¸tÐØ ×$Ñ$ _Ó5×UÐ9I×UÌX€MðhF€Dð`M "°"ñ :Ø× Ñ  Ó1×7°Rð:€Eà€DðÛ#Ü�s˜M¨2Ó.×4°"ˆð ß‰WÐ)¬4¯:ª:°c×6PÑ6PÓ6RÓ+SÓTß‰WÐ(¬$¯*ª*°S×5MÑ5MÓ5OÓ*PÓQß‰W�_¤d§j¢j°Ó&7Ó8ß‰WÐ)¬4¯:ª:°c×6GÑ6GÓ6IÓ+JÓKß‰WÐ&¬¯
ª
Ø× Ñ Ó"×&Ñ&Ð'7Ó8×>¸Bó)@ó Aç‰W�Y §¨Ó.ð 	ô Ø�‰�^ XÓ.×6Ñ6Ð7HÈ+ÓVß‰WÐ% zÓ2·7±7Ð;KÈZÓ3Xß‰WÐ(¨-Ó8ß‰WÐ(¨-Ó8ß‰W�]¤GÓ,¯W©W°]ÄGÓ-Lß‰W�^¤XÓ.ß‰W�]¤GÓ,ß‰WÐ'Ô);Ó<ß‰WÐ%¤{Ó3ß‰W�^¤Y£[Ó1ß‰W�_¤j£lÓ3ß‰W�^¤Y¸eÑ%DÓEß‰WÐ%¤{£}Ó5ß‰W�^¤Y£[Ó1ß‰WÐ'Ô):Ó)<Ó=ß‰WÐ&¬Ø=Ø˜Ið4ô (¨¸òBà,°gñ?ñ?ô "Ð"6Ð8JÈBØ+9ÐDWñYñYô & f¨oÓ>ñ	?ô & fÐ.?Ó@ñ
Aô " -°Ü7BÀ>Ô7RÓ"SÒ7R°! W¤:Ñ7RÑ"SØ+9ñ;ñ;ô " .Ð2EÜ7BÐCVÔ7WÓ"XÒ7W°! W¤:Ñ7WÑ"XØ+9ñ;ñ;ô" " /°9Ü7BÀ<Ô7PÓ"QÒ7P°! W¤:Ñ7PÑ"QØ+9ñ;ñ#;ô( " /°9Ü7BÀ?Ô7SÓ"TÒ7S°! W¤:Ñ7SÑ"TØ+9ñ;ñ);ô. (¨°jØ)8Ð:PÐ(QØ1?ñAñ/Aô4 (¨°kØ)aØ)Oð)Qà1?ñAñ5Aô< % ]°KÀKÑPñ=Qô> % ^°]ÐGXÑYñ?Zô@ % ^°XÀ(ÑKñA LôB % ]°IÀ)Ø*.°Gñ=ñC"=ð#>ðH_ðM.6ð\ *ñ].)+ó .,÷^ ‰W�]¤HÓ-ß‰WÐ+Ô->Ó-@ÓAß‰WÐ(¬.¸Ó*EÓFß‰W�_Ô&7Ó&9Ó:ß‰W�]¤H¨_Ó$=Ó>óGC@ð C@øô ó ÚðüòR #Tùò #Yùò #Rùò #Us*   Ä;V& ÑV7"Ñ*V<"ÒW"Ò<W"Ö&
V4Ö3V4Ú	DealerKitr  z
LE Capitalr  zMotorway (accounts)r   r!  r"  r#  r  z	Dealer OS)r©   rµ   r¸   ræ   Úmotorway_accountsrç   rè   rê   rë   Úcazanar²   )r©   r²   rµ   r  r¸   ræ   r  rç   rè   rê   rë   z17:00z06:00)r  rç   r©   ræ   r  rè   rê   r¸   r²   z‘Your DMS. Stock counts feed the cockpit's gap, ok and too many flags, and purchases can be pushed in as Due In records with photos and documents.z’The second retail valuation source, added on demand via Run Glass's checks. A single shared login, so BidBrain should be the only thing signed in.z_The retail valuation source every run prices on. No on or off switch, every run always uses it.z\Stock funding platform. Syncs funded and settled loans into DealerKit's own Funding records.z-Daily auction stock, purchases and lost bids.a\  Right Drive's second Motorway account, accounts@rightdrive.co.uk, its own separate login and browser profile. Only ever used to receive a copy of every starred car's own shortlist sync, alongside the primary account, so whoever completes a purchase (which needs this inbox's own SMS code) sees the same shortlist. Never reads stock or places a bid.z&Daily auction stock and won purchases.zGMulti day auction platform, read daily for lots ending within the week.z•Auto Trader's own trade auction platform, read daily for lots ending within the week. Sometimes needs an SMS code at login, so this one stays manual.a  Mark's Dealer OS platform. BidBrain sends its finished daily list (the shortlisted and held cars, each with its price) into Dealer OS's own BidBrain page after every run, using the shared secret in dealer_config.py. Dealer OS only displays the list, all the buying rules stay here.)r©   rµ   r  r¸   ræ   r  rç   rè   rê   rë   r²   r¨   zDealerKit stock readr©   z…Pulls DealerKit's in stock list each day (and via the Run DealerKit stock button) so cars can carry a gap, ok or too many stock flag.)rS   r$   rÎ   rO  rª   zDealerKit purchases pushztPushes new purchases into DealerKit as Due In records with photos, service history documents, V5 and purchase price.rµ   zGlass's on demand valuationzyAdds Glass's retail valuations to the shortlist when you click Run Glass's checks. Cars still price on Cazana either way.r¶   r·   r¸   zjReads LE Capital's Current Stock and Stock History and adds or settles DealerKit funding records to match.r±   zDealer OS list pushz¬Sends the finished daily list into Dealer OS after every run (and via the Run modal's Dealer OS button). Needs DEALER_OS_BASE_URL and DEALER_OS_API_KEY in dealer_config.py.Úauto_updatez-Update itself when a new release is publishedzÈEvery few minutes, when nothing is running, the Mac checks GitHub for a newer BidBrain release and installs it, restarting BidBrain for about a minute. Off means only the Update now button updates it.Úsite_keyc                 ó´  ^• SSK nSSKJn  SSKJn  UR	                  5       R                  U 5      mUR                  U 5      n[        R                  R                  U5      n[        R                  U 5      nU4S jnS nU(       d  T(       d  Sn	SX™S	.$ T(       a}  TR                  S
5      SL ai  U" 5       n
U
b  U" U
5      OSnTR                  S5      =(       d    Sn[        U5      S::  a  UOUSS R                  5       S-   nSU SU 3n	SU	SU SU 3S	.$ U(       a4  UR                  5       [        R                  R                  U5      -
  S-  nOU" 5       nU [        ;   a3  Uc  Sn	O(US:  a  Sn	O[        US-  5      nSU SUS:w  a  SOS S3n	SX™S	.$ Uc  Sn	O(US:  a  S n	O[        US-  5      nS!U SUS:w  a  SOS S3n	U(       a	  U	S"U S#3-  n	SX™S	.$ )$aK  The one honest, merged state for a site's login: did the last real
attempt (db.record_site_health, written both by a genuine data read
and, since 2026-08-26, by a successful automated login) outright fail,
or is there simply no evidence of one on this machine at all, or is
everything fine, and if so how fresh. Replaces the old separate
_login_status (session file freshness only) and _site_flag (last read
outcome only) texts, which could say contradictory things (Steven
2026-08-26, caught live: a red flag next to "logged in within the last
day" for DealerKit, a green flag next to "no saved login yet" for
Motorway, since the two were never connected to each other). Returns
{"dot": "good"/"bad"/"grey", "message": the one line to show, "title":
the full, untruncated version for a hover tooltip}. Read only, costs
nothing at render time.r   N)Úbrowserr  c                  ó
  >• T(       a  TR                  S5      (       d  g  [        R                  R                  TS   5      n [        R                  R	                  5       U -
  R                  5       S-  $ ! [         a     g f = f)NÚ
checked_até  )rÄ   r“  ÚfromisoformatÚ
ValueErrorÚnowÚtotal_seconds)ÚcheckedÚhealths    €r   Ú_checked_age_hoursÚ'_site_state.<locals>._checked_age_hoursk   su   ø€ Þ˜VŸZ™Z¨×5Ñ5Øð	Ü×'Ñ'×5Ñ5°f¸\Ñ6JÓKˆGô ×!Ñ!×%Ñ%Ó'¨'Ñ1×@Ñ@ÓBÀTÑIÐIøô ó 	Ùð	ús   ¡"A5 Á5
BÂBc                 óF   • U S:  a  g[        U S-  5      nUS:X  a  S$ U S3$ )Né   r•  r   Ú	yesterdayz	 days ago©rC  )Úhoursr˜  s     r   Ú
_age_wordsÚ_site_state.<locals>._age_wordst   s2   € Ø�2‹:ØÜ�5˜B‘;ÓˆØ" a›iˆ{Ð?°¨v°YÐ-?Ð?r&   zNot set up on this Mac yet.Úgrey)ÚdotÚmessagerœ   rF  FÚrecentlyr6  zThe last attempt failed.éŒ   é‰   z...zDown since r’  Úbadr$  zWorking.r.  zWorking, list sent today.zWorking, list last sent z dayr   rî   r   z ago.ÚgoodzWorking, logged in today.zWorking, last logged in z Logs itself back in daily at r…  )ÚtimerÝ   r!  r  Úget_site_healthrÄ   Ú
state_pathr\   r]   ÚexistsÚ
_SITE_AUTOr:   r“  ÚgetmtimeÚ_NO_LOGIN_SITESrC  )r  Ú_timeÚ_browserrG  r>  Úhas_state_fileÚ	auto_timer+  r2  ÚmsgÚageÚwhenÚdetail_fullÚdetailÚ	age_hoursr˜  r*  s                   @r   Ú_site_staterM  U   sø  ø€ ó Ý,Ý"Ø× Ñ Ó"×&Ñ& xÓ0€FØ×$Ñ$ XÓ.€JÜ—W‘W—^‘^ JÓ/€NÜ—‘˜xÓ(€IõJò@ö ¦&Ø+ˆØ¨#Ñ<Ð<ö �&—*‘*˜TÓ" eÒ+Ù Ó"ˆØ"%¡/‰z˜#Œ°zˆØ—j‘j Ó+×IÐ/IˆÜ # KÓ 0°CÓ 7‘¸[ÈÈ#Ð=N×=UÑ=UÓ=WÐZ_Ñ=_ˆØ˜D˜6  F 8Ð,ˆØ¨¸ÀTÀFÈ"È[ÈMÐ7ZÑ[Ð[æØ—Z‘Z“\¤B§G¡G×$4Ñ$4°ZÓ$@Ñ@ÀDÑH‰	á&Ó(ˆ	Ø”?Ó"àÑØ‰CØ˜‹^Ø-‰Cä�y B‘Ó'ˆDØ,¨T¨F°$¸dÀa»i±sÈRÐ6PÐPUÐVˆCØ¨#Ñ<Ð<ØÑØ‰Ø	�R‹Ø)‰ä�9 ‘?Ó#ˆØ(¨¨¨d¸$À!»)±3ÈÐ2LÈEÐRˆÞØÐ/°	¨{¸!Ð<Ñ<ˆØ cÑ8Ð8r&   c                  óº  • SSK Jn   U R                  5       nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d    S	nUR                  S
5      =(       d    S	nUR                  S5      =(       d    U=(       d    [
        n	Sn
[        U
R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU	5      R                  S[        5      R                  S[        5      R                  S[        5      R                  S[        5       5      R                  S[        5       5      R                  S[        5       5      R                  S[        5       5      R                  S[        5      R                  S[         R"                  " U R                  5       R                  S5      =(       d    / 5      5      R                  S[%        5       5      R                  S['        5       5      R                  S[)        S S![+        S"S#5      [-        S$S%S&S'S(S)9-   [/        S*S+S*S,9-   [/        S-S.S/S,9-   4/5      5      R                  S0[1        5       5      R                  S1[3        S25      5      R                  S3[5        5       5      R                  S4[7        U5      5      5      $ )5aU  The Didn't win admin page (Mark 2026-08-24: "the original vision for
BB was to use ai to understand from our own data our best sellers...
to make educated bids... we could also use data that we bid on that
didnt win... lets start with motorway"). Every real auction bid on and
lost, read off Motorway's own Bids & offers > Didn't win screen by
lost_bids_run.py into the lost_bids table, ready for a future
suggested bid feature: the real Sold for price is what it actually
would have taken to win. Same shape and conventions as the purchases
page (fetch live from an API on load, never baked stale into a server
render), deliberately simpler since there is no status to track and
nothing to sync back to a DMS, just a growing dataset. Theme colours
(Mark 2026-08-25) read fresh from settings on every request, same
live pattern as the rest of this page.r   r  r­  r®  r¯  r°  r±  r²  Nr³  r´  uV¦  <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, didn't win</title>
<link rel="icon" href="{{THEME_FAVICON}}">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root { --ink:oklch(0.279 0.0299 260.05); --soft:oklch(0.446 0.0313 257.3); --line:oklch(0.929 0.0095 255.53);
    --bg:{{THEME_BG}}; --no:oklch(0.444 0.177 26.9); --good:oklch(0.448 0.119 151.33);
    --motorway:#0a5fb4; --carwow:#7b3ff2;
    --price:{{THEME_PRICE}}; --pill:{{THEME_PILL}}; --icon:{{THEME_ICON}};
    --sidebar-bg:{{THEME_SIDEBAR}}; --radius-xl:14px; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif; }
  .appshell { display:flex; align-items:flex-start; gap:0; min-height:100vh; background:var(--sidebar-bg); padding:8px; }
  /* No overflow:hidden here (Mark 2026-08-25: "make the top nav sticky so
     the listing go under as you scroll") â€” it was clipping the .controls
     bar's own position:sticky, an ancestor with overflow hidden breaks
     sticky positioning entirely since it stops being "the page scrolls"
     and becomes "this box scrolls", which .insetpanel itself never
     actually does (the whole page does). .controls gets the top corners
     directly instead so the rounded panel still looks seamless where it
     is actually ever seen; the bottom corners are moot in practice, the
     panel is always taller than the viewport until the very end of a
     long list, and only a sharp edge on the last row could ever poke
     past them there. */
  .insetpanel { flex:1; min-width:0; background:var(--bg); border-radius:var(--radius-xl);
    box-shadow:0 1px 3px rgba(20,30,40,.08), 0 1px 2px rgba(20,30,40,.04); }
  @media (max-width:860px) {
    /* align-items:stretch: the column flip otherwise inherits the row
       layout's flex-start, the panel shrinks to content width and the
       whole PAGE scrolls sideways (2026-08-29 hardening). */
    .appshell { flex-direction:column; padding:0; align-items:stretch; }
    .insetpanel { border-radius:0; }
  }
  .controls { position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px rgba(20,30,40,.05); border-radius:var(--radius-xl) var(--radius-xl) 0 0; }
  .controls .brand { font-size:18px; font-weight:800; letter-spacing:-0.02em; color:var(--ink);
    margin-right:2px; white-space:nowrap; flex:none; text-decoration:none; cursor:pointer; }
  .controls .brand span { color:var(--icon); }
  /* All/Motorway/Carwow tabs, same shape as the cockpit and purchases
     page's own (Mark 2026-08-25: "add the toggle to lost bids like home
     page and purchase for all, motorway carwow ready to add carwow data
     in to lost bids"). Carwow's own Didn't win reader does not exist yet,
     this just makes the page ready for it, keeping the UI the same as
     everywhere else in the meantime; a Carwow tab with 0 rows today is
     honest, not a guess. */
  .tabs { display:inline-flex; background:var(--sidebar-bg); border-radius:10px; padding:3px; gap:2px; flex:none; }
  .tab { border:none; background:none; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer;
    padding:7px 14px; border-radius:8px; white-space:nowrap; }
  .tab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px rgba(20,30,40,.15); }
  /* The Live Auctions/Purchases/Lost page switcher (Mark 2026-08-27: "copy
     the toggle for the auction house and make toggle for Live Autions,
     Purchases, Lost"), same .tabs/.tab shape, real links not JS state. */
  .pageswitcher .tab { display:flex; align-items:center; gap:6px; text-decoration:none; }
  .pageswitcher .tab svg { width:15px; height:15px; flex:none; }
  /* The search/date filters live in a floating panel opened from the
     section heading (Mark 2026-08-25, moved again 2026-08-26: "bring
     the filter button down off the nav bar again to match the live
     auction page"), same .sortdirbtn shape as the Stats button next to
     it and the cockpit's own Cazana price / Ending soonest / Filters
     row. */
  /* The dealership logo at the left of the nav bar, where the left rail
     used to carry it (2026-08-31). */
  .navlogo { flex:none; display:flex; align-items:center; height:34px; margin-right:4px; }
  .navlogo img { height:26px; width:auto; max-width:150px; object-fit:contain; display:block; }
  .controls-actions { display:flex; align-items:center; gap:4px; margin-left:auto; }
  /* Moved out of the Filters panel onto the nav bar itself, next to the
     refresh icon (Mark 2026-08-26: "move the search from all the filters
     to next to the refresh on the nav bar"), same #search id and oninput
     as before so render()'s own filtering is unchanged, just reachable
     without opening Filters first. */
  .navsearch-wrap { position:relative; display:inline-flex; align-items:center; }
  .navsearch-wrap svg { position:absolute; left:11px; color:var(--soft); pointer-events:none; }
  /* -webkit-appearance:none strips Safari's own native search-field chrome
     on type="search", which reserves internal space for its own icon
     invisible to the CSS box model (padding/width look fine, the text
     still clips) â€” this box already draws its own magnifying glass
     (.navsearch-wrap svg), so the native one was never wanted anyway. */
  .navsearch { height:34px; padding:0 12px 0 32px; border:1px solid var(--line); border-radius:9px;
    font:inherit; font-size:13px; background:var(--sidebar-bg); color:var(--ink); width:230px; max-width:36vw;
    -webkit-appearance:none; appearance:none; }
  .navsearch::placeholder { color:var(--soft); }
  /* "Read full history" moved into this modal (found in review,
     2026-08-30: it was the one text button in an otherwise icon only
     cluster, with no confirm, that could lock the shared browser profile
     for several minutes). Same runmodal shape purchases already uses for
     its own Sync, no options section here since this page has only the
     one reader to sync. */
  .filterscope { font-size:var(--fr-fs-small); color:var(--soft); line-height:1.45; margin:4px 0 0; }
  /* Icon only, no outline (Mark 2026-08-25: "starting from the top right
     user icon, notification bell, star, and a refresh icon all just icons
     no outlines to match the left panel look"), replacing the old solid
     coloured Sync now button. Same id and onclick throughout. */
  .syncbtn { display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    background:none; color:var(--soft); border:none; border-radius:9px; cursor:pointer; padding:0; }
  .syncbtn:hover { background:var(--bg); color:var(--ink); }
  .syncbtn:disabled { color:var(--line); cursor:default; }
  .syncbtn:disabled:hover { background:none; }
  @keyframes navspin { to { transform:rotate(360deg); } }
  /* A quiet text link, not an icon (Mark 2026-08-25's "just icons" ask was
     about the star/bell/user/refresh row; this is a real, distinct,
     genuinely slower action, about 38 pages, kept clearly separate rather
     than folded into the same plain refresh icon Sync now became). */
  .backfillbtn { background:none; color:var(--soft); border:none; font-size:12px; font-weight:700;
    cursor:pointer; white-space:nowrap; text-decoration:underline; text-underline-offset:2px; padding:0; margin-right:8px; }
  .backfillbtn:hover { color:var(--ink); }
  .backfillbtn:disabled { opacity:.6; cursor:default; }
  /* Icon only, same nav convention as the cockpit's own bag/target/wrench
     buttons (Mark 2026-08-25: "use icons to navigate to other pages like
     home page"), so all four pages read as the same product. */
  .settingslink { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px; border:none; background:none; cursor:pointer; padding:0; font:inherit;
    border:none; border-radius:9px; color:var(--soft); text-decoration:none; margin-left:4px; background:none; cursor:pointer; }
  .settingslink:hover { background:var(--bg); color:var(--ink); }
  .settingslink.on { color:#d39e00; }
  .settingslink.on svg { fill:currentColor; }
  /* Notification bell, same design as the cockpit's own (Mark 2026-08-25:
     "standardise the nav to icons for favorites, purchases, notifications,
     lost bids and settings ... same order on all pages with same look and
     design"); this page has no run data of its own to bake notices into at
     render time, so the panel starts empty and fills in from GET
     /api/notices on load, the same live-fetch pattern this page's own
     table already uses. */
  .bellwrap { position:relative; margin-left:4px; }
  /* Signed in user, moved here from the left panel's own footer (Mark
     2026-08-25: "move the user icon top right on the nav"), since the
     panel can now collapse to a rail too narrow for a name. Just the
     round avatar now (Mark 2026-08-25 again: "all just icons no
     outlines"), the name is still on the title attribute as a tooltip. */
  .navuser { display:flex; align-items:center; margin-left:4px; }
  .navuser-av { width:32px; height:32px; border-radius:50%; background:var(--line); flex:none;
    display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:var(--ink); cursor:default; }
  .bellbtn { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .bellbtn:hover { background:var(--bg); color:var(--ink); }
  .bellbadge:empty { display:none; }
  /* box-sizing:border-box shrinks the badge's own content area to 12px
     (16px minus the 2px top+bottom border), but a plain line-height:16px
     still laid the digit out at the full 16px, so it overflowed past the
     visible circle, reading as cut off at the bottom (Mark 2026-08-26:
     "fix the ios red flags they seen to cut off at the bottom"). Flex
     centring is immune to that box-sizing/line-height mismatch, same
     mechanism every other icon button here already centres its own SVG
     with. */
  .bellbadge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 3px;
    border-radius:8px; background:var(--no); color:#fff; font-size:10px; font-weight:800;
    box-sizing:border-box; display:flex; align-items:center; justify-content:center; border:2px solid #fff; }
  .bellpanel { position:absolute; top:calc(100% + 8px); right:0; width:320px; max-height:360px; overflow-y:auto;
    background:#fff; border:1px solid var(--line); border-radius:12px; padding:8px; box-shadow:0 10px 28px rgba(20,30,40,.16);
    z-index:10; }
  .bellpanel[hidden] { display:none; }
  .bellnote { display:block; margin:0; padding:9px 10px; border-radius:8px; font-size:13px; line-height:1.4;
    color:var(--soft); text-decoration:none; }
  .bellnote + .bellnote, .bellnote + a.bellnote, a.bellnote + .bellnote { margin-top:2px; }
  .bellnote.alert { background:#fdf3e0; color:#7a5a00; font-weight:600; }
  .bellnote-link { cursor:pointer; }
  .bellnote-link:hover { filter:brightness(.96); text-decoration:underline; }
  .bellempty { margin:0; padding:14px 10px; font-size:13px; color:var(--soft); text-align:center; }
  /* Red when there is something to actually see, not just the small badge
     count (Mark 2026-08-25: "make the bell go red when notifications").
     The star icon stays the same look as every other nav icon (Mark:
     "make sure the watched / shortlisted icons are always the same"),
     just gaining the same small corner badge with a real count ("add the
     amount watched like the amount of notifications"), both filled in by
     loadNotices() once GET /api/notices resolves. */
  #starbadge:empty { display:none; }
  /* Full width on a real monitor, same cap as the purchases page and the
     live auction cockpit's own .layout (Mark 2026-08-27: "make the didnt
     win page full width responsive"), rather than sitting narrow inside a
     1560px column with wasted space either side on anything wider than a
     laptop. */
  .wrap { max-width:3560px; margin:0 auto; padding:20px; }
  /* Heading above the table now matches the live auction page's own
     section heading (Mark 2026-08-26: "make the heading above the table
     match the live auction"): the platform's own logo (or "All") plus a
     live count on the left, Stats and Filters as a pair of pill buttons
     on the right, same .sortdirbtn shape both already use there. The old
     permanent summary tile row moved into the Stats button's own
     dropdown ("move the 107 car bought etc... data in to a button with
     a floating nav"), same mechanism as the notification bell. */
  .section-head { display:flex; align-items:center; justify-content:space-between; gap:14px;
    flex-wrap:wrap; margin-bottom:16px; }
  .section { font-size:14px; text-transform:uppercase; letter-spacing:1px; color:var(--soft);
    margin:0; display:flex; align-items:center; gap:10px; flex:none; }
  .section .setcount { background:var(--bg); color:var(--ink); border-radius:999px; padding:1px 9px;
    font-size:12px; font-weight:700; vertical-align:middle; }
  .section-logo { height:20px; width:auto; display:block; }
  /* The platform toggle, moved here from the top nav bar (Mark 2026-08-27:
     "make the 2nd nav toggle for auction house move to the same place as
     the home page on the purchases and didn't win pages, make the look
     the same ie logos"), same .tabs/.tab shape and real logos the live
     auction page's own toggle uses, just Motorway/Carwow/All, this page
     never sees the other three platforms. */
  .platform-toggle .tab { display:flex; align-items:center; justify-content:center; min-height:26px; }
  .platform-toggle .tab .section-logo { height:18px; }
  .tablewrap { overflow-x:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
  table { width:100%; border-collapse:collapse; }
  th, td { text-align:left; padding:10px 12px; font-size:13.5px; border-bottom:1px solid var(--line); white-space:nowrap; }
  th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--soft); background:#f7f8f6; }
  tr:last-child td { border-bottom:none; }
  .reg { font-weight:700; background:#f4d000; padding:2px 8px; border-radius:5px; font-size:12px; white-space:nowrap; }
  .plat { font-size:11px; font-weight:700; color:#fff; padding:2px 8px; border-radius:999px; white-space:nowrap; }
  .plat.motorway { background:var(--motorway); } .plat.carwow { background:var(--carwow); }
  .plat.manual { background:#55606b; }
  .num { font-variant-numeric:tabular-nums; }
  .num.dim { color:var(--soft); }
  .lostby { font-weight:700; }
  .lostby.close { color:#a06a00; }
  .lostby.wide { color:var(--no); }
  .empty { color:var(--soft); padding:20px; text-align:center; }
  /* Cazana 115% and Margin tucked behind a small popover on Retail est.,
     same as the purchases page's own (Mark 2026-08-25: "add the margin on
     the retail est for margin... on hover modal"), shown on hover on a
     real pointer, and on a tap via the JS .show class for a touch screen
     with no hover to trigger it. */
  .info-trigger { position:relative; cursor:help; border-bottom:1px dotted var(--soft); }
  .info-pop { display:none; position:absolute; z-index:15; top:calc(100% + 8px); left:0;
    background:#fff; border:1px solid var(--line); border-radius:9px; box-shadow:0 10px 26px rgba(20,30,40,.15);
    padding:8px 10px; min-width:150px; white-space:nowrap; text-align:left; }
  .info-trigger:hover .info-pop, .info-trigger.show .info-pop { display:block; }
  .info-row { display:flex; justify-content:space-between; gap:16px; font-size:12.5px;
    color:var(--soft); padding:3px 0; font-weight:400; }
  .info-row span:last-child { font-variant-numeric:tabular-nums; color:var(--ink); }
  .info-row.good span:last-child { color:var(--good); font-weight:700; }
  .info-row.bad span:last-child { color:var(--no); font-weight:700; }
    th.sortable { cursor:pointer; user-select:none; }
  th.sortable:hover { color:var(--icon); }
  th.sortable.sorted { color:var(--icon); }
  th .sortarrow { display:inline-block; margin-left:3px; }
{{FRAME_CSS}}
  @media (max-width: 760px) {
    .controls { padding:10px 14px; }
    .controls-actions { margin-left:0; width:100%; justify-content:space-between; }
    .navsearch-wrap { flex:1 1 auto; min-width:0; }
    .navsearch { width:100%; }
    .wrap { padding:12px; }
    th, td { padding:8px 9px; font-size:13px; }
    .info-pop { left:auto; right:0; min-width:140px; }
  }
</style>
</head>
<body>
<div class="appshell" id="appshell">
<div class="insetpanel">
  <div class="controls">
    {{NAVLOGO}}
    {{PAGE_SWITCHER}}
    <div class="controls-actions">
      <div class="navsearch-wrap">{{SEARCH_SVG}}<input id="search" class="navsearch" type="search" placeholder="Search reg, make or model" oninput="render()"></div>
      <button type="button" class="syncbtn" id="syncbtn" onclick="openRunModal()" title="Run now"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></button>
      <a id="navstar" class="settingslink" href="cockpit.html?view=shortlist" title="Shortlisted cars" aria-label="Shortlisted cars"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg><span id="starbadge" class="bellbadge"></span></a>
      <div class="bellwrap">
        <button id="bellbtn" class="bellbtn" onclick="toggleBell()" aria-label="Notifications" aria-haspopup="true" aria-expanded="false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span id="bellbadge"></span></button>
        <div id="bellpanel" class="bellpanel" hidden><p class="bellempty">Loading...</p></div>
      </div>
      {{SETTINGS_NAVLINK}}
      <div class="navuser" title="Mark, Right Drive">
        <div class="navuser-av">MN</div>
      </div>
    </div>
  </div>
  {{RUN_MODAL}}
  <div class="wrap">
    <div id="runbar" class="runbar" hidden><div class="runbar-row"><span id="runmsg" class="runmsg"></span><span class="runbar-right"><span id="runpct" class="runpct"></span><button type="button" id="stopbtn" class="stopbtn" onclick="stopRun()" title="Stop whatever is running now">Stop</button></span></div><div class="runtrack"><div id="runfill" class="runfill"></div></div></div>
    <div class="section-head">
      {{VIEWS_CHIP}}
      <div class="tabs platform-toggle" id="tabs">
        <button class="tab on" data-plat="all" onclick="setTab(this)" title="All" aria-label="All">All</button>
        <button class="tab" data-plat="Motorway" onclick="setTab(this)" title="Motorway" aria-label="Motorway"><img class="section-logo motorway" src="{{MW_LOGO}}" alt="Motorway"></button>
        <button class="tab" data-plat="Carwow" onclick="setTab(this)" title="Carwow" aria-label="Carwow"><img class="section-logo carwow" src="{{CW_LOGO}}" alt="Carwow"></button>
      </div>
      {{MORE_BTN}}
    </div>
    {{FILTER_CHIPS}}
    <div id="list" class="tablewrap"><p class="empty">Loading...</p></div>
  </div>
</div>
</div>
  {{MORE_DRAWER}}
  <div id="flash"></div>
  <script>{{GUARD_JS}}
    function pounds(n){
      if(n == null) return 'â€“';
      const pence = Math.round(Number(n) * 100) % 100 !== 0;
      return 'Â£' + Number(n).toLocaleString('en-GB',
        {minimumFractionDigits: pence ? 2 : 0, maximumFractionDigits: pence ? 2 : 0});
    }
    function poundsNeg(n){ return 'âˆ’' + pounds(n); }
    function ukDate(s){
      if(!s) return '';
      const dp = String(s).slice(0, 10).split('-');
      return dp.length === 3 ? dp[2] + '/' + dp[1] + '/' + dp[0] : String(s);
    }
    // A headline figure (Retail est.) with its own breakdown tucked behind
    // a small popover, shown on hover, or a tap on a touch screen with no
    // hover to trigger it (Mark 2026-08-25: "add the margin on the retail
    // est for margin... on hover modal"), same pattern as the purchases
    // page's own. rows is [label, value, cls?].
    function _infoCell(headline, rows){
      const body = rows.map(r => '<div class="info-row' + (r[2] ? ' ' + r[2] : '') +
        '"><span>' + r[0] + '</span><span>' + r[1] + '</span></div>').join('');
      return '<span class="info-trigger" onclick="toggleInfo(this, event)">' + headline +
        '<span class="info-pop">' + body + '</span></span>';
    }
    function toggleInfo(el, ev){
      ev.stopPropagation();
      const already = el.classList.contains('show');
      document.querySelectorAll('.info-trigger.show').forEach(t => t.classList.remove('show'));
      if(!already) el.classList.add('show');
    }
    document.addEventListener('click', () => {
      document.querySelectorAll('.info-trigger.show').forEach(t => t.classList.remove('show'));
    });
    let ALL = [];
    let currentTab = 'all';
    function setTab(btn){
      currentTab = btn.dataset.plat;
      // Scoped to #tabs specifically, not every .tab on the page: the page
      // switcher (Live Auctions/Purchases/Lost) reuses the same .tab class,
      // a bare '.tab' selector here would incorrectly clear its own "on"
      // state too (found live 2026-08-27).
      document.querySelectorAll('#tabs .tab').forEach(t => t.classList.toggle('on', t === btn));
      // Below 860px #tabs scrolls inside itself (2026-08-30 review), so the
      // one you just picked can end up off the strip, reloading with
      // DealerWay chosen for instance would leave it out of sight with no
      // clue you have to swipe to find it again.
      btn.scrollIntoView({inline:'nearest', block:'nearest'});
      render();
    }
    // Column sorting, the same 3 state convention as every other table in
    // the app (click ascending, again descending, a third time back to the
    // page's own default order), a row missing the value always last.
    let sortKey = null, sortDir = 1;
    function lostBy(r){
      return (r.sold_for != null && r.our_bid != null) ? r.sold_for - r.our_bid : null;
    }
    // Where this page's own numbers live, for the range sliders' bounds.
    function rangeValues(key){
      return ALL.map(r => key === 'lostby' ? lostBy(r) : r[key])
        .filter(v => v != null && v !== '')
        .map(Number).filter(n => !isNaN(n));
    }
    const SORT_KEYS = {
      reg: r => r.reg || null,
      name: r => r.name || null,
      platform: r => r.platform || null,
      sold_for: r => r.sold_for == null ? null : Number(r.sold_for),
      our_bid: r => r.our_bid == null ? null : Number(r.our_bid),
      lostby: r => lostBy(r),
      retail_estimate: r => r.retail_estimate == null ? null : Number(r.retail_estimate),
      date_bid: r => r.date_bid || null,
    };
    function compareRows(a, b){
      const key = SORT_KEYS[sortKey];
      if(!key) return 0;
      const av = key(a), bv = key(b);
      const aMissing = av == null || av === '';
      const bMissing = bv == null || bv === '';
      if(aMissing && bMissing) return 0;
      if(aMissing) return 1;
      if(bMissing) return -1;
      if(typeof av === 'number' && typeof bv === 'number') return (av - bv) * sortDir;
      return String(av).localeCompare(String(bv)) * sortDir;
    }
    function onSortHeaderClick(key){
      if(sortKey === key && sortDir === -1){ sortKey = null; sortDir = 1; }
      else if(sortKey === key){ sortDir = -1; }
      else { sortKey = key; sortDir = 1; }
      render();
    }
    function thLabel(key, label){
      const active = sortKey === key;
      const arrow = active ? '<span class="sortarrow">' + (sortDir === 1 ? 'â–¾' : 'â–´') + '</span>' : '';
      return '<th class="sortable' + (active ? ' sorted' : '') + '" data-col="' + key +
        '" onclick="onSortHeaderClick(&#39;' + key + '&#39;)">' + label + arrow + '</th>';
    }
{{FRAME_JS}}
    // This page's own frame config: one list drives the chips row, the More
    // badge and the drawer's footer count. Declared before the first load
    // call, never after (the TDZ trap this file has hit before).
    const FRAME = {
      title: 'Filters',
      filters: [
        { ids: ['bidfrom', 'bidto'],
          label(){
            const f = bbVal('bidfrom'), t = bbVal('bidto');
            if(!f && !t) return null;
            if(f && t) return 'Bid ' + ukDate(f) + ' to ' + ukDate(t);
            return f ? 'Bid from ' + ukDate(f) : 'Bid up to ' + ukDate(t);
          },
          clear(){ bbSetVal('bidfrom', ''); bbSetVal('bidto', ''); } },
        { ids: ['lostbymin', 'lostbymax'],
          label(){ return rangeLabel('Lost by', 'lostby'); },
          clear(){ bbSetVal('lostbymin', ''); bbSetVal('lostbymax', ''); } },
        { ids: ['soldformin', 'soldformax'],
          label(){ return rangeLabel('Sold for', 'soldfor'); },
          clear(){ bbSetVal('soldformin', ''); bbSetVal('soldformax', ''); } },
        { ids: ['search'], q: true,
          label(){
            const q = (bbVal('search') || '').trim();
            return q ? 'â€œ' + bbEsc(q) + 'â€�' : null;
          },
          clear(){ bbSetVal('search', ''); } },
        { ids: ['filterrecentonly'],
          label(){ return bbChecked('filterrecentonly') ? 'Last 30 days' : null; },
          clear(){ bbSetChecked('filterrecentonly', false); } },
      ],
      apply(){ render(); },
    };
    // One place for every view (Steven, 2026-08-30, the same model the
    // cockpit and purchases already moved to): which cars you are looking
    // at (the platform tab) is never part of a view, how you are looking
    // at them (every filter above plus the sort) is. Last 30 days and Lost
    // by under Â£250 were fixed slices before this, one view of the
    // list at a time; Last 30 days is now the ordinary rolling filter
    // above, and Lost by under Â£250 needed no new mechanism at all,
    // it is just the existing Lost by filter set to 250, saved as a view
    // under that name if you want one click back to it.
    const PIN_SVG = '{{PIN_SVG}}';
    const VIEWX_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="M6 6l12 12"></path></svg>';
    let PVIEWS = {{LVIEWS_JSON}};
    const PV_IDS = ['bidfrom', 'bidto', 'lostbymin', 'lostbymax',
      'soldformin', 'soldformax', 'search', 'filterrecentonly'];
    function pvVal(o, k){
      return (o && o[k] !== undefined && o[k] !== null) ? String(o[k]) : '';
    }
    function samePView(a, b){
      if(String(a && a.sortKey || '') !== String(b && b.sortKey || '')) return false;
      if(Number((a && a.sortDir) || 1) !== Number((b && b.sortDir) || 1)) return false;
      return PV_IDS.every(k => pvVal(a, k) === pvVal(b, k));
    }
    function currentCriteria(){
      // WRITE PATH feeder: a missing filter box means a broken page, so
      // refuse (null) rather than save a view with silently blank fields.
      if(PV_IDS.some(id => !bbEl(id))) return null;
      const c = {sortKey: sortKey, sortDir: sortDir};
      PV_IDS.forEach(id => { c[id] = domGet(id) || ''; });
      return c;
    }
    // A saved name wins over "All cars": someone who saved the default
    // state under their own name clicks it and must see that name back.
    function activePViewName(){
      const c = currentCriteria();
      if(!c) return 'All cars';
      const hit = PVIEWS.find(v => samePView(c, v.criteria || {}));
      if(hit) return hit.name;
      return samePView(c, {}) ? 'All cars' : 'Custom';
    }
    // How many rows you would see if you clicked this view, in the
    // platform you are actually looking at.
    function pViewCount(criteria){
      const base = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab);
      return filterRows(base, k => pvVal(criteria, k)).length;
    }
    function paintViewsList(shownNow){
      const name = activePViewName();
      bbSetText('viewchiplabel', name);
      const chip = bbEl('viewchip');
      if(chip) chip.classList.toggle('custom', name === 'Custom');
      bbSetText('viewchipn', shownNow === undefined
        ? pViewCount(currentCriteria() || {}) : shownNow);
      const list = bbEl('viewslist');
      if(list){
        const order = PVIEWS.map((v, i) => ({v: v, i: i}))
          .sort((a, b) => (b.v.starred ? 1 : 0) - (a.v.starred ? 1 : 0));
        list.innerHTML = order.length ? order.map(o => (
          '<div class="viewrow' + (name === o.v.name ? ' on' : '') + '" onclick="applySavedView(' + o.i + ')">' +
          '<button type="button" class="viewpin' + (o.v.starred ? ' on' : '') +
          '" onclick="event.stopPropagation();toggleViewStar(' + o.i + ')" title="' +
          (o.v.starred ? 'Unpin' : 'Pin to the top') + '">' + PIN_SVG + '</button>' +
          '<span class="viewname-btn">' + bbEsc(o.v.name) + '</span>' +
          '<span class="viewn">' + pViewCount(o.v.criteria || {}) + '</span>' +
          '<button type="button" class="viewdel" title="Delete this view" ' +
          'onclick="event.stopPropagation();deleteSavedView(' + o.i + ')">' + VIEWX_SVG + '</button></div>'
        )).join('') : '<p class="viewsempty">No saved views yet.</p>';
      }
      const allRow = bbEl('viewallrow');
      if(allRow){
        allRow.classList.toggle('on', name === 'All cars');
        bbSetText('viewalln', pViewCount({}));
      }
      const save = bbEl('viewsaverow');
      if(save) save.classList.toggle('hot', name === 'Custom');
      const nameBox = bbEl('viewname');
      if(nameBox) nameBox.placeholder = name === 'Custom' ? 'Name this view' : 'Name a view';
    }
    function toggleViewMenu(ev){
      if(ev) ev.stopPropagation();
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(!m) return;
      const open = m.hidden;
      m.hidden = !open;
      if(c) c.setAttribute('aria-expanded', open);
      if(open) paintViewsList();
    }
    function closeViewMenu(){
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(m) m.hidden = true;
      if(c) c.setAttribute('aria-expanded', false);
    }
    document.addEventListener('click', () => closeViewMenu());
    function toggleViewStar(i){
      const v = PVIEWS[i];
      if(!v) return;
      v.starred = !v.starred;
      savePViews(() => paintViewsList());
    }
    function applySavedView(i){
      const v = PVIEWS[i];
      if(!v) return;
      applyPCriteria(v.criteria || {});
    }
    // The views chip's "All cars" row calls this (the contract every page
    // carrying the chip has to meet, see _views_chip's own docstring): no
    // view applied, every filter cleared and the sort back to default.
    function resetFilters(){
      sortKey = null;
      sortDir = 1;
      clearAllFilters();
    }
    // Restores a view exactly, so the state it produces is the state that
    // was saved and the derived label can be trusted.
    function applyPCriteria(c){
      PV_IDS.forEach(id => {
        if(id === 'filterrecentonly') bbSetChecked('filterrecentonly', c[id] === '1');
        else bbSetVal(id, c[id] || '');
      });
      sortKey = c.sortKey || null;
      sortDir = c.sortDir === -1 ? -1 : 1;
      paintRanges();
      render();
    }
    function savePViews(after){
      fetch('/api/lost-bids-page-settings', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({lost_bids_views: PVIEWS})
      }).then(r => r.json()).then(d => {
        if(!d || !d.ok){ toast((d && d.error) || 'The view did not save.', false); return; }
        if(after) after();
      }).catch((err) => { console.warn('BidBrain: view save failed.', err); toast('The view did not save.', false); });
    }
    function saveCurrentView(){
      const inp = bbEl('viewname');
      if(!inp) return;
      const name = (inp.value || '').trim();
      if(!name){ inp.focus(); return; }
      const crit = currentCriteria();
      if(!crit){
        toast('This page is broken (a filter box is missing), the view was not saved. Refresh the page.', false);
        return;
      }
      const existing = PVIEWS.findIndex(v => v.name.toLowerCase() === name.toLowerCase());
      const entry = {name: name, criteria: crit,
                     starred: existing >= 0 ? !!PVIEWS[existing].starred : false};
      if(existing >= 0) PVIEWS[existing] = entry; else PVIEWS.push(entry);
      savePViews(() => { inp.value = ''; paintViewsList(); });
    }
    function deleteSavedView(i){
      const v = PVIEWS[i];
      if(!v) return;
      if(!confirm('Delete the view ' + v.name + '?')) return;
      PVIEWS.splice(i, 1);
      savePViews(() => paintViewsList());
    }
    function rangeLabel(label, prefix){
      const lo = bbVal(prefix + 'min'), hi = bbVal(prefix + 'max');
      if(!lo && !hi) return null;
      const f = v => pounds(Number(v));
      if(lo && hi) return label + ' ' + f(lo) + ' to ' + f(hi);
      return lo ? label + ' from ' + f(lo) : label + ' up to ' + f(hi);
    }
    function inRange(val, prefix){
      const lo = bbVal(prefix + 'min'), hi = bbVal(prefix + 'max');
      if(!lo && !hi) return true;
      // A row with no figure never matches an active range: missing data is
      // never treated as if it were zero.
      if(val == null) return false;
      if(lo && Number(val) < Number(lo)) return false;
      if(hi && Number(val) > Number(hi)) return false;
      return true;
    }
    // Every real narrowing this page does, in one place, so counting a
    // saved view and actually rendering the list can never disagree (the
    // exact bug class the cockpit's own search box hit, 2026-08-30: two
    // separate implementations of "what matches" drift apart the moment
    // one of them changes and the other does not). get(key) reads a value
    // from wherever it lives: bbVal for the live boxes, pvVal against a
    // saved view's own criteria for counting one.
    function filterRows(rows, get){
      const q = (get('search') || '').trim().toLowerCase();
      if(q){
        rows = rows.filter(r => ((r.reg || '') + ' ' + (r.name || '')).toLowerCase().includes(q));
      }
      const inR = (v, lo, hi) => {
        if(!lo && !hi) return true;
        if(v == null) return false;
        if(lo && Number(v) < Number(lo)) return false;
        if(hi && Number(v) > Number(hi)) return false;
        return true;
      };
      rows = rows.filter(r => inR(r.sold_for, get('soldformin'), get('soldformax'))
                            && inR(lostBy(r), get('lostbymin'), get('lostbymax')));
      const bfrom = get('bidfrom'), bto = get('bidto');
      if(bfrom || bto){
        rows = rows.filter(r => {
          const d = (r.date_bid || '').slice(0, 10);
          if(!d) return false;
          if(bfrom && d < bfrom) return false;
          if(bto && d > bto) return false;
          return true;
        });
      }
      // Last 30 days used to be a fixed slice; now it is this ordinary
      // filter, a ROLLING window computed fresh every call, from today,
      // never a fixed date baked into a saved view (2026-08-30).
      if(get('filterrecentonly') === '1'){
        const cut = new Date();
        cut.setDate(cut.getDate() - 30);
        const cutStr = cut.toISOString().slice(0, 10);
        rows = rows.filter(r => {
          const d = (r.date_bid || '').slice(0, 10);
          return !!d && d >= cutStr;
        });
      }
      return rows;
    }
    // The live DOM, with the one checkbox read correctly: bbVal alone would
    // read a checkbox's own value attribute ("on"), never its checked state.
    function domGet(k){
      return k === 'filterrecentonly' ? (bbChecked('filterrecentonly') ? '1' : '') : bbVal(k);
    }
    function render(){
      let rows = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab);
      rows = filterRows(rows, domGet);
      if(sortKey) rows = rows.slice().sort(compareRows);
      const list = bbEl('list');
      if(!list){
        console.error('BidBrain: #list is missing, the lost bids table cannot draw.');
        return;
      }
      if(!rows.length){
        // The empty state names what is actually narrowing the list, so it
        // is never a dead end wondering why nothing shows.
        const act = activeFrameFilters().map(x => x.label);
        let why = 'No lost bids recorded yet.';
        if(ALL.length){
          why = 'No lost bids match ' + (act.length
            ? 'these filters: ' + act.join(', ') + '.'
            : 'the current view.');
        }
        list.innerHTML = '<p class="empty">' + bbEsc(why) + '</p>';
        paintFrame(0);
        paintViewsList(0);
        return;
      }
      const body = rows.map(r => {
        let lostByCell = '<span class="num">â€“</span>';
        if(r.sold_for != null && r.our_bid != null){
          const gap = r.sold_for - r.our_bid;
          const lostByPct = r.our_bid ? (gap / r.our_bid * 100) : null;
          const cls = lostByPct != null && lostByPct <= 5 ? 'close' : 'wide';
          lostByCell = '<span class="num lostby ' + cls + '">' + pounds(gap) +
            (lostByPct != null ? ' <span class="num dim">(' + lostByPct.toFixed(1) + '%)</span>' : '') + '</span>';
        }
        // Cazana 115% and Margin tucked into a popover on Retail est.
        // (Mark 2026-08-25: "add the margin on the retail est for
        // margin... on hover modal"), same shape as the purchases page's
        // own Retail est. popover. Margin here is retail_estimate less
        // OUR OWN bid (never sold_for, someone else's winning price),
        // the same "what would this car have made against MY bid"
        // question the purchases page's own Margin already answers for
        // a car actually won, just asked here of the one that got away.
        let margin = null;
        if(r.our_bid != null && r.retail_estimate != null) margin = r.retail_estimate - r.our_bid;
        const retailCell = _infoCell(pounds(r.retail_estimate), [
          ['Cazana 115%', pounds(r.cazana_115)],
          ['Margin', pounds(margin), margin == null ? '' : (margin >= 0 ? 'good' : 'bad')]]);
        return '<tr>' +
          '<td><span class="reg">' + bbEsc(r.reg || 'no plate') + '</span></td>' +
          '<td>' + bbEsc(r.name || '') + '</td>' +
          '<td><span class="plat ' + bbEsc((r.platform || '').toLowerCase()) + '">' + bbEsc(r.platform || '') + '</span></td>' +
          '<td class="num total">' + pounds(r.sold_for) + '</td>' +
          '<td class="num dim">' + pounds(r.our_bid) + '</td>' +
          '<td>' + lostByCell + '</td>' +
          '<td class="num">' + retailCell + '</td>' +
          '<td>' + ukDate(r.date_bid) + '</td>' +
          '<td>' + (r.listing_url ? '<a href="' + bbEsc(r.listing_url) + '" target="_blank" rel="noopener">View</a>' : '') + '</td>' +
          '</tr>';
      }).join('');
      list.innerHTML = '<table><thead><tr>' +
        thLabel('reg', 'Reg') + thLabel('name', 'Vehicle') + thLabel('platform', 'Platform') +
        thLabel('sold_for', 'Sold for') + thLabel('our_bid', 'Our bid') +
        thLabel('lostby', 'Lost by') + thLabel('retail_estimate', 'Retail est.') +
        thLabel('date_bid', 'Date bid') + '<th></th>' +
        '</tr></thead><tbody>' + body + '</tbody></table>';
      paintFrame(rows.length);
      paintViewsList(rows.length);
    }
    async function loadLostBids(){
      // Two separated failure domains (2026-08-29 hardening), same shape
      // as the purchases page's own loadPurchases: a fetch or parse
      // failure keeps the friendly network message, a crash while DRAWING
      // says so with the stack in the console.
      let d;
      try {
        d = await (await fetch('/api/lost-bids')).json();
      } catch(err){
        console.warn('BidBrain: lost bids could not be fetched.', err);
        bbSetHTML('list', '<p class="empty">Could not load. Open this page at its web address, not from a file.</p>');
        return;
      }
      if(!d || !d.ok){ bbSetHTML('list', '<p class="empty">Could not load.</p>'); return; }
      ALL = d.lost_bids || [];
      resetRangeBounds();
      try {
        render();
      } catch(err){
        console.error('BidBrain: the lost bids list loaded but failed to draw.', err);
        bbSetHTML('list', '<p class="empty">The list loaded but could not be drawn. Refresh the page. If it happens again the page itself has a fault, check the browser console.</p>');
      }
    }
    loadLostBids();

    // Notification bell, same design and behaviour as the cockpit's own
    // (Mark 2026-08-25: "standardise the nav to icons for favorites,
    // purchases, notifications, lost bids and settings"), just fed by a
    // live fetch here rather than baked in at render time, since this page
    // has no run data of its own. A "Run Glass's checks"/"Run DealerKit
    // stock" notice has no Run modal to open on this page, so it deep
    // links back to the cockpit with ?openrun=1 instead (server side, see
    // bidbrain.render._bell_markup).

    // Sync now / Read full history (Mark 2026-08-24). A normal sync reads
    // the first couple of pages (a day's worth of new losses); the full
    // history read is a one off, deliberately slower (about 38 pages),
    // for building up the real dataset behind a future suggested bid
    // feature. Both now live in one modal (found in review, 2026-08-30:
    // Read full history was the one text button in an otherwise icon only
    // cluster, sitting right beside the everyday sync icon, no more
    // prominent visually than the routine action despite being the
    // slower, more disruptive one). Same shape purchases already uses.
    function onRunFinish(key, s){ if(key === 'lost_bids') loadLostBids(); }
  </script>
</body>
</html>
r»  r¼  r½  r¾  r¿  rÀ  rÁ  rÂ  rÄ  rÅ  rÆ  rÇ  rÉ  r  z{{LVIEWS_JSON}}Úlost_bids_viewsrÊ  rË  rÌ  rÏ  rÐ  ÚbidzDate bidÚfilterrecentonlyzBid datezOnly the last 30 daysz[A rolling window from today, recomputed every time this is applied, even from a saved view.rÚ  rä  ÚlostbyzLost byr÷  ÚsoldforzSold forÚsold_forr  r  Úlostbidsr  r  )rÝ   r  r¦  rÄ   r   r  rs   rv   ró   rô   r  rn   r}   r§   r  r  rÅ   rÆ   r‚   r„   r    r‰   Ú_filter_checkr˜   r  r  rØ   r  ©rG  r  r­  r®  r¯  r°  r±  r²  r³  r´  rq   s              r   Úrender_lost_bidsrX  ¤   sÄ  € õ #Ø×#Ñ#Ó%€LØ×Ñ 
Ó+×KÔ/?À
Ñ/K€HØ×"Ñ" =Ó1×TÔ5EÀmÑ5T€KØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ ×$Ñ$ _Ó5×ZÔ9IÈ/Ñ9Z€MØ"×&Ñ&Ð'8Ó9×A¸T€OØ#×'Ñ'Ð(:Ó;×C¸tÐØ ×$Ñ$ _Ó5×UÐ9I×UÌX€Mðm€Dô\ Ø�‰�^ XÓ.×6Ñ6Ð7HÈ+ÓVß‰WÐ% zÓ2·7±7Ð;KÈZÓ3Xß‰WÐ(¨-Ó8ß‰WÐ(¨-Ó8ß‰W�]¤GÓ,¯W©W°]ÄGÓ-Lß‰WÐ%¤{Ó3ß‰W�^¤Y£[Ó1ß‰W�_¤j£lÓ3ß‰W�^¤Y£[Ó1ß‰WÐ%¤{£}Ó5ß‰W�]¤HÓ-ß‰WÐ&¬¯
ª
°3×3CÑ3CÓ3E×3IÑ3IÐJ[Ó3\×3bÐ`bÓ(cÓdß‰W�^¤Y£[Ó1ß‰WÐ'Ô):Ó)<Ó=ß‰WÐ&¬°yÀ)Ü" 5¨*Ó5ô  Ð 2°JØ+ð:à+ñ	-ñ-ô $ H¨i¸HÑEñFô $ I¨z¸jÑIñJð7Kð 6Ló )Mó N÷" ‰WÐ+Ô->Ó-@ÓAß‰WÐ(¬.¸Ó*DÓEß‰W�_Ô&7Ó&9Ó:ß‰W�]¤H¨_Ó$=Ó>óG#@ð #@r&   c                  óª  • SSK Jn   U R                  5       nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d    S	nUR                  S
5      =(       d    S	nUR                  S5      =(       d    U=(       d    [
        n	Sn
[        U
R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU5      R                  SU	5      R                  S[        5      R                  S[        5      R                  S[        5      R                  S[        5      R                  S[        5       5      R                  S[        5       5      R                  S[        5       5      R                  S[        5       5      R                  S[!        5       5      R                  S[#        SS[%        SS 5      4/5      5      R                  S!['        5       5      R                  S"[)        S#5      5      R                  S$[+        5       5      R                  S%[-        U5      5      5      $ )&aï  Cars won or offered on but not yet a confirmed, accepted purchase
(Mark 2026-08-27: "add a under offer section to the main nav for cars
that are won but bids not accepts this is for all auctions"). Real
live reconnaissance found this is genuinely NOT anything in the
purchases table: each platform's own "Bids & offers > Under offer"
style screen shows a bid Mark has placed that is currently accepted
as leading, or where the reserve has not been met, before it becomes
a confirmed sale at all (Mark, live: "on MW got to bids and offers
then under offer, we have 2 cars there currently", proven exactly
against Motorway's own real /account/bids/under-offer screen).
Motorway's own reader is proven live both directions; Carwow's own
equivalent screen is real and confirmed (a genuine "Under offer 0"
count on this account at build time) but its per card field parsing
has never been proven against a real populated example, see
carwow.read_under_offer's own docstring. Auction4Cars has a real
equivalent (My Account > Awaiting Payment) but a substantially
different page layout with no populated example to build a reader
against yet, a real, honest gap, not read here.

A snapshot, not an accumulating history (db.record_under_offer
replaces the whole set per platform on every read), fetched live from
/api/under-offer on load same as every other admin page here, never
baked stale into a server render. Theme colours read fresh from
settings on every request, same live pattern as purchases.html and
lost-bids.html.r   r  r­  r®  r¯  r°  r±  r²  Nr³  r´  uMK  <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, under offer</title>
<link rel="icon" href="{{THEME_FAVICON}}">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root { --ink:oklch(0.279 0.0299 260.05); --soft:oklch(0.446 0.0313 257.3); --line:oklch(0.929 0.0095 255.53);
    --bg:{{THEME_BG}}; --no:oklch(0.444 0.177 26.9); --good:oklch(0.448 0.119 151.33);
    --motorway:#0a5fb4; --carwow:#7b3ff2;
    --price:{{THEME_PRICE}}; --pill:{{THEME_PILL}}; --icon:{{THEME_ICON}};
    --sidebar-bg:{{THEME_SIDEBAR}}; --radius-xl:14px; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif; }
  .appshell { display:flex; align-items:flex-start; gap:0; min-height:100vh; background:var(--sidebar-bg); padding:8px; }
  .insetpanel { flex:1; min-width:0; background:var(--bg); border-radius:var(--radius-xl);
    box-shadow:0 1px 3px rgba(20,30,40,.08), 0 1px 2px rgba(20,30,40,.04); }
  @media (max-width:860px) {
    /* align-items:stretch: the column flip otherwise inherits the row
       layout's flex-start, the panel shrinks to content width and the
       whole PAGE scrolls sideways (2026-08-29 hardening). */
    .appshell { flex-direction:column; padding:0; align-items:stretch; }
    .insetpanel { border-radius:0; }
  }
  .controls { position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px rgba(20,30,40,.05); border-radius:var(--radius-xl) var(--radius-xl) 0 0; }
  .controls .brand { font-size:18px; font-weight:800; letter-spacing:-0.02em; color:var(--ink);
    margin-right:2px; white-space:nowrap; flex:none; text-decoration:none; cursor:pointer; }
  .controls .brand span { color:var(--icon); }
  .tabs { display:inline-flex; background:var(--sidebar-bg); border-radius:10px; padding:3px; gap:2px; flex:none; }
  .tab { border:none; background:none; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer;
    padding:7px 14px; border-radius:8px; white-space:nowrap; }
  .tab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px rgba(20,30,40,.15); }
  .pageswitcher .tab { display:flex; align-items:center; gap:6px; text-decoration:none; }
  .pageswitcher .tab svg { width:15px; height:15px; flex:none; }
  /* The dealership logo at the left of the nav bar, where the left rail
     used to carry it (2026-08-31). */
  .navlogo { flex:none; display:flex; align-items:center; height:34px; margin-right:4px; }
  .navlogo img { height:26px; width:auto; max-width:150px; object-fit:contain; display:block; }
  .controls-actions { display:flex; align-items:center; gap:4px; margin-left:auto; }
  .navsearch-wrap { position:relative; display:inline-flex; align-items:center; }
  .navsearch-wrap svg { position:absolute; left:11px; color:var(--soft); pointer-events:none; }
  /* -webkit-appearance:none strips Safari's own native search-field chrome
     on type="search", which reserves internal space for its own icon
     invisible to the CSS box model (padding/width look fine, the text
     still clips) â€” this box already draws its own magnifying glass
     (.navsearch-wrap svg), so the native one was never wanted anyway. */
  .navsearch { height:34px; padding:0 12px 0 32px; border:1px solid var(--line); border-radius:9px;
    font:inherit; font-size:13px; background:var(--sidebar-bg); color:var(--ink); width:230px; max-width:36vw;
    -webkit-appearance:none; appearance:none; }
  .navsearch::placeholder { color:var(--soft); }
  .syncbtn { display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    background:none; color:var(--soft); border:none; border-radius:9px; cursor:pointer; padding:0; }
  .syncbtn:hover { background:var(--bg); color:var(--ink); }
  .syncbtn:disabled { color:var(--line); cursor:default; }
  .syncbtn:disabled:hover { background:none; }
  @keyframes navspin { to { transform:rotate(360deg); } }
  .settingslink { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px; border:none; background:none; cursor:pointer; padding:0; font:inherit;
    border:none; border-radius:9px; color:var(--soft); text-decoration:none; margin-left:4px; background:none; cursor:pointer; }
  .settingslink:hover { background:var(--bg); color:var(--ink); }
  .settingslink.on { color:#d39e00; }
  .settingslink.on svg { fill:currentColor; }
  .bellwrap { position:relative; margin-left:4px; }
  .navuser { display:flex; align-items:center; margin-left:4px; }
  .navuser-av { width:32px; height:32px; border-radius:50%; background:var(--line); flex:none;
    display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:var(--ink); cursor:default; }
  .bellbtn { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .bellbtn:hover { background:var(--bg); color:var(--ink); }
  .bellbadge:empty { display:none; }
  .bellbadge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 3px;
    border-radius:8px; background:var(--no); color:#fff; font-size:10px; font-weight:800;
    box-sizing:border-box; display:flex; align-items:center; justify-content:center; border:2px solid #fff; }
  .bellpanel { position:absolute; top:calc(100% + 8px); right:0; width:320px; max-height:360px; overflow-y:auto;
    background:#fff; border:1px solid var(--line); border-radius:12px; padding:8px; box-shadow:0 10px 28px rgba(20,30,40,.16);
    z-index:10; }
  .bellpanel[hidden] { display:none; }
  .bellnote { display:block; margin:0; padding:9px 10px; border-radius:8px; font-size:13px; line-height:1.4;
    color:var(--soft); text-decoration:none; }
  .bellnote + .bellnote, .bellnote + a.bellnote, a.bellnote + .bellnote { margin-top:2px; }
  .bellnote.alert { background:#fdf3e0; color:#7a5a00; font-weight:600; }
  .bellnote-link { cursor:pointer; }
  .bellnote-link:hover { filter:brightness(.96); text-decoration:underline; }
  .bellempty { margin:0; padding:14px 10px; font-size:13px; color:var(--soft); text-align:center; }
  #starbadge:empty { display:none; }
  .wrap { max-width:3560px; margin:0 auto; padding:20px; }
  .section-head { display:flex; align-items:center; gap:14px;
    flex-wrap:wrap; margin-bottom:16px; }
  .section { font-size:14px; text-transform:uppercase; letter-spacing:1px; color:var(--soft);
    margin:0; display:flex; align-items:center; gap:10px; flex:none; }
  .section .setcount { background:var(--bg); color:var(--ink); border-radius:999px; padding:1px 9px;
    font-size:12px; font-weight:700; vertical-align:middle; }
  .section-logo { height:20px; width:auto; display:block; }
  .platform-toggle .tab { display:flex; align-items:center; justify-content:center; min-height:26px; }
  .platform-toggle .tab .section-logo { height:18px; }
  .platform-toggle-count { background:var(--bg); color:var(--ink); border-radius:999px; padding:3px 11px;
    font-size:13px; font-weight:700; }
  .tablewrap { overflow-x:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
  table { width:100%; border-collapse:collapse; }
  th, td { text-align:left; padding:10px 12px; font-size:13.5px; border-bottom:1px solid var(--line); white-space:nowrap; }
  th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--soft); background:#f7f8f6; }
  th.sortable { cursor:pointer; user-select:none; }
  th.sortable:hover { color:var(--icon); }
  th.sortable.sorted { color:var(--icon); }
  th .sortarrow { display:inline-block; margin-left:3px; }
  tr:last-child td { border-bottom:none; }
  .reg { font-weight:700; background:#f4d000; padding:2px 8px; border-radius:5px; font-size:12px; white-space:nowrap; }
  .plat { font-size:11px; font-weight:700; color:#fff; padding:2px 8px; border-radius:999px; white-space:nowrap; }
  .plat.motorway { background:var(--motorway); } .plat.carwow { background:var(--carwow); }
  .num { font-variant-numeric:tabular-nums; }
  .empty { color:var(--soft); padding:20px; text-align:center; }
  .vlink { display:inline-flex; margin-left:6px; color:var(--soft); vertical-align:middle; }
  .vlink:hover { color:var(--accent, var(--icon)); }
{{FRAME_CSS}}
  @media (max-width: 760px) {
    .controls { padding:10px 14px; }
    .controls-actions { margin-left:0; width:100%; justify-content:space-between; }
    .navsearch-wrap { flex:1 1 auto; min-width:0; }
    .navsearch { width:100%; }
    .wrap { padding:12px; }
    th, td { padding:8px 9px; font-size:13px; }
  }
</style>
</head>
<body>
<div class="appshell" id="appshell">
<div class="insetpanel">
  <div class="controls">
    {{NAVLOGO}}
    {{PAGE_SWITCHER}}
    <div class="controls-actions">
      <div class="navsearch-wrap">{{SEARCH_SVG}}<input id="search" class="navsearch" type="search" placeholder="Search reg, make or model" oninput="render()"></div>
      <button type="button" class="syncbtn" id="syncbtn" onclick="openRunModal()" title="Run now"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></button>
      <a id="navstar" class="settingslink" href="cockpit.html?view=shortlist" title="Shortlisted cars" aria-label="Shortlisted cars"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg><span id="starbadge" class="bellbadge"></span></a>
      <div class="bellwrap">
        <button id="bellbtn" class="bellbtn" onclick="toggleBell()" aria-label="Notifications" aria-haspopup="true" aria-expanded="false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span id="bellbadge"></span></button>
        <div id="bellpanel" class="bellpanel" hidden><p class="bellempty">Loading...</p></div>
      </div>
      {{SETTINGS_NAVLINK}}
      <div class="navuser" title="Mark, Right Drive">
        <div class="navuser-av">MN</div>
      </div>
    </div>
  </div>
  {{RUN_MODAL}}
  <div class="wrap">
    <div id="runbar" class="runbar" hidden><div class="runbar-row"><span id="runmsg" class="runmsg"></span><span class="runbar-right"><span id="runpct" class="runpct"></span><button type="button" id="stopbtn" class="stopbtn" onclick="stopRun()" title="Stop whatever is running now">Stop</button></span></div><div class="runtrack"><div id="runfill" class="runfill"></div></div></div>
    <div class="section-head">
      <div class="tabs platform-toggle" id="tabs">
        <button class="tab on" data-plat="all" onclick="setTab(this)" title="All" aria-label="All">All</button>
        <button class="tab" data-plat="Motorway" onclick="setTab(this)" title="Motorway" aria-label="Motorway"><img class="section-logo motorway" src="{{MW_LOGO}}" alt="Motorway"></button>
        <button class="tab" data-plat="Carwow" onclick="setTab(this)" title="Carwow" aria-label="Carwow"><img class="section-logo carwow" src="{{CW_LOGO}}" alt="Carwow"></button>
      </div>
      <span class="setcount platform-toggle-count" id="platcount">0</span>
      {{MORE_BTN}}
    </div>
    {{FILTER_CHIPS}}
    <div id="list" class="tablewrap"><p class="empty">Loading...</p></div>
  </div>
</div>
</div>
  {{MORE_DRAWER}}
  <div id="flash"></div>
  <script>{{GUARD_JS}}
    let ALL = [];
    let currentTab = 'all';
    function updatePlatCount(count){
      bbSetText('platcount', count);
    }
    function setTab(btn){
      currentTab = btn.dataset.plat;
      document.querySelectorAll('#tabs .tab').forEach(t => t.classList.toggle('on', t === btn));
      // Below 860px #tabs scrolls inside itself (2026-08-30 review), so the
      // one you just picked can end up off the strip on reload.
      btn.scrollIntoView({inline:'nearest', block:'nearest'});
      render();
    }
    function pounds(n){
      if(n == null) return 'â€“';
      const pence = Math.round(Number(n) * 100) % 100 !== 0;
      return 'Â£' + Number(n).toLocaleString('en-GB',
        {minimumFractionDigits: pence ? 2 : 0, maximumFractionDigits: pence ? 2 : 0});
    }
    function ukDate(s){
      if(!s) return '';
      const dp = String(s).slice(0, 10).split('-');
      return dp.length === 3 ? dp[2] + '/' + dp[1] + '/' + dp[0] : String(s);
    }
    // Column sorting, the same 3 state convention as the purchases page
    // (click for ascending, again for descending, a third time back to
    // the default order), with a row missing the value always sorting to
    // the very end whichever direction.
    let sortKey = null, sortDir = 1;
    const SORT_KEYS = {
      reg: r => r.reg || null,
      name: r => r.name || null,
      platform: r => r.platform || null,
      max_bid: r => r.max_bid == null ? null : Number(r.max_bid),
      noted_at: r => r.noted_at || null,
    };
    function compareRows(a, b){
      const key = SORT_KEYS[sortKey];
      if(!key) return 0;
      const av = key(a), bv = key(b);
      const aMissing = av == null || av === '';
      const bMissing = bv == null || bv === '';
      if(aMissing && bMissing) return 0;
      if(aMissing) return 1;
      if(bMissing) return -1;
      if(typeof av === 'number' && typeof bv === 'number') return (av - bv) * sortDir;
      return String(av).localeCompare(String(bv)) * sortDir;
    }
    function onSortHeaderClick(key){
      if(sortKey === key && sortDir === -1){ sortKey = null; sortDir = 1; }
      else if(sortKey === key){ sortDir = -1; }
      else { sortKey = key; sortDir = 1; }
      render();
    }
    function thLabel(key, label){
      const active = sortKey === key;
      const arrow = active ? '<span class="sortarrow">' + (sortDir === 1 ? 'â–¾' : 'â–´') + '</span>' : '';
      return '<th class="sortable' + (active ? ' sorted' : '') + '" data-col="' + key +
        '" onclick="onSortHeaderClick(&#39;' + key + '&#39;)">' + label + arrow + '</th>';
    }
{{FRAME_JS}}
    // This page's own frame config: one list drives the chips row, the More
    // badges and the drawer's footer count, so they can never disagree.
    // Declared before the first load call, never after (the TDZ trap this
    // file has hit before).
    const FRAME = {
      title: 'Filters',
      filters: [
        { ids: ['notedfrom', 'notedto'],
          label(){
            const f = bbVal('notedfrom'), t = bbVal('notedto');
            if(!f && !t) return null;
            if(f && t) return 'Noted ' + ukDate(f) + ' to ' + ukDate(t);
            return f ? 'Noted from ' + ukDate(f) : 'Noted up to ' + ukDate(t);
          },
          clear(){ bbSetVal('notedfrom', ''); bbSetVal('notedto', ''); } },
        { ids: ['search'], q: true,
          label(){
            const q = (bbVal('search') || '').trim();
            return q ? 'â€œ' + bbEsc(q) + 'â€�' : null;
          },
          clear(){ bbSetVal('search', ''); } },
      ],
      apply(){ render(); },
    };
    function render(){
      let rows = currentTab === 'all' ? ALL : ALL.filter(r => r.platform === currentTab);
      const q = (bbVal('search') || '').trim().toLowerCase();
      if(q){
        rows = rows.filter(r => ((r.reg || '') + ' ' + (r.name || '')).toLowerCase().includes(q));
      }
      const nfrom = bbVal('notedfrom'), nto = bbVal('notedto');
      if(nfrom || nto){
        rows = rows.filter(r => {
          const d = (r.noted_at || '').slice(0, 10);
          if(!d) return false;
          if(nfrom && d < nfrom) return false;
          if(nto && d > nto) return false;
          return true;
        });
      }
      if(sortKey) rows = rows.slice().sort(compareRows);
      updatePlatCount(rows.length);
      const list = bbEl('list');
      if(!list){
        console.error('BidBrain: #list is missing, the under offer table cannot draw.');
        return;
      }
      if(!rows.length){
        list.innerHTML = '<p class="empty">' + (ALL.length ? 'Nothing under offer matches the current filters.' : 'Nothing currently under offer.') + '</p>';
        paintFrame(0);
        return;
      }
      const body = rows.map(r => {
        const plat = '<span class="plat ' + bbEsc((r.platform || '').toLowerCase()) + '">' + bbEsc(r.platform || '') + '</span>';
        const view = r.listing_url ? '<a class="vlink" href="' + bbEsc(r.listing_url) + '" target="_blank" rel="noopener" title="View the original listing" aria-label="View the original listing">{{EXT_LINK_SVG}}</a>' : '';
        return '<tr>' +
          '<td><span class="reg">' + bbEsc(r.reg || 'no plate') + '</span></td>' +
          '<td>' + bbEsc(r.name || '') + view + '</td>' +
          '<td>' + plat + '</td>' +
          '<td class="num">' + pounds(r.max_bid) + '</td>' +
          '<td>' + (r.noted_at ? ukDate(r.noted_at) : 'â€“') + '</td>' +
          '</tr>';
      }).join('');
      list.innerHTML = '<table><thead><tr>' +
        thLabel('reg', 'Reg') + thLabel('name', 'Vehicle') + thLabel('platform', 'Platform') +
        thLabel('max_bid', 'Max bid') + thLabel('noted_at', 'Noted') +
        '</tr></thead><tbody>' + body + '</tbody></table>';
      paintFrame(rows.length);
    }
    async function loadUnderOffer(){
      // Two separated failure domains (2026-08-29 hardening), same shape
      // as loadPurchases and loadLostBids.
      let d;
      try {
        d = await (await fetch('/api/under-offer')).json();
      } catch(err){
        console.warn('BidBrain: under offer could not be fetched.', err);
        bbSetHTML('list', '<p class="empty">Could not load. Open this page at its web address, not from a file.</p>');
        return;
      }
      if(!d || !d.ok){ bbSetHTML('list', '<p class="empty">Could not load.</p>'); return; }
      ALL = d.under_offer || [];
      try {
        render();
      } catch(err){
        console.error('BidBrain: the under offer list loaded but failed to draw.', err);
        bbSetHTML('list', '<p class="empty">The list loaded but could not be drawn. Refresh the page. If it happens again the page itself has a fault, check the browser console.</p>');
      }
    }
    loadUnderOffer();


    // Refresh (Mark 2026-08-27): reads every supported platform's own real
    // Under offer screen and replaces the stored snapshot. Asks first: this
    // button doubles as the sync light, so a click must never launch a
    // live read by surprise (a red state click goes to the run board).
    function onRunFinish(key, s){ if(key === 'under_offer') loadUnderOffer(); }
  </script>
</body>
</html>
r»  r¼  r½  r¾  r¿  rÀ  rÁ  rÂ  rÃ  rÄ  rÅ  rÆ  rÇ  rÊ  rË  rÌ  rÏ  rÐ  ÚnotedÚNotedr  r  Ú
underofferr  r  )rÝ   r  r¦  rÄ   r   r  rs   rv   ró   rô   r;  r  rn   r}   r§   r‚   r„   r    r‰   r  r  rØ   r  rW  s              r   Úrender_under_offerr]  Ï#  s7  € õ4 #Ø×#Ñ#Ó%€LØ×Ñ 
Ó+×KÔ/?À
Ñ/K€HØ×"Ñ" =Ó1×TÔ5EÀmÑ5T€KØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ ×$Ñ$ _Ó5×ZÔ9IÈ/Ñ9Z€MØ"×&Ñ&Ð'8Ó9×A¸T€OØ#×'Ñ'Ð(:Ó;×C¸tÐØ ×$Ñ$ _Ó5×UÐ9I×UÌX€MðI€DôT
 Ø�‰�^ XÓ.×6Ñ6Ð7HÈ+ÓVß‰WÐ% zÓ2·7±7Ð;KÈZÓ3Xß‰WÐ(¨-Ó8ß‰WÐ(¨-Ó8ß‰W�]¤GÓ,¯W©W°]ÄGÓ-Lß‰WÐ'Ô);Ó<ß‰WÐ%¤{Ó3ß‰W�^¤Y£[Ó1ß‰W�_¤j£lÓ3ß‰W�^¤Y£[Ó1ß‰W�^¤Y£[Ó1ß‰WÐ'Ô):Ó)<Ó=ß‰WÐ&¬Ø˜YÔ(:¸7ÀGÓ(LÐMÐNó)Pó Qç‰WÐ+Ô->Ó-@ÓAß‰WÐ(¬.¸Ó*FÓGß‰W�_Ô&7Ó&9Ó:ß‰W�]¤H¨_Ó$=Ó>ó%@ð @r&   c                  ó@  ^^^^• 0 m[          H'  n TR                  U S   / 5      R                  U 5        M)     [         Vs0 s H  o[	        U5      _M     snmSSKJn  UR                  5       R                  S5      =(       d    /  Vs/ s H  nU[        ;   d  M  UPM     snmU4S jn[        [        US9 Vs/ s H  o3T;  d  M
  UPM     nnUU4S jmSR                  U4S	 jU 5       5      nT(       a'  SR                  U4S
 j[         5       5      nUSU-   -  nU Vs/ s H  nTU   S   S:X  d  M  UPM     nnU(       d  Sn	O]Sn
[        U5      S:X  a  S[        US       S3nO)SR                  S U 5       5      n[        U5       SU S3nSU
 S[        U5       S3n	SSKJn  UR                  5       nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d	    [        S   nUR                  S5      =(       d    SnUR                  S 5      =(       d    SnUR                  S!5      =(       d    U=(       d    [        nS"n[!        UR#                  S#U	5      R#                  S$U5      R#                  S%U5      R#                  S&U5      R#                  S'U5      R#                  S(U5      R#                  S)U5      R#                  S*[$        5      R#                  S+[        5      R#                  S,['        5       [(        -   [+        5       -   [,        -   5      R#                  S-[.        [0        -   [2        -   5      R#                  S.[5        5       5      R#                  S/U5      R#                  S0[7        5       5      R#                  S1[9        U5      5      5      $ s  snf s  snf s  snf s  snf )2uû  The buying rule settings page: the hard gate thresholds, the list
shaped bans (banned makes, banned models, vans, value caps, small city
cars), and the two pricing figures (retail uplift %, flat spread Â£),
editable, plus a read only list of the banned engine rules for
visibility. The governing threshold and the engine rules themselves are
not editable here, they stay code only (see CLAUDE.md). Fetches the
current values live on load, same pattern as render_hidden, must be
opened at the served address.rÎ   r   r  rÊ   c                 ó`   >• TU    nUS   S:X  a  gUS   S:X  a  U [         ;   a  gUS   S:X  a  gg)Nr5  r:  r   r;  r   r-   é   )r@  )rÎ   ÚstÚ_statess     €r   Ú_tierÚrender_settings.<locals>._tierv%  sC   ø€ Ø�T‰]ˆØˆe‰9˜ÓØØˆe‰9˜Ó 4¬:Ó#5ØØˆe‰9˜ÓØØr&   r%  c                 óV  >• TU    n[         R                  U 5      nU [        ;   a  SnOU(       a  SU S3OSn[        R                  U S5      nU(       a  S[	        U5       S3OSnT
R                  U / 5      nSR                  S U 5       5      nU(       a  S	U S
3nU S:X  a  SOSU  S3nU [        ;   a  SO!SU  SU  S[	        [        U    5       SU  SU  S3n	SUS    SU  SUS    S[	        [        U    5       SU SU S[	        US   5       S[	        US   5       SU S U	 S!U S"3$ )#Nz:<span class="conn-badge auto">Auto, after every run</span>z$<span class="conn-badge auto">Auto, z daily</span>z3<span class="conn-badge manual">Manual login</span>r   z¤<button type="button" class="conn-info" onclick="toggleConnNote(this)" title="What is this?" aria-label="What is this?">&#9432;</button><p class="conn-note" hidden>rf  c           
   3   óp   #   • U  H,  nS US    S[        US   5       S[        US   5       S3v •  M.     g7f)z\<div class="conn-sub">
              <label class="switch"><input type="checkbox" id="integ-rS   z™"
                onchange="saveIntegrations()"><span></span></label>
              <div class="conn-sub-text">
                <p class="conn-sub-name">r$   z.</p>
                <p class="conn-sub-note">rO  z,</p>
              </div>
            </div>NrC  )r   Úis     r   r   Ú5render_settings.<locals>._conn_row.<locals>.<genexpr>�%  sY   é € ð 

ò �ðFØFGÈÁhÀZð P*ô +/¨q°©zÓ*:Ð);ð <*Ü*.¨q°©y«/Ð):ð ;õò ùs   ‚46z<div class="conn-subs">rš   r  zF<button type="button" class="conn-hide-link" onclick="hideConnection('z.', true)">Hide, we don&#39;t use this</button>z5<button type="button" class="relogin-btn" data-site="z'"
              onclick="startRelogin('z')">Log in to z\</button>
            <button type="button" class="relogin-btn relogin-done-btn" data-site="z/" hidden
              onclick="finishRelogin('z'')">I&#39;m logged in, save it</button>z<div class="conn-row conn-r5  z" data-site="z#">
          <span class="conn-dot zc"></span>
          <div class="conn-main">
            <div class="conn-title">
              <h3>z</h3>
              z
              z=
            </div>
            <p class="conn-state" title="rœ   rò   r6  z</p>
            zC
          </div>
          <div class="conn-actions">
            r7  z 
          </div>
        </div>)r@  rÄ   rB  Ú_SITE_ABOUTrl   r   Ú_SITE_LABELS)rÎ   ra  rF  ÚbadgeÚaboutr‡  ÚsubsÚ	subs_htmlÚ	hide_linkÚ
login_btnsÚ_by_siterb  s             €€r   Ú	_conn_rowÚ"render_settings.<locals>._conn_row‚%  sÎ  ø€ Ø�T‰]ˆÜ—N‘N 4Ó(ˆ	Ø”?Ó"ØP‰EæXaÐ;¸I¸;ÀmÑTØPð ä—‘  bÓ)ˆö FKð/ä/3°E«{¨m¸4ñAàPRð 	ð �|‰|˜D "Ó%ˆØ—G‘Gñ 

ñ ó

ó 

ˆ	ö Ø1°)°¸FÐCˆIð   8Ó+‘Rð2Ø26°Ð7fðhð 	ð
  ¤?Ó2‘RÐ<qÐrvÐqwð x&Ø&* V¨>¼$¼|ÈDÑ?QÓ:RÐ9Sð TSØSWÐRXð Y'Ø'+ fÐ,Sð9Wˆ
ð .¨b°©i¨[¸ÀdÀVð L!Ø!# E¡ ð ,ô œ TÑ*Ó+Ð,ð -Øˆgð Øˆfð *ä*.¨r°'©{Ó*;Ð)<¸B¼tÀBÀyÁMÓ?RÐ>Sð TØˆKð ð ˆLð ØˆKð ðð 	r&   r   c              3   ó4   >#   • U  H  nT" U5      v •  M     g 7fr   r   )r   rÎ   rr  s     €r   r   Ú"render_settings.<locals>.<genexpr>»%  s   øé € ÐIº.°$™i¨Ÿo˜oº.ùrk  c              3   ój   >#   • U  H(  oT;   d  M
  S U S[        [        U   5       SU S3v •  M*     g7f)z0<div class="conn-row conn-hiddenrow" data-site="z‡">
              <span class="conn-dot grey"></span>
              <div class="conn-main">
                <div class="conn-title"><h3>a  </h3></div>
                <p class="conn-state">Hidden, and switched off, until shown again.</p>
              </div>
              <div class="conn-actions">
                <button type="button" class="relogin-btn"
                  onclick="hideConnection('zF', false)">Show again</button>
              </div>
            </div>N)rl   rj  )r   rÎ   Ú_hidden_connss     €r   r   ru  Â%  sW   øé € ð 
ò $�¨}Ñ'<ó
Ð@ÀÀð G-ô .2´,¸tÑ2DÓ-EÐ,Fð G,ð
 -1¨6ð 2õ
ò $ùs   ƒ	3�#3zC<p class="conn-hidden-head">Hidden, not used by this dealership</p>r5  r:  as  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>r   z#1 connection needs your attention. z	 is down.r(  c              3   ó4   #   • U  H  n[         U   v •  M     g 7fr   )rj  )r   rî   s     r   r   ru  ß%  s   é € Ð@º°1œ|¨Ažºùs   ‚z" connections need your attention. r…  z<div class="conn-banner">r#  r  r­  r®  r¯  r°  r±  r²  Nr³  r´  u)' <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain, settings</title>
<link rel="icon" href="{{THEME_FAVICON}}">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root { --ink:oklch(0.279 0.0299 260.05); --soft:oklch(0.446 0.0313 257.3); --line:oklch(0.929 0.0095 255.53);
    --bg:{{THEME_BG}}; --no:oklch(0.444 0.177 26.9); --good:oklch(0.448 0.119 151.33);
    --motorway:#0a5fb4;
    --price:{{THEME_PRICE}}; --pill:{{THEME_PILL}}; --icon:{{THEME_ICON}};
    --sidebar-bg:{{THEME_SIDEBAR}}; --radius-xl:14px; }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif; }
  .appshell { display:flex; align-items:flex-start; gap:0; min-height:100vh; background:var(--sidebar-bg); padding:8px; }
  /* No overflow:hidden here (Mark 2026-08-25: "make the top nav sticky so
     the listing go under as you scroll") â€” it was clipping the .controls
     bar's own position:sticky, an ancestor with overflow hidden breaks
     sticky positioning entirely since it stops being "the page scrolls"
     and becomes "this box scrolls", which .insetpanel itself never
     actually does (the whole page does). .controls gets the top corners
     directly instead so the rounded panel still looks seamless where it
     is actually ever seen; the bottom corners are moot in practice, the
     panel is always taller than the viewport until the very end of a
     long list, and only a sharp edge on the last row could ever poke
     past them there. */
  .insetpanel { flex:1; min-width:0; background:var(--bg); border-radius:var(--radius-xl);
    box-shadow:0 1px 3px rgba(20,30,40,.08), 0 1px 2px rgba(20,30,40,.04); }
  @media (max-width:860px) {
    /* align-items:stretch: the column flip otherwise inherits the row
       layout's flex-start, the panel shrinks to content width and the
       whole PAGE scrolls sideways (2026-08-29 hardening). */
    .appshell { flex-direction:column; padding:0; align-items:stretch; }
    .insetpanel { border-radius:0; }
    /* Seven tabs in one unwrappable row were 866px on their own, the one
       thing still forcing this page to scroll sideways at 800px. */
    #top-tabs { flex-wrap:wrap; max-width:100%; flex-shrink:1; }
  }
  /* Same sticky control bar as the cockpit, purchases and lost bids pages
     (Mark 2026-08-25: "on the setting page and the purchases and lost bids
     use the same nav bar as the home page, so you can get to each page
     with having to back to the main page"), replacing the old dark topbar
     this page used to be the only one still carrying. */
  .controls { position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px rgba(20,30,40,.05); border-radius:var(--radius-xl) var(--radius-xl) 0 0; }
  .controls .brand { font-size:18px; font-weight:800; letter-spacing:-0.02em; color:var(--ink);
    margin-right:2px; white-space:nowrap; flex:none; text-decoration:none; cursor:pointer; }
  .controls .brand span { color:var(--icon); }
  .controls h1 { margin:0; font-size:15px; font-weight:700; color:var(--soft); flex:none; }
  /* The dealership logo at the left of the nav bar, where the left rail
     used to carry it (2026-08-31). */
  .navlogo { flex:none; display:flex; align-items:center; height:34px; margin-right:4px; }
  .navlogo img { height:26px; width:auto; max-width:150px; object-fit:contain; display:block; }
  .controls-actions { display:flex; align-items:center; gap:10px; margin-left:auto; }
  /* Icon only, same nav convention as the cockpit's own bag/target/wrench
     buttons (Mark 2026-08-25: "use icons to navigate to other pages like
     home page"), so all four pages read as the same product. */
  .settingslink { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px; border:none; background:none; cursor:pointer; padding:0; font:inherit;
    border:none; border-radius:9px; color:var(--soft); text-decoration:none; margin-left:4px; background:none; cursor:pointer; }
  .settingslink:hover { background:var(--bg); color:var(--ink); }
  .settingslink.on { color:#d39e00; }
  .settingslink.on svg { fill:currentColor; }
  /* Notification bell, same design as the cockpit's own (Mark 2026-08-25:
     "standardise the nav to icons for favorites, purchases, notifications,
     lost bids and settings ... same order on all pages with same look and
     design"); this page has no run data of its own to bake notices into at
     render time, so the panel starts empty and fills in from GET
     /api/notices on load, the same live-fetch pattern this page's own
     settings already use. */
  .bellwrap { position:relative; margin-left:4px; }
  /* Signed in user, moved here from the left panel's own footer (Mark
     2026-08-25: "move the user icon top right on the nav"), since the
     panel can now collapse to a rail too narrow for a name. Just the
     round avatar now (Mark 2026-08-25 again: "all just icons no
     outlines"), the name is still on the title attribute as a tooltip. */
  .navuser { display:flex; align-items:center; margin-left:4px; }
  .navuser-av { width:32px; height:32px; border-radius:50%; background:var(--line); flex:none;
    display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:var(--ink); cursor:default; }
  .bellbtn { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .bellbtn:hover { background:var(--bg); color:var(--ink); }
  .bellbadge:empty { display:none; }
  /* box-sizing:border-box shrinks the badge's own content area to 12px
     (16px minus the 2px top+bottom border), but a plain line-height:16px
     still laid the digit out at the full 16px, so it overflowed past the
     visible circle, reading as cut off at the bottom (Mark 2026-08-26:
     "fix the ios red flags they seen to cut off at the bottom"). Flex
     centring is immune to that box-sizing/line-height mismatch, same
     mechanism every other icon button here already centres its own SVG
     with. */
  .bellbadge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 3px;
    border-radius:8px; background:var(--no); color:#fff; font-size:10px; font-weight:800;
    box-sizing:border-box; display:flex; align-items:center; justify-content:center; border:2px solid #fff; }
  .bellpanel { position:absolute; top:calc(100% + 8px); right:0; width:320px; max-height:360px; overflow-y:auto;
    background:#fff; border:1px solid var(--line); border-radius:12px; padding:8px; box-shadow:0 10px 28px rgba(20,30,40,.16);
    z-index:10; }
  .bellpanel[hidden] { display:none; }
  .bellnote { display:block; margin:0; padding:9px 10px; border-radius:8px; font-size:13px; line-height:1.4;
    color:var(--soft); text-decoration:none; }
  .bellnote + .bellnote, .bellnote + a.bellnote, a.bellnote + .bellnote { margin-top:2px; }
  .bellnote.alert { background:#fdf3e0; color:#7a5a00; font-weight:600; }
  .bellnote-link { cursor:pointer; }
  .bellnote-link:hover { filter:brightness(.96); text-decoration:underline; }
  .bellempty { margin:0; padding:14px 10px; font-size:13px; color:var(--soft); text-align:center; }
  /* Red when there is something to actually see, not just the small badge
     count (Mark 2026-08-25: "make the bell go red when notifications").
     The star icon stays the same look as every other nav icon (Mark:
     "make sure the watched / shortlisted icons are always the same"),
     just gaining the same small corner badge with a real count ("add the
     amount watched like the amount of notifications"), both filled in by
     loadNotices() once GET /api/notices resolves. */
  #starbadge:empty { display:none; }
  .wrap { max-width:760px; margin:0 auto; padding:20px 20px 60px; }
  .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px 18px; margin-bottom:16px; }
  .card h2 { margin:0 0 4px; font-size:16px; }
  .card .note { color:var(--soft); font-size:12.5px; margin:0 0 12px; }
  .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
  label { display:block; font-size:12.5px; color:var(--soft); margin-bottom:4px; }
  input[type=number], input[type=text] { width:100%; padding:8px 9px; border:1px solid var(--line); border-radius:8px; font-size:14px; }
  input[type=color] { width:100%; height:36px; padding:3px; border:1px solid var(--line); border-radius:8px; cursor:pointer; background:#fff; }
  .logo-preview-row { display:flex; align-items:center; gap:10px; }
  .logo-preview { max-width:120px; max-height:40px; object-fit:contain; border:1px solid var(--line); border-radius:8px; padding:5px; background:#fff; flex:none; }
  .logo-preview.small { max-width:40px; }
  input[type=file] { font-size:12.5px; color:var(--soft); }
  .field { margin-bottom:10px; }
  .grades { display:flex; gap:14px; margin-top:4px; }
  .grades label { display:flex; align-items:center; gap:5px; color:var(--ink); font-size:14px; }
  .chips { display:flex; flex-wrap:wrap; gap:7px; margin:8px 0 10px; }
  .chip { background:var(--bg); border:1px solid var(--line); border-radius:20px; padding:5px 8px 5px 12px;
    font-size:13px; display:flex; align-items:center; gap:6px; }
  .chip button { border:none; background:none; color:var(--soft); cursor:pointer; font-size:14px; line-height:1; padding:0; }
  .addrow { display:flex; gap:8px; }
  .addrow input { flex:1; }
  /* The purchases tab's dropdown editor. The pill palette matches the
     purchases page's own .opill colours exactly, one visual language. */
  .pd-card { border-top:1px solid var(--line); padding-top:14px; margin-top:14px; }
  .pd-card h3 { margin:0 0 4px; font-size:15px; }
  .pd-rows { display:flex; flex-direction:column; gap:6px; margin:10px 0; }
  .pd-row { display:flex; align-items:center; gap:10px; padding:5px 8px; border:1px solid var(--line);
    border-radius:9px; background:#fff; }
  .pd-move { display:flex; gap:2px; }
  .pd-move button { border:none; background:none; cursor:pointer; color:#9aa39b; font-size:13px;
    padding:1px 4px; border-radius:5px; }
  .pd-move button:hover { background:#f0f2ee; color:var(--ink); }
  .pd-row select { font:inherit; font-size:12px; padding:3px 5px; border:1px solid var(--line);
    border-radius:7px; background:#fff; }
  .pd-flags { display:flex; gap:12px; margin-left:auto; }
  .pd-flags label { display:flex; align-items:center; gap:4px; font-size:12px; color:#6b7280; cursor:pointer; }
  .pd-remove { border:none; background:none; cursor:pointer; color:#9aa39b; font-size:16px;
    font-weight:700; padding:0 5px; border-radius:5px; }
  .pd-remove:hover { color:#b42318; background:#fdf0ef; }
  .pd-flowlab { display:flex; align-items:center; gap:8px; font-size:14px; color:var(--ink);
    margin:8px 0 2px; cursor:pointer; }
  .opill { display:inline-block; font-size:11px; font-weight:700; padding:2px 9px; border-radius:999px;
    white-space:nowrap; }
  .oc-grey{background:#e8eae6;color:#4b5563}   .oc-blue{background:#dbeafe;color:#1e40af}
  .oc-navy{background:#2d3748;color:#fff}      .oc-green{background:#d8f3dc;color:#166534}
  .oc-teal{background:#ccf2ec;color:#0f6d5f}   .oc-teal2{background:#c9eef7;color:#0e5c73}
  .oc-sky{background:#d7e9fb;color:#1d4e89}    .oc-yellow{background:#fde68a;color:#78350f}
  .oc-purple{background:#e9d5ff;color:#5b21b6} .oc-pink{background:#fbd0e0;color:#9d174d}
  .oc-red{background:#fde0dd;color:#9b1c1c}    .oc-orange{background:#fed7aa;color:#9a3412}
  .oc-amber{background:#fdf0d5;color:#92400e}
  .addrow button, .rowadd { background:var(--motorway); color:#fff; border:none; border-radius:8px; padding:8px 13px; font-weight:700; cursor:pointer; white-space:nowrap; }
  table.edit { width:100%; border-collapse:collapse; margin-bottom:10px; }
  table.edit th { text-align:left; font-size:12px; color:var(--soft); font-weight:600; padding:4px 6px; }
  table.edit td { padding:4px 6px; }
  table.edit input { padding:6px 8px; font-size:13px; }
  table.edit .rm { border:none; background:none; color:var(--no); cursor:pointer; font-size:15px; }
  .readonly ul { margin:0; padding-left:18px; font-size:13px; color:var(--soft); columns:2; }
  /* Same pill tab style, in the same spot, as the cockpit and every other
     page's own nav bar (Mark 2026-08-25: "change the nav to match the home
     page" then "no the nav should go on the nav bar like the other pages
     and use the same toggle design"), sitting directly in .controls next
     to the brand rather than stacked in its own row above the cards, so
     .tabs/.tab here are the identical flex:none rule those pages use, no
     bottom margin needed now it is a flex child of .controls itself. Still
     its own page specific tab mechanism underneath (switchView, #top-tabs),
     just placed and styled like the same product. Overview stands in for
     the cockpit's own All, Integrations is new on the end, order otherwise
     matches the cockpit's own platform order. */
  .tabs { display:inline-flex; background:var(--sidebar-bg); border-radius:10px; padding:3px; gap:2px; flex:none; }
  .tab { border:none; background:none; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer;
    padding:7px 14px; border-radius:8px; white-space:nowrap; }
  .tab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px rgba(20,30,40,.15); }
  /* The Live Auctions/Purchases/Lost page switcher (Mark 2026-08-27: "copy
     the toggle for the auction house and make toggle for Live Autions,
     Purchases, Lost"), same .tabs/.tab shape, real links not JS state. */
  .pageswitcher .tab { display:flex; align-items:center; gap:6px; text-decoration:none; }
  .pageswitcher .tab svg { width:15px; height:15px; flex:none; }
  /* Save settings/integrations, moved into the top nav bar (Mark
     2026-08-25: "move the save setting to the top nav bar like sync and
     run"), same compact button shape as purchases/lost bids' own Sync now
     and the cockpit's own Run now, replacing the old full width sticky
     bottom bar. */
  .save { background:var(--icon); color:#fff; border:none; border-radius:9px; padding:9px 16px;
    font-weight:700; font-size:13px; cursor:pointer; white-space:nowrap; }
  .save:hover { filter:brightness(.95); }
  /* Software update's own status line reuses this, unchanged in shape
     since the redesign below (Steven 2026-08-26 review) only touched the
     Integrations tab, not this one. */
  .integ-status { font-size:12px; color:var(--soft); font-style:italic; margin:0; }
  .relogin-btn { margin-top:6px; background:var(--bg); border:1px solid var(--line); border-radius:8px;
    padding:6px 11px; font-size:12.5px; font-weight:600; color:var(--ink); cursor:pointer; white-space:nowrap; }
  .relogin-btn:hover { filter:brightness(.96); }
  .relogin-btn:disabled { color:var(--soft); cursor:default; opacity:.6; }
  .relogin-done-btn { background:var(--icon); border-color:var(--icon); color:#fff; margin-left:6px; }

  /* Integrations tab redesign, 2026-08-26, from Steven's own review ("is
     this the best most functional most easy to use way to display and
     manage the integrations"): one flat list instead of three cards
     grouped by how the code happened to grow, a single merged state per
     row instead of two separate signals that could contradict each other
     (see bidbrain.render._site_state), broken rows floated to the top
     under a real banner, an Auto/Manual badge showing which logins fix
     themselves, prose collapsed behind an info button, and instant apply
     switches for each automation's own on/off state (no separate Save
     button on this tab any more). */
  .conn-banner { display:flex; align-items:center; gap:9px; background:#fdecee; border:1px solid #f5c2c9;
    color:#8f1d2b; border-radius:12px; padding:10px 14px; margin-bottom:14px; font-weight:600; font-size:13px; }
  /* Same specificity as the browser's own [hidden]{display:none} rule, and
     ours loads after it, so without this the waiting strip stayed visible
     as an empty pill even with the hidden attribute set (caught live
     2026-08-26 on the very first real load of this redesign). */
  .conn-banner[hidden] { display:none; }
  .conn-banner svg { flex:none; }
  .conn-banner.wait { background:#eef3fd; border-color:#c9d8f5; color:#1d3a75; font-weight:500; }
  .conn-banner .spin { width:13px; height:13px; border:2px solid #c9d8f5; border-top-color:#1d3a75;
    border-radius:50%; animation:connspin 1s linear infinite; flex:none; }
  @keyframes connspin { to { transform:rotate(360deg); } }
  /* ---- the sync light ----
     The SAME shared SYNC_LIGHT_CSS constant _frame_css() splices into the
     other four pages, spliced here too (this page shares neither the
     frame CSS nor the frame JS, so it takes the constant directly â€” one
     source, five pages, no copy to drift). This page had no run or sync
     button at all before 2026-08-31, so it gains one (deep linking to the
     Run modal on the cockpit, the way the bell's own notes already do) and
     the navspin keyframe every other page already had. */
  @keyframes navspin { to { transform:rotate(360deg); } }
{SYNC_LIGHT_CSS}
  .conn-row { display:flex; align-items:flex-start; gap:12px; padding:14px 0; border-top:1px solid var(--line); }
  .conn-row:first-child { border-top:none; padding-top:2px; }
  .conn-dot { width:9px; height:9px; border-radius:50%; margin-top:6px; flex:none; background:var(--soft); }
  .conn-dot.good { background:var(--good); }
  .conn-dot.bad { background:var(--no); box-shadow:0 0 0 3px #fdecee; }
  .conn-dot.grey { background:var(--soft); opacity:.5; }
  .conn-main { flex:1 1 auto; min-width:0; }
  .conn-title { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
  .conn-title h3 { margin:0; font-size:14.5px; }
  .conn-badge { font-size:10px; font-weight:700; letter-spacing:.3px; text-transform:uppercase;
    padding:2px 7px; border-radius:999px; white-space:nowrap; }
  .conn-badge.auto { background:#e7f6e9; color:#15701f; }
  .conn-badge.manual { background:var(--bg); color:var(--soft); }
  .conn-info { background:none; border:none; padding:0 2px; cursor:pointer; color:var(--soft); line-height:1; font-size:14px; }
  .conn-info:hover { color:var(--ink); }
  .conn-note { margin:6px 0 0; font-size:12px; color:var(--soft); background:var(--bg); border-radius:8px;
    padding:7px 9px; max-width:520px; }
  .conn-state { font-size:12.5px; color:var(--soft); margin:3px 0 0; }
  .conn-row.conn-bad .conn-state { color:#a5253a; font-weight:600; }
  .conn-subs { margin-top:9px; display:flex; flex-direction:column; gap:8px; }
  .conn-sub { display:flex; align-items:flex-start; gap:9px; }
  .conn-sub-text { flex:1 1 auto; }
  .conn-sub-name { margin:0; font-size:12.5px; font-weight:600; }
  .conn-sub-note { margin:1px 0 0; font-size:11.5px; color:var(--soft); }
  .switch { position:relative; width:32px; height:19px; flex:none; margin-top:1px; }
  .switch input { opacity:0; width:0; height:0; position:absolute; }
  .switch span { position:absolute; inset:0; border-radius:999px; background:#d3d7dd; transition:.15s; cursor:pointer; }
  .switch span::after { content:""; position:absolute; width:15px; height:15px; border-radius:50%;
    background:#fff; top:2px; left:2px; transition:.15s; box-shadow:0 1px 2px rgba(0,0,0,.25); }
  .switch input:checked + span { background:var(--good); }
  .switch input:checked + span::after { left:15px; }
  .conn-actions { flex:none; display:flex; flex-direction:column; align-items:flex-end; gap:6px; }
  .conn-actions .relogin-btn { margin-top:0; }
  .conn-actions .relogin-done-btn { margin-left:0; }
  .conn-hide-link { background:none; border:none; padding:0; font-size:11px; color:var(--soft);
    text-decoration:underline; cursor:pointer; font-family:inherit; }
  .conn-hide-link:hover { color:var(--ink); }
  .conn-hidden-head { margin:18px 0 0; padding-top:12px; border-top:1px solid var(--line);
    font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--soft); }
  .conn-hiddenrow { opacity:.55; }
  .conn-hiddenrow:hover { opacity:1; }
</style>
</head>
<body>
<div class="appshell" id="appshell">
<div class="insetpanel">
  <div class="controls">
    {{NAVLOGO}}
    <!-- Four tabs, not eight (Steven 2026-09-02, Step 4 of the review):
         the five auction houses became a picker INSIDE Buying rules, see
         #rules-tabs in the template. data-view values are unchanged so
         every ?tab= deep link (the bell's settings.html?tab=integrations,
         a platform name) still lands where it always did. -->
    <div class="tabs" id="top-tabs">
      <button type="button" class="tab on" data-view="overview">Buying rules</button>
      <button type="button" class="tab" data-view="purchases">Purchases</button>
      <button type="button" class="tab" data-view="integrations">Connections</button>
      <button type="button" class="tab" data-view="theme">Look</button>
    </div>
    <div class="controls-actions">
      <button type="button" class="save" id="savebtn">Save settings</button>
      <button type="button" id="slbtn" class="settingslink" onclick="openRunModal()" title="Run now" aria-label="Run now"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></button>
      <a id="navstar" class="settingslink" href="cockpit.html?view=shortlist" title="Shortlisted cars" aria-label="Shortlisted cars"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg><span id="starbadge" class="bellbadge"></span></a>
      <div class="bellwrap">
        <button id="bellbtn" class="bellbtn" onclick="toggleBell()" aria-label="Notifications" aria-haspopup="true" aria-expanded="false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span id="bellbadge"></span></button>
        <div id="bellpanel" class="bellpanel" hidden><p class="bellempty">Loading...</p></div>
      </div>
      {{SETTINGS_NAVLINK}}
      <div class="navuser" title="Mark, Right Drive">
        <div class="navuser-av">MN</div>
      </div>
    </div>
  </div>
  {{RUN_MODAL}}
  <div id="runbar" class="runbar" hidden><div class="runbar-row"><span id="runmsg" class="runmsg"></span><span class="runbar-right"><span id="runpct" class="runpct"></span><button type="button" id="stopbtn" class="stopbtn" onclick="stopRun()" title="Stop whatever is running now">Stop</button></span></div><div class="runtrack"><div id="runfill" class="runfill"></div></div></div>
  <div class="wrap" id="wrap"><p>Loading...</p></div>
  <div id="flash"></div>
  <script>{{GUARD_JS}}

    // Right Drive's own real logo, the same asset the left panel itself
    // falls back to when no custom upload is saved, so a cleared upload's
    // preview shows the real default rather than a blank image.
    const DEFAULT_LOGO_FULL = "{{DEFAULT_LOGO_FULL}}";
    const DEFAULT_LOGO_SMALL = "{{DEFAULT_LOGO_SMALL}}";

    function chipList(id, values){
      const box = document.getElementById(id+'-chips');
      box.innerHTML = '';
      values.forEach((v,i) => {
        const c = document.createElement('span'); c.className='chip';
        c.innerHTML = '<span></span><button type="button" aria-label="Remove">&times;</button>';
        c.querySelector('span').textContent = v;
        c.querySelector('button').onclick = () => { values.splice(i,1); chipList(id, values); };
        box.appendChild(c);
      });
    }
    function wireAdd(id, values, lower){
      if(lower === undefined) lower = true;
      const input = document.getElementById(id+'-add');
      const add = () => { const v = input.value.trim(); if(!v) return;
        values.push(lower ? v.toLowerCase() : v); input.value=''; chipList(id, values); };
      document.getElementById(id+'-addbtn').onclick = add;
      input.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); add(); } });
    }

    let S = null; // the live editable settings object

    function renderModels(){
      const tb = document.querySelector('#models-table tbody'); tb.innerHTML = '';
      S.banned_models.forEach((row, i) => {
        const tr = document.createElement('tr');
        tr.innerHTML = '<td><input value="'+row[0]+'"></td><td><input value="'+row[1]+'"></td>'+
          '<td><button type="button" class="rm">&times;</button></td>';
        const ins = tr.querySelectorAll('input');
        ins[0].oninput = e => row[0] = e.target.value.trim().toLowerCase();
        ins[1].oninput = e => row[1] = e.target.value.trim().toLowerCase();
        tr.querySelector('.rm').onclick = () => { S.banned_models.splice(i,1); renderModels(); };
        tb.appendChild(tr);
      });
    }
    function renderCaps(){
      const tb = document.querySelector('#caps-table tbody'); tb.innerHTML = '';
      S.value_caps.forEach((row, i) => {
        const tr = document.createElement('tr');
        tr.innerHTML = '<td><input value="'+(row.make||'')+'"></td>'+
          '<td><input value="'+(row.model||'')+'" placeholder="any"></td>'+
          '<td><input type="number" value="'+(row.cap||'')+'"></td>'+
          '<td><input value="'+((row.except||[]).join(', '))+'" placeholder="e.g. countryman, clubman"></td>'+
          '<td><button type="button" class="rm">&times;</button></td>';
        const ins = tr.querySelectorAll('input');
        ins[0].oninput = e => row.make = e.target.value.trim().toLowerCase();
        ins[1].oninput = e => row.model = e.target.value.trim().toLowerCase() || null;
        ins[2].oninput = e => row.cap = parseFloat(e.target.value) || 0;
        ins[3].oninput = e => row.except = e.target.value.split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);
        tr.querySelector('.rm').onclick = () => { S.value_caps.splice(i,1); renderCaps(); };
        tb.appendChild(tr);
      });
    }

    // Which platform's hard gate is currently shown on the toggle. The three
    // platforms share the same set of input elements, switching just repaints
    // them from S.platforms[currentPlatform], syncing whatever was typed back
    // into the outgoing platform first so nothing typed is lost mid edit.
    let currentPlatform = 'motorway';

    // Auction4Cars and Dealer Auction both stand in for a real distance
    // check with a location allow list instead of a mile limit (depot name
    // for one, county for the other), but only Auction4Cars has no real
    // condition grade, Dealer Auction's is genuine and stays editable.
    function usesLocationList(plat){ return plat === 'auction4cars' || plat === 'dealerauction'; }
    const LOCATION_COPY = {
      auction4cars: {label: 'Allowed depots', placeholder: 'Add a depot, for example Newport',
        note: 'Auction4Cars only ever gives a depot name, never exact miles, so this is its distance check: only cars at a depot listed here show. Leave empty to not filter by depot at all (todayâ€™s default).'},
      dealerauction: {label: 'Allowed counties', placeholder: 'Add a county, for example Tyne and Wear',
        note: 'Dealer Auction only ever gives a town plus county, never exact miles, so this is its distance check: only cars in a county listed here show. Leave empty to not filter by county at all (todayâ€™s default).'},
    };

    function paintGate(){
      const p = S.platforms[currentPlatform];
      document.getElementById('platform-enabled-toggle').checked = S.platform_enabled[currentPlatform] !== false;
      document.getElementById('max_mileage').value = p.max_mileage;
      document.getElementById('max_reserve').value = p.max_reserve;
      document.getElementById('min_age').value = p.min_age;
      document.getElementById('max_age').value = p.max_age;
      document.getElementById('max_owners').value = p.max_owners;
      const locList = usesLocationList(currentPlatform);
      const noGrade = currentPlatform === 'auction4cars' || currentPlatform === 'dealerway';
      document.getElementById('distance-field').hidden = locList;
      document.getElementById('grades-field').hidden = noGrade;
      document.getElementById('locations-field').hidden = !locList;
      if(locList){
        const copy = LOCATION_COPY[currentPlatform];
        document.querySelector('#locations-field label').textContent = copy.label;
        document.querySelector('#locations-field .note').textContent = copy.note;
        document.getElementById('locations-add').placeholder = copy.placeholder;
        chipList('locations', p.allowed_locations || (p.allowed_locations = []));
      } else {
        document.getElementById('max_distance_miles').value = p.max_distance_miles;
      }
      if(!noGrade){
        [1,2,3,4,5].forEach(g => document.getElementById('grade'+g).checked = (p.allowed_grades||[]).includes(g));
      }
    }

    function syncGate(){
      const p = S.platforms[currentPlatform];
      S.platform_enabled[currentPlatform] = document.getElementById('platform-enabled-toggle').checked;
      p.max_mileage = parseFloat(document.getElementById('max_mileage').value);
      p.max_reserve = parseFloat(document.getElementById('max_reserve').value);
      p.min_age = parseFloat(document.getElementById('min_age').value);
      p.max_age = parseFloat(document.getElementById('max_age').value);
      p.max_owners = parseFloat(document.getElementById('max_owners').value);
      if(!usesLocationList(currentPlatform)){
        p.max_distance_miles = parseFloat(document.getElementById('max_distance_miles').value);
      }
      if(currentPlatform === 'auction4cars' || currentPlatform === 'dealerway'){
        p.allowed_grades = p.allowed_grades || [1,2,3];  // hidden on this tab, never edited here
      } else {
        p.allowed_grades = [1,2,3,4,5].filter(g => document.getElementById('grade'+g).checked);
      }
    }

    // Which top level tab is showing: Overview (settings shared across every
    // auction house), one platform's own page (currently just its Hard
    // gate, built as its own panel so more platform specific settings have
    // somewhere to go later, Mark 2026-08-21), or Integrations (every back
    // office automation's own on/off switch, Mark 2026-08-24). Switching
    // away from a platform tab always syncs its gate fields first so
    // nothing typed is lost, the same guard switchPlatform used to
    // provide on its own; Overview and Integrations have no such sync
    // since their own Save buttons read straight from the DOM.
    const PLATFORM_VIEWS = ['motorway', 'carwow', 'auction4cars', 'dealerauction', 'dealerway'];
    let currentView = 'overview';
    function switchView(view){
      if(view === currentView) return;
      if(PLATFORM_VIEWS.includes(currentView)) syncGate();
      currentView = view;
      // Keep the URL's own ?tab= in step with the tab actually showing, so
      // every action that reloads this page (hiding a connection, "I'm
      // logged in, save it", the software update) comes back to the SAME
      // tab instead of dumping back on Overview (Steven 2026-08-26:
      // "every time i change a setting inside settings it changes the
      // page and sends me to overview"). The load() deep link reader
      // already restores from ?tab=, this just writes it as tabs switch
      // rather than only ever reading it. replaceState, not pushState, so
      // the browser's Back button still leaves the page rather than
      // stepping back through every tab ever clicked.
      try {
        history.replaceState(null, '', view === 'overview'
          ? location.pathname : location.pathname + '?tab=' + view);
      } catch(e){}
      // The top bar shows the four groups; Buying rules is "overview"
      // plus every platform, whose own picker sits inside the panel.
      const isPlatform = PLATFORM_VIEWS.includes(view);
      const top = isPlatform ? 'overview' : view;
      document.querySelectorAll('#top-tabs .tab').forEach(b => b.classList.toggle('on', b.dataset.view === top));
      document.querySelectorAll('#rules-tabs .tab').forEach(b => b.classList.toggle('on', b.dataset.view === view));
      // display, not the hidden attribute: .tabs is display:inline-flex,
      // which outranks [hidden] (the same trap the Integrations waiting
      // strip hit, 2026-08-26), caught live on the Look tab.
      const rt = document.getElementById('rules-tabs');
      if(rt) rt.style.display = top !== 'overview' ? 'none' : '';
      document.getElementById('overview-panel').hidden = view !== 'overview';
      document.getElementById('theme-panel').hidden = view !== 'theme';
      document.getElementById('purchases-panel').hidden = view !== 'purchases';
      document.getElementById('platform-panel').hidden = !isPlatform;
      document.getElementById('integrations-panel').hidden = view !== 'integrations';
      // Integrations applies instantly, and the Purchases tab has its own
      // Save purchase settings button posting to its own endpoint, so the
      // main Save (the full buying rules payload) hides on both.
      document.getElementById('savebtn').hidden = view === 'integrations' || view === 'purchases';
      if(isPlatform){
        currentPlatform = view;
        const names = {motorway:'Motorway', carwow:'Carwow', auction4cars:'Auction4Cars', dealerauction:'Dealer Auction', dealerway:'DealerWay'};
        document.getElementById('platform-note').textContent =
          'Every car from ' + names[view] + ' must pass all of these or it is rejected outright.';
        document.getElementById('platform-enabled-label').textContent =
          'Include ' + names[view] + ' in daily and manual runs';
        paintGate();
      }
    }

    function paint(){
      document.getElementById('dealer_postcode').value = S.dealer_postcode || '';
      document.getElementById('default_price_ceiling').value = S.default_price_ceiling || '';
      document.getElementById('retail_price_cap').value = S.retail_price_cap || '';
      document.getElementById('retail_estimate_margin').value =
        S.retail_estimate_margin != null ? S.retail_estimate_margin : 500;
      document.getElementById('retail_estimate_ending').value = S.retail_estimate_ending || 95;
      document.getElementById('details_style').value = S.details_style || 'drawer';
      document.getElementById('theme_bg').value = S.theme_bg || '#f9fafb';
      document.getElementById('theme_price').value = S.theme_price || '#ff306a';
      document.getElementById('theme_pill').value = S.theme_pill || '#ff306a';
      document.getElementById('theme_icon').value = S.theme_icon || '#ff306a';
      document.getElementById('theme_sidebar').value = S.theme_sidebar || '#E9EDEB';
      document.getElementById('logo_full_preview').src = S.theme_logo_full || DEFAULT_LOGO_FULL;
      document.getElementById('logo_small_preview').src = S.theme_logo_small || DEFAULT_LOGO_SMALL;
      document.getElementById('favicon_preview').src = S.theme_favicon || DEFAULT_LOGO_SMALL;
      document.getElementById('retail_uplift').value = S.retail_uplift;
      document.getElementById('flat_spread').value = S.flat_spread;
      document.getElementById('small_car_cap_limit').value = S.small_car_cap_limit;
      paintGate();
      chipList('makes', S.banned_makes);
      chipList('vans', S.van_models);
      chipList('small', S.small_car_tokens);
      renderModels();
      renderCaps();
      paintPurchasesTab();
    }

    // ------------------------------------------------------------------
    // Purchases tab (Steven 2026-08-27, "build settings inside of
    // settings to allow new answers to be added and old ones removed"):
    // each dropdown column on the purchases working list, editable per
    // dealership. Saved through its own /api/purchase-page-settings, the
    // server refuses removing an answer still on real cars.
    // ------------------------------------------------------------------
    const PD_COLS = [
      ['stock_status', 'Status', 'The working list state itself. At least one option must be marked finished, that is what moves a car off the working list into the archive.'],
      ['video_status', 'Video', 'The video walkaround steps, in order. The LAST option in this list is what closes the Video check stage on the car journey.'],
      ['person_collecting', 'Person collecting', 'Who goes to get the car. Yours to name, each dealership keeps its own list.'],
      ['on_finance', 'On finance', 'Whether the car is on stock funding.'],
      ['location', 'Location', 'Where the car currently sits, if this dealership tracks it (the column can be switched off in the Fields menu on the purchases page).'],
      ['supplier', 'Supplier', 'Who a hand added car was bought from, a local trader for instance. A platform car never carries one, the platform is the supplier. New names can also be added straight from the Add car screen, they land here.'],
      ['logbook_state', 'Logbook', 'Whether the V5 is physically here yet.'],
      ['cancelled_reason', 'Cancellation reasons', 'What the Cancel this sale dialog offers. The free text reason typed alongside is always kept too.'],
    ];
    const PD_COLOURS = ['grey', 'blue', 'navy', 'green', 'teal', 'teal2', 'sky',
                        'yellow', 'purple', 'pink', 'red', 'orange', 'amber'];
    let PD = {};
    function paintPurchasesTab(){
      PD = JSON.parse(JSON.stringify(S.purchase_dropdowns || {}));
      const flow = S.purchase_flow || {};
      document.getElementById('flow-video').checked = !!flow.video_stage;
      const rebands = flow.fee_rebands || {};
      document.getElementById('flow-reband-Motorway').checked = !!rebands.Motorway;
      document.getElementById('flow-reband-Carwow').checked = !!rebands.Carwow;
      pdPaintAll();
    }
    function pdPaintAll(){
      const host = document.getElementById('pd-cards');
      host.innerHTML = PD_COLS.map(c => {
        const col = c[0];
        const rows = (PD[col] || []).map((o, i) => pdRowHtml(col, o, i)).join('');
        return '<div class="pd-card"><h3>' + c[1] + '</h3><p class="note">' + c[2] + '</p>' +
          '<div class="pd-rows">' + rows + '</div>' +
          '<div class="addrow"><input type="text" id="pd-add-' + col + '" placeholder="Add an answer">' +
          '<button type="button" onclick="pdAdd(&#39;' + col + '&#39;)">Add</button></div></div>';
      }).join('');
    }
    function pdRowHtml(col, o, i){
      const flags = [];
      flags.push('<label title="Pre selected on a brand new car"><input type="checkbox"' +
        (o.default ? ' checked' : '') + ' onchange="pdFlag(&#39;' + col + '&#39;,' + i + ',&#39;default&#39;,this.checked)">default</label>');
      if(col === 'stock_status'){
        flags.push('<label title="Means the car is finished, off the working list"><input type="checkbox"' +
          (o.finished ? ' checked' : '') + ' onchange="pdFlag(&#39;' + col + '&#39;,' + i + ',&#39;finished&#39;,this.checked)">finished</label>');
      }
      flags.push('<label title="Kept readable on old cars, never offered again"><input type="checkbox"' +
        (o.retired ? ' checked' : '') + ' onchange="pdFlag(&#39;' + col + '&#39;,' + i + ',&#39;retired&#39;,this.checked)">retired</label>');
      const colours = PD_COLOURS.map(cl => '<option value="' + cl + '"' +
        (o.colour === cl ? ' selected' : '') + '>' + cl + '</option>').join('');
      return '<div class="pd-row">' +
        '<span class="pd-move"><button type="button" title="Move up" onclick="pdMove(&#39;' + col + '&#39;,' + i + ',-1)">â†‘</button>' +
        '<button type="button" title="Move down" onclick="pdMove(&#39;' + col + '&#39;,' + i + ',1)">â†“</button></span>' +
        '<span class="opill oc-' + (o.colour || 'grey') + '">' + o.value + '</span>' +
        '<select onchange="pdColour(&#39;' + col + '&#39;,' + i + ',this.value)">' + colours + '</select>' +
        '<span class="pd-flags">' + flags.join('') + '</span>' +
        '<button type="button" class="pd-remove" title="Remove outright, only works when no car carries it" ' +
        'onclick="pdRemove(&#39;' + col + '&#39;,' + i + ')">Ã—</button></div>';
    }
    function pdAdd(col){
      const inp = document.getElementById('pd-add-' + col);
      const v = (inp.value || '').trim();
      if(!v) return;
      if(!PD[col]) PD[col] = [];
      if(PD[col].some(o => o.value.toLowerCase() === v.toLowerCase())){
        toast('That answer is already on the list.', false); return;
      }
      PD[col].push({value: v, colour: 'grey'});
      inp.value = '';
      pdPaintAll();
    }
    function pdMove(col, i, dir){
      const list = PD[col] || [];
      const j = i + dir;
      if(j < 0 || j >= list.length) return;
      const t = list[i]; list[i] = list[j]; list[j] = t;
      pdPaintAll();
    }
    function pdColour(col, i, colour){
      PD[col][i].colour = colour;
      pdPaintAll();
    }
    function pdFlag(col, i, flag, on){
      if(on){
        if(flag === 'default'){
          // only one default per column, the server refuses more anyway
          PD[col].forEach(o => delete o.default);
        }
        PD[col][i][flag] = true;
      } else {
        delete PD[col][i][flag];
      }
      pdPaintAll();
    }
    function pdRemove(col, i){
      PD[col].splice(i, 1);
      pdPaintAll();
    }
    // Wired inline on the button rather than addEventListener here: this
    // panel's markup lives inside the form template, cloned into the DOM
    // only after the settings fetch, so it does not exist yet when this
    // script first parses.
    function pdSaveAll(btn){
      btn.disabled = true;
      const flow = {
        video_stage: document.getElementById('flow-video').checked,
        fee_rebands: {
          Motorway: document.getElementById('flow-reband-Motorway').checked,
          Carwow: document.getElementById('flow-reband-Carwow').checked,
        },
      };
      fetch('/api/purchase-page-settings', {method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({purchase_dropdowns: PD, purchase_flow: flow})
      }).then(r => r.json()).then(d => {
        btn.disabled = false;
        if(!d || !d.ok){ toast((d && d.error) || 'Could not save.', false); return; }
        S.purchase_dropdowns = PD;
        S.purchase_flow = flow;
        const ok = document.getElementById('pd-saved');
        ok.style.display = 'inline';
        setTimeout(() => { ok.style.display = 'none'; }, 2500);
      }).catch(() => { btn.disabled = false; toast('Could not save.', false); });
    }

    // Logo and favicon uploads (Mark 2026-08-25: "add logo upload for the
    // left panel, big and small also fav icon"), staged straight into S as
    // a data URL the moment a file is picked (a real FileReader read, not
    // a guessed path), same as every other field on this page, only
    // actually saved once Save settings is clicked. The server side
    // re validates the shape and a size cap on save, this is just for a
    // live preview and to avoid an obviously oversized file wasting a
    // round trip.
    function wireLogoUpload(inputId, previewId, settingsKey, defaultSrc){
      document.getElementById(inputId).addEventListener('change', function(){
        const file = this.files && this.files[0];
        if(!file) return;
        if(file.size > 1.5 * 1024 * 1024){
          flash('That file is over 1.5MB, use a smaller one.');
          this.value = '';
          return;
        }
        const reader = new FileReader();
        reader.onload = () => {
          S[settingsKey] = reader.result;
          document.getElementById(previewId).src = reader.result;
        };
        reader.readAsDataURL(file);
      });
      document.getElementById(inputId.replace('_upload', '_clear')).addEventListener('click', () => {
        S[settingsKey] = '';
        document.getElementById(previewId).src = defaultSrc;
        document.getElementById(inputId).value = '';
      });
    }

    // Integrations: each automation's own on/off switch, a separate object
    // and a separate Save button from the buying rules above (Mark
    // 2026-08-24), since /api/settings only ever validates and stores the
    // buying rule shape, an unrelated integrations key inside it would be
    // silently dropped rather than saved.
    let I = null;
    function paintIntegrations(){
      Object.keys(I).forEach(key => {
        const box = document.getElementById('integ-' + key);
        if(box) box.checked = !!(I[key] && I[key].enabled);
      });
    }

    // Each connection row's own info button reveals its one line "what is
    // this" note, collapsed by default (Steven 2026-08-26 review: the old
    // page kept every card's own prose permanently open, pushing the
    // actually useful state text further down).
    function toggleConnNote(btn){
      const note = btn.closest('.conn-main').querySelector('.conn-note');
      if(note) note.hidden = !note.hidden;
    }

    // Hide a connection this dealership does not use, or show it again
    // (Steven 2026-08-26). Hiding also switches its own automations off,
    // showing it again switches them back on, all server side (see
    // serve._apply_hide), so this just posts and reloads: settings.html
    // renders fresh per request, the reload shows the new arrangement.
    async function hideConnection(site, hide){
      if(hide && !confirm('Hide ' + SITE_LABELS[site] + '? It stops running entirely and disappears '
          + 'from the Run menu too. Show it again any time from the Hidden section at the bottom of this list.')) return;
      try {
        const r = await fetch('/api/hide-connection', {method:'POST', headers:{'Content-Type':'application/json'},
          body:JSON.stringify({site, hidden: hide})});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'could not save');
        location.reload();
      } catch(e){ flash(e.message || 'Could not save.', false); }
    }

    // Relogin: opens a real headed browser window for one site so Mark can
    // log in by hand (Mark 2026-08-24, "add a button under each
    // intergration to relogin"). Two steps, not one, because manual login
    // needs a real person to actually type credentials in: "Log in to X"
    // starts the window, "I'm logged in, save it" tells it he is done
    // (writes the same data/.login_done flag login.py has always waited
    // on, previously only ever set by a Claude session on his word in
    // chat). Buttons are keyed by data-site since DealerKit's own login
    // is shared by two cards (stock read and purchases push).
    const SITE_LABELS = {dealerkit: 'DealerKit', glass: "Glass's", lecapital: 'LE Capital', cazana: 'Cazana',
      motorway: 'Motorway', motorway_accounts: 'Motorway (accounts)', carwow: 'Carwow',
      auction4cars: 'Auction4Cars', dealerauction: 'Dealer Auction', dealerway: 'DealerWay'};
    async function startRelogin(site){
      if(!confirm('Open a login window for ' + SITE_LABELS[site] + ' now? Log in there, then come back and click "I\'m logged in, save it".')) return;
      try {
        const r = await fetch('/api/relogin', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({site})});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'could not start');
        if(d.started) flash(SITE_LABELS[site] + ' login window opening on the Mac.', true);
        else flash(d.reason || 'Another run or login is already in progress. Try again once it finishes.', false);
        pollLoginStatus();
        slNudge();
      } catch(e){ flash(e.message || 'Could not start the login window.', false); }
    }
    async function finishRelogin(site){
      try {
        const r = await fetch('/api/relogin-done', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({site})});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'could not save');
        flash(SITE_LABELS[site] + ' login saved. Reloading...', true);
        setTimeout(() => location.reload(), 1500);
      } catch(e){ flash(e.message || 'Could not save. Was the login window still open?', false); }
    }
    function paintLoginButtons(activeSite){
      // Real bug found live 2026-08-26 (Mark: "it wont allow me to click
      // it"): .relogin-btn matches BOTH the "Log in to X" buttons and the
      // "I'm logged in, save it" ones (the finish button carries both
      // classes), so this used to disable the active site's own finish
      // button right along with every other site's start button, the
      // one button that is actually meant to stay clickable while a
      // login is open. Split the two kinds explicitly: every start
      // button disables while any login is open (never two at once),
      // the finish button shows and stays enabled only for the site
      // actually being logged into.
      document.querySelectorAll('.relogin-btn:not(.relogin-done-btn)').forEach(b => { b.disabled = !!activeSite; });
      document.querySelectorAll('.relogin-done-btn').forEach(b => {
        const isActive = b.dataset.site === activeSite;
        b.hidden = !isActive;
        b.disabled = !isActive;
      });
      // Explains, in plain sight, why every other login button just went
      // grey (Mark 2026-08-26 hit exactly this blind: "it wont allow me
      // to click it", having clicked several relogin buttons in a row
      // with no idea one was already open).
      const wait = document.getElementById('loginwait');
      if(wait){
        wait.hidden = !activeSite;
        if(activeSite) document.getElementById('loginwait-text').textContent =
          'Waiting for you to finish logging in to ' + (SITE_LABELS[activeSite] || activeSite) +
          '. Other logins are paused until it closes.';
      }
    }
    let _loginPollTimer = null;
    async function pollLoginStatus(){
      try {
        const s = await (await fetch('/api/run-status')).json();
        const active = s.login_running ? s.login_site : null;
        paintLoginButtons(active);
        if(_loginPollTimer) clearTimeout(_loginPollTimer);
        if(active) _loginPollTimer = setTimeout(pollLoginStatus, 3000);
      } catch(e){ _loginPollTimer = setTimeout(pollLoginStatus, 5000); }
    }

    // Software update, Mark 2026-08-24: "a button to trigger move to
    // latest version". Checks the real GitHub Releases against whatever
    // this Mac has checked out, one click updates and restarts the
    // server (a few seconds' blip while it comes back up on the new
    // code, KeepAlive brings it back automatically, this page just polls
    // until it responds again and reloads).
    async function loadVersion(){
      const status = document.getElementById('update-status');
      const btn = document.getElementById('updatebtn');
      try {
        const v = await (await fetch('/api/version')).json();
        if(v.update_available){
          status.textContent = 'Update available: ' + v.latest + ' (you have ' + v.current + ').';
          btn.hidden = false;
        } else if(v.latest){
          status.textContent = "You're on the latest version (" + v.current + ").";
          btn.hidden = true;
        } else {
          status.textContent = 'On ' + v.current + '. Could not check for a newer release'
            + (v.error ? (': ' + v.error) : '.');
          btn.hidden = true;
        }
      } catch(e){ status.textContent = 'Could not check for updates.'; }
    }
    async function startUpdate(){
      if(!confirm('Update BidBrain now? The server will restart itself, a few seconds\' blip on this page, then reload.')) return;
      const btn = document.getElementById('updatebtn');
      const status = document.getElementById('update-status');
      btn.disabled = true;
      status.textContent = 'Updating...';
      try {
        const r = await fetch('/api/update', {method:'POST'});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'update failed');
        if(d.already_latest){
          status.textContent = "You're already on the latest version (" + d.version + ").";
          btn.hidden = true; btn.disabled = false;
          return;
        }
        status.textContent = 'Updated to ' + d.version + '. Restarting the server...';
        pollForRestart();
      } catch(e){ status.textContent = e.message || 'Could not update.'; btn.disabled = false; }
    }
    function pollForRestart(){
      setTimeout(async () => {
        try {
          const r = await fetch('/api/version');
          if(r.ok){ location.reload(); return; }
        } catch(e){}
        pollForRestart();
      }, 2000);
    }

    async function load(){
      try {
        const r = await fetch('/api/settings'); const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'load failed');
        S = d.settings;
        I = d.settings.integrations || {};
        document.getElementById('wrap').innerHTML = document.getElementById('form-template').innerHTML;
        currentPlatform = 'motorway';
        currentView = 'overview';
        document.querySelectorAll('#top-tabs .tab, #rules-tabs .tab').forEach(b => {
          b.classList.toggle('on', b.dataset.view === currentView);
          b.onclick = () => switchView(b.dataset.view);
        });
        paint();
        wireLogoUpload('logo_full_upload', 'logo_full_preview', 'theme_logo_full', DEFAULT_LOGO_FULL);
        wireLogoUpload('logo_small_upload', 'logo_small_preview', 'theme_logo_small', DEFAULT_LOGO_SMALL);
        wireLogoUpload('favicon_upload', 'favicon_preview', 'theme_favicon', DEFAULT_LOGO_SMALL);
        // Deep link from a bell notification (Mark 2026-08-24: "make the
        // click of notification go to the action that is required"), for
        // example settings.html?tab=integrations straight from a relogin
        // notice, without forcing a click through Overview first.
        const wantTab = new URLSearchParams(location.search).get('tab');
        if(wantTab && document.querySelector('#top-tabs .tab[data-view="' + wantTab + '"], #rules-tabs .tab[data-view="' + wantTab + '"]')){
          switchView(wantTab);
        }
        wireAdd('makes', S.banned_makes);
        wireAdd('vans', S.van_models);
        wireAdd('small', S.small_car_tokens);
        // Bound to whichever platform's location list is currently on
        // screen (Auction4Cars' depots or Dealer Auction's counties), not a
        // fixed array, since the two tabs share this one input and button.
        document.getElementById('locations-addbtn').onclick = () => {
          const p = S.platforms[currentPlatform];
          const values = p.allowed_locations || (p.allowed_locations = []);
          const input = document.getElementById('locations-add');
          const v = input.value.trim(); if(!v) return;
          values.push(v); input.value = ''; chipList('locations', values);
        };
        document.getElementById('locations-add').addEventListener('keydown', e => {
          if(e.key === 'Enter'){ e.preventDefault(); document.getElementById('locations-addbtn').click(); }
        });
        document.getElementById('add-model').onclick = () => { S.banned_models.push(['','']); renderModels(); };
        document.getElementById('add-cap').onclick = () => { S.value_caps.push({make:'',model:null,cap:0}); renderCaps(); };
        document.getElementById('savebtn').onclick = save;
        paintIntegrations();
        pollLoginStatus();
        loadVersion();
        if(d.banned_engine_names){
          document.getElementById('engine-list').innerHTML =
            d.banned_engine_names.map(n => '<li>'+n+'</li>').join('');
        }
      } catch(e){
        document.getElementById('wrap').innerHTML = '<p>Could not load. Open this page at its web address, not from a file.</p>';
      }
    }

    async function save(){
      S.retail_uplift = parseFloat(document.getElementById('retail_uplift').value);
      S.flat_spread = parseFloat(document.getElementById('flat_spread').value);
      S.small_car_cap_limit = parseFloat(document.getElementById('small_car_cap_limit').value);
      S.dealer_postcode = document.getElementById('dealer_postcode').value;
      const ceilingVal = document.getElementById('default_price_ceiling').value;
      S.default_price_ceiling = ceilingVal ? parseFloat(ceilingVal) : null;
      const retailCapVal = document.getElementById('retail_price_cap').value;
      S.retail_price_cap = retailCapVal ? parseFloat(retailCapVal) : null;
      const marginVal = document.getElementById('retail_estimate_margin').value;
      S.retail_estimate_margin = marginVal !== '' ? parseFloat(marginVal) : 500;
      S.retail_estimate_ending = parseInt(document.getElementById('retail_estimate_ending').value, 10);
      S.details_style = document.getElementById('details_style').value;
      S.theme_bg = document.getElementById('theme_bg').value;
      S.theme_price = document.getElementById('theme_price').value;
      S.theme_pill = document.getElementById('theme_pill').value;
      S.theme_icon = document.getElementById('theme_icon').value;
      S.theme_sidebar = document.getElementById('theme_sidebar').value;
      syncGate();
      try {
        const r = await fetch('/api/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(S)});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'save failed');
        S = d.settings;
        paint();
        flash('Saved.', true);
      } catch(e){ flash(e.message || 'Could not save.', false); }
    }

    async function saveIntegrations(){
      Object.keys(I).forEach(key => {
        const box = document.getElementById('integ-' + key);
        if(box) I[key] = {enabled: box.checked};
      });
      try {
        const r = await fetch('/api/integration-settings', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(I)});
        const d = await r.json();
        if(!d.ok) throw new Error(d.error || 'save failed');
        I = d.integrations;
        paintIntegrations();
        flash('Saved. Run buttons on the cockpit now reflect this.', true);
      } catch(e){ flash(e.message || 'Could not save.', false); }
    }

    load();

    // Notification bell, same design and behaviour as the cockpit's own
    // (Mark 2026-08-25: "standardise the nav to icons for favorites,
    // purchases, notifications, lost bids and settings"), just fed by a
    // live fetch here rather than baked in at render time, since this page
    // has no run data of its own. A "Run Glass's checks"/"Run DealerKit
    // stock" notice has no Run modal to open on this page, so it deep
    // links back to the cockpit with ?openrun=1 instead (server side, see
    // bidbrain.render._bell_markup).
  </script>

  <template id="form-template">
    <div class="tabs" id="rules-tabs" style="margin:0 0 14px">
      <button type="button" class="tab on" data-view="overview">All auction houses</button>
      <button type="button" class="tab" data-view="motorway">Motorway</button>
      <button type="button" class="tab" data-view="carwow">Carwow</button>
      <button type="button" class="tab" data-view="auction4cars">Auction4Cars</button>
      <button type="button" class="tab" data-view="dealerauction">Dealer Auction</button>
      <button type="button" class="tab" data-view="dealerway">DealerWay</button>
    </div>
    <div id="overview-panel">
    <div class="card">
      <h2>Software update</h2>
      <p class="note">Checks the project's own GitHub releases against the version this Mac has checked out.</p>
      <p class="integ-status" id="update-status">Checking...</p>
      <button type="button" class="relogin-btn" id="updatebtn" hidden onclick="startUpdate()">Update now</button>
    </div>

    <div class="card">
      <h2>Dealer location</h2>
      <p class="note">Reference only, BidBrain does not use this to calculate anything itself. Motorway and Carwow already know where you are, each from the postcode set directly in your own account on that platform, and do their own distance filtering from it before a car ever reaches here. Keep this in sync with what is actually set on each platform so it is visible in one place.</p>
      <div class="field" style="max-width:220px;"><label>Postcode</label><input type="text" id="dealer_postcode" placeholder="e.g. NE3 5HE"></div>
    </div>

    <div class="card">
      <h2>Cockpit display</h2>
      <p class="note">Display only, never a gate: a car above this still gets priced, gated and counted as normal, it is just hidden from the cockpit's live view by default (the sidebar's own Max bid AND Reserve filters both start pre filled with it on load, clear either box, or click Reset, to see everything for that session). Leave empty for no ceiling.</p>
      <div class="field" style="max-width:220px;"><label>Price ceiling, pounds</label><input type="number" id="default_price_ceiling" placeholder="No ceiling"></div>
    </div>

    <div class="card">
      <h2>Pricing formula</h2>
      <p class="note">Controls the actual recommended max bid on every car, the same for every auction house. Governing value is Glass's or Cazana retail plus the uplift below; max bid is the governing value minus the flat spread.</p>
      <div class="grid2">
        <div class="field"><label>Retail uplift, %</label><input type="number" id="retail_uplift" step="0.1"></div>
        <div class="field"><label>Flat spread, pounds</label><input type="number" id="flat_spread"></div>
      </div>
      <p class="note">The flat spread covers prep, buyer fees, VAT on the margin, transport and profit on every car. The Â£10,000 governing threshold and the engine ban rules stay code only.</p>
    </div>

    <div class="card">
      <h2>Retail est.</h2>
      <p class="note">How the Retail est. figure itself is worked out, once a car is bought: Cazana retail &times;1.15, plus a flat margin, rounded to the nearest price ending. Genuinely different from "Retail uplift, %" in the pricing formula above, which only affects the maximum BID, not this resale figure.</p>
      <div class="field" style="max-width:220px;"><label>Margin added on top, pounds</label><input type="number" id="retail_estimate_margin" min="0" step="1"></div>
      <div class="field" style="max-width:220px;"><label>Price ending</label>
        <select id="retail_estimate_ending">
          <option value="95">&hellip;95 (&pound;X,295 / X,495 / X,795 / X,995)</option>
          <option value="99">&hellip;99 (&pound;X,299 / X,499 / X,799 / X,999)</option>
        </select>
      </div>
      <p class="note">A ceiling on the figure itself, on the Purchases page and pushed to DealerKit as Retail Price. A purchase above this cap shows and pushes at the cap instead of the formula's own higher figure. Leave empty for no cap. A manual edit on the Purchases page row always wins over this cap.</p>
      <div class="field" style="max-width:220px;"><label>Cap, pounds</label><input type="number" id="retail_price_cap" placeholder="No cap"></div>
    </div>

    <div class="card">
      <h2>Banned makes</h2>
      <p class="note">Rejected outright, whatever the rest of the car looks like, across every auction house.</p>
      <div class="chips" id="makes-chips"></div>
      <div class="addrow"><input type="text" id="makes-add" placeholder="Add a make"><button type="button" id="makes-addbtn">Add</button></div>
    </div>

    <div class="card">
      <h2>Banned models</h2>
      <p class="note">Specific make and model combinations, rejected regardless of price.</p>
      <table class="edit" id="models-table">
        <thead><tr><th>Make</th><th>Model</th><th></th></tr></thead>
        <tbody></tbody>
      </table>
      <button type="button" class="rowadd" id="add-model">Add a row</button>
    </div>

    <div class="card">
      <h2>Vans and commercials</h2>
      <p class="note">Model name tokens treated as a van or commercial, not a car.</p>
      <div class="chips" id="vans-chips"></div>
      <div class="addrow"><input type="text" id="vans-add" placeholder="Add a model name"><button type="button" id="vans-addbtn">Add</button></div>
    </div>

    <div class="card">
      <h2>Value caps</h2>
      <p class="note">Rejected when CAP Clean is over the cap. Leave model blank for any model of that make. Exceptions are model names that skip the cap, comma separated.</p>
      <table class="edit" id="caps-table">
        <thead><tr><th>Make</th><th>Model</th><th>Cap Â£</th><th>Exceptions</th><th></th></tr></thead>
        <tbody></tbody>
      </table>
      <button type="button" class="rowadd" id="add-cap">Add a row</button>
    </div>

    <div class="card">
      <h2>Small city cars</h2>
      <p class="note">Rejected when CAP Clean is over the limit below. Below it they are fine.</p>
      <div class="chips" id="small-chips"></div>
      <div class="addrow"><input type="text" id="small-add" placeholder="Add a model name"><button type="button" id="small-addbtn">Add</button></div>
      <div class="field" style="margin-top:10px; max-width:220px;"><label>Cap limit, pounds</label><input type="number" id="small_car_cap_limit"></div>
    </div>

    <div class="card readonly">
      <h2>Banned engines</h2>
      <p class="note">Structured rules (engine text matches, make and year restrictions), not simple lists, so these stay code only. Ask your Claude to add or change one.</p>
      <ul id="engine-list"></ul>
    </div>
    </div>

    <div id="theme-panel" hidden>
    <div class="card">
      <h2>Car details</h2>
      <p class="note">What Details on a live auction card opens. The record drawer is the full slide in panel with photos, the max bid receipt, CompMatch, history and flags. The pop-up is the small floating panel it replaced, flags and valuation chips only, over the card itself.</p>
      <div class="field" style="max-width:320px;"><label>When I click Details</label>
        <select id="details_style" class="filtersel" style="width:100%; padding:8px 9px; border:1px solid var(--line); border-radius:8px; font-size:14px; background:#fff;">
          <option value="drawer">Open the record drawer</option>
          <option value="popup">Open the small pop-up</option>
        </select></div>
    </div>
    <div class="card">
      <h2>Brand colours</h2>
      <p class="note">Every colour below is genuinely independent, changing one never touches the others. Background is the app's own base colour behind every page. Price colour is the big retail and max bid figures. Pill colour is badges like the room pill and the stock count. Icon colour is buttons, links and the active item in the left panel. Left panel colour is the sidebar's own background tone. Red, amber and green status colours (warnings, condition grades) and the Motorway/Carwow platform badges are not covered here, they carry their own real meaning and always stay as they are.</p>
      <div class="grid2">
        <div class="field"><label>Background colour</label><input type="color" id="theme_bg"></div>
        <div class="field"><label>Left panel colour</label><input type="color" id="theme_sidebar"></div>
        <div class="field"><label>Price colour</label><input type="color" id="theme_price"></div>
        <div class="field"><label>Pill colour</label><input type="color" id="theme_pill"></div>
        <div class="field"><label>Icon colour</label><input type="color" id="theme_icon"></div>
      </div>
    </div>

    <div class="card">
      <h2>Left panel logo</h2>
      <p class="note">Shown at the top of the left panel: the full logo while it is open, the small mark alone once it is collapsed to a rail. Real image files only (PNG, JPG, WebP or SVG), kept under about 1.5MB. Leave either as the default to use Right Drive's own real logo.</p>
      <div class="grid2">
        <div class="field">
          <label>Full logo, expanded</label>
          <div class="logo-preview-row"><img id="logo_full_preview" class="logo-preview" alt=""><input type="file" accept="image/png,image/jpeg,image/svg+xml,image/webp" id="logo_full_upload"></div>
          <button type="button" class="rowadd" id="logo_full_clear" style="margin-top:8px;">Use the default logo</button>
        </div>
        <div class="field">
          <label>Small mark, collapsed</label>
          <div class="logo-preview-row"><img id="logo_small_preview" class="logo-preview small" alt=""><input type="file" accept="image/png,image/jpeg,image/svg+xml,image/webp" id="logo_small_upload"></div>
          <button type="button" class="rowadd" id="logo_small_clear" style="margin-top:8px;">Use the default mark</button>
        </div>
      </div>
    </div>

    <div class="card">
      <h2>Favicon</h2>
      <p class="note">The small icon shown in the browser's own tab for every page. Leave as the default to use the small logo mark above.</p>
      <div class="field" style="max-width:260px;">
        <div class="logo-preview-row"><img id="favicon_preview" class="logo-preview small" alt=""><input type="file" accept="image/png,image/x-icon,image/svg+xml" id="favicon_upload"></div>
        <button type="button" class="rowadd" id="favicon_clear" style="margin-top:8px;">Use the default icon</button>
      </div>
    </div>
    </div>

    <div id="purchases-panel" hidden>
    <div class="card">
      <h2>Purchases working list</h2>
      <p class="note">The answers each dropdown on the purchases page offers, this dealership's own
        (Steven 2026-08-27, "marks can be his own"). Add new answers, reorder them, or retire the ones
        no longer used. Removing an answer still sitting on real cars is refused, retire it instead:
        a retired answer stays readable on the cars that carry it but is never offered again.</p>
      <div id="pd-cards"></div>
    </div>
    <div class="card">
      <h2>Journey</h2>
      <label class="pd-flowlab"><input type="checkbox" id="flow-video"> Video walkaround stage</label>
      <p class="note">On, the car's journey is Bought, Video check, Collection, Arrived, and the Video
        column shows on the working list. Off, the video stage and column disappear entirely, the
        journey runs straight from Bought to Collection.</p>
      <label class="pd-flowlab"><input type="checkbox" id="flow-reband-Motorway"> A chip moves the Motorway fee</label>
      <label class="pd-flowlab"><input type="checkbox" id="flow-reband-Carwow"> A chip moves the Carwow fee</label>
      <p class="note">When on for a platform, recording a chip re reads that platform's own fee band at
        the new price and freezes the new fee onto the chip. Carwow fees follow the ORIGINAL price, so
        Carwow stays off unless their billing changes. The fee band tables themselves live in the code,
        deliberately, they only ever change when a platform publishes new ones.</p>
      <button type="button" class="save" id="pd-save" style="margin-top:12px;" onclick="pdSaveAll(this)">Save purchase settings</button>
      <span id="pd-saved" class="note" style="margin-left:10px;display:none;">Saved.</span>
    </div>
    </div>

    <div id="platform-panel" hidden>
    <div class="card">
      <h2>Read this platform</h2>
      <label><input type="checkbox" id="platform-enabled-toggle"> <span id="platform-enabled-label">Include this auction house</span></label>
      <p class="note">Off means it is never read again, not by the scheduled daily run and not by Run now, until switched back on here. Cars already on the list from it stay exactly as they are, they are not removed.</p>
    </div>
    <div class="card">
      <h2>Hard gate</h2>
      <p class="note" id="platform-note">Every car from this auction house must pass all of these or it is rejected outright.</p>
      <div class="grid2">
        <div class="field"><label>Max mileage</label><input type="number" id="max_mileage"></div>
        <div class="field"><label>Max reserve, pounds</label><input type="number" id="max_reserve"></div>
        <div class="field"><label>Min age, years</label><input type="number" id="min_age"></div>
        <div class="field"><label>Max age, years</label><input type="number" id="max_age"></div>
        <div class="field" id="distance-field"><label>Max distance, miles</label><input type="number" id="max_distance_miles"></div>
        <div class="field"><label>Max previous owners</label><input type="number" id="max_owners"></div>
      </div>
      <div class="field" id="grades-field">
        <label>Condition grades allowed</label>
        <div class="grades">
          <label><input type="checkbox" id="grade1">1</label>
          <label><input type="checkbox" id="grade2">2</label>
          <label><input type="checkbox" id="grade3">3</label>
          <label><input type="checkbox" id="grade4">4</label>
          <label><input type="checkbox" id="grade5">5</label>
        </div>
      </div>
      <div class="field" id="locations-field" hidden>
        <label>Allowed depots</label>
        <p class="note">Auction4Cars only ever gives a depot name, never exact miles, so this is its distance check: only cars at a depot listed here show. Leave empty to not filter by depot at all (today's default).</p>
        <div class="chips" id="locations-chips"></div>
        <div class="addrow"><input type="text" id="locations-add" placeholder="Add a depot, for example Newport"><button type="button" id="locations-addbtn">Add</button></div>
      </div>
      <p class="note" id="grade-note">Auction4Cars and DealerWay have no condition grade at all, judged by eye from the photos instead, so the grades above have no effect for either. Dealer Auction has a real condition grade and keeps it. Motorway and Carwow read the exact distance separately, this limit only applies when they can supply one.</p>
    </div>
    </div>

    <div id="integrations-panel" hidden>
    <div class="card">
      <h2>Integrations</h2>
      <p class="note">Everything BidBrain logs in to, plus every back office automation beyond the auction platforms and valuation sources themselves. Anything broken floats to the top. Turning an automation off both hides its button on the cockpit's Run menu and stops it running at all, whether started from the page, the API, or (DealerKit stock only) the scheduled daily run.</p>
      {{CONN_BANNER}}
      <div class="conn-banner wait" id="loginwait" hidden>
        <span class="spin"></span>
        <span id="loginwait-text"></span>
      </div>
      {{CONNECTION_ROWS}}
    </div>
    </div>
  </template>
</div>
</div>
</body>
</html>
z{{CONN_BANNER}}z{{CONNECTION_ROWS}}r»  r¼  r½  r¾  r¿  z{{DEFAULT_LOGO_FULL}}z{{DEFAULT_LOGO_SMALL}}rÅ  ru   r  rÀ  r  r  )Ú_INTEGRATIONSrL   rÇ   Ú_CONN_ORDERrM  rÝ   r  r¦  rÄ   rj  rP   r   r:   rl   r   r  rs   rv   ÚRD_LOGOrn   r¢   r£   r¤   rw   rx   ry   rØ   r  r  )rg  rÎ   Ú_conn_dbrî   rc  Ú_ordered_sitesÚconnection_rowsÚhidden_rowsÚ_brokenÚconn_bannerÚ	_warn_svgÚ_banner_textÚ_namesrG  r  r­  r®  r¯  r°  r±  r²  r³  r´  rq   rq  rr  rw  rb  s                           @@@@r   Úrender_settingsr…  R%  s½  û€ ð €HßˆØ×Ñ˜A˜f™I rÓ*×1Ñ1°!Ö4ñ õ 4?Ó?²;¨4”[ Ó&Ò&±;Ñ?€Gõ (Ø!)×!6Ñ!6Ó!8×!<Ñ!<Ð=QÓ!R×!XÐVXÐ!Xó ,Ò!X˜1ØœlÑ*÷ Ñ!Xñ ,€Mõô "(¬¸Ò!?ÓZÒ!?˜AÈMÑCY—aÑ!?€NÐZö7ðr —g‘gÔI¹.ÓIÓI€Oö Ø—g‘gô 
õ $ó
ó 
ˆð 	ÐaØ)ñ*ñ 	+ˆñ !/ÓP¢˜°'¸$±-ÀÑ2FÈ%Ñ2O�t¡€GÐPÞØ‰ðOˆ	ô
 ˆw‹<˜1ÓØ@ÄÈgÐVWÉjÑAYÐ@ZÐZcÐd‰Là—Y‘YÑ@¹Ó@Ó@ˆFÜ! '›l˜^Ð+MÈfÈXÐUVÐWˆLØ1°)°¸FÄ4ÈÓCUÐBVÐVcÐdˆõ #Ø×#Ñ#Ó%€LØ×Ñ 
Ó+×KÔ/?À
Ñ/K€HØ×"Ñ" =Ó1×TÔ5EÀmÑ5T€KØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ×!Ñ! ,Ó/×QÔ3CÀLÑ3Q€JØ ×$Ñ$ _Ó5×ZÔ9IÈ/Ñ9Z€MØ"×&Ñ&Ð'8Ó9×A¸T€OØ#×'Ñ'Ð(:Ó;×C¸tÐØ ×$Ñ$ _Ó5×UÐ9I×UÌX€Mðo€Dô`% Ø�‰Ð&¨Ó4ß‰WÐ*¨OÓ<ç‰W�^ XÓ.ß‰WÐ&¨Ó4ß‰WÐ% zÓ2ß‰WÐ% zÓ2ß‰WÐ(¨-Ó8ß‰WÐ,¬gÓ6ß‰WÐ-¬xÓ8ß‰W�^¤Y£[´=Ñ%@Ä;Ã=Ñ%PÔS_Ñ%_Ó`ß‰WÐ'¬¼+Ñ)EÌÑ)UÓVß‰W�_Ô&7Ó&9Ó:ß‰WÐ(¨-Ó8ß‰WÐ+Ô->Ó-@ÓAß‰W�]¤H¨_Ó$=Ó>ó!@ð @ùòo) @ùò,ùò [ùòf Qs*   Á PÂPÂPÂ9	PÃPÄ"PÄ6Pa¡  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>zÑ<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4.5 6 7.5 9 4.5"></path></svg>c                 ó4   • S[          S3U (       a  SOS-   S-   $ )aG  The cockpit's head bar control: one place for every view (Steven
2026-08-30, "this could be the place where we house all filtered/saved
views... so we only have one place"). The label is derived on every
repaint by activeViewName, so it says the name of the view you are exactly
on, "All cars" when nothing is applied, or "Custom" the moment you deviate.

Everything in the list is a view YOU made. The fixed slices that used to
live here (Gap fillers, Auto only, Held back) became ordinary filters, and
the shortlist is a top bar toggle, not a view.

CONTRACT: a page embedding this must define resetFilters(), meaning "no
view applied", every filter cleared and the sort back to that page's own
default. It cannot live here because each page keeps its sort differently.
test_pricing.py checks every page that renders the chip defines it.zö<div class="viewwrap"><button type="button" id="viewchip" class="viewchip" onclick="toggleViewMenu(event)" aria-haspopup="true" aria-expanded="false"><span id="viewchiplabel">All cars</span><span class="n" id="viewchipn"></span><span class="car">a  </span></button><div id="viewmenu" class="viewmenu" hidden onclick="event.stopPropagation()"><h3 class="viewsmenu-head">Your views</h3><div class="viewrow" id="viewallrow" onclick="resetFilters()"><span class="viewname-btn">All cars</span><span class="viewn" id="viewalln"></span></div>z¢<div class="viewrow" id="viewtoppicksrow" onclick="showTopPicks()"><span class="viewname-btn">Top picks</span><span class="viewn" id="viewtoppicksn"></span></div>r   a#  <div id="viewslist" class="viewslist"></div><div class="viewdiv"></div><div class="views-save-row" id="viewsaverow"><input id="viewname" type="text" placeholder="Name a view" maxlength="60"><button type="button" class="viewsavebtn" onclick="saveCurrentView()">Save</button></div></div></div>r€   ©Úpickss    r   r  r  Á*  s>   € ð 	ô
 ,Ð,ð 
-:ð	:ö$ FKñBàPRñ%	Tð(	ñ)	ðr&   c                 óZ   • U (       d  gU R                  SS5      nSU(       a  U S3$ S S3$ )aµ  The Shortlisted count, a button in the top nav bar. A standalone
toggle independent of the view dropdown above. Hidden when no summary.
Icon only, no outline, gold when on (Mark 2026-08-25: "starting from
the top right user icon, notification bell, star, and a refresh icon
all just icons no outlines to match the left panel look"), the same
.settingslink shape every other icon in this row and on every other
page already uses, with the same small corner count badge the bell's
own notification count uses. The count keeps its real .fn/data-n hook
(applyView's own live recount still finds and updates it after a star
or filter change), just styled as a badge instead of inline bold text
now.r   Úshortlistedr   aŽ  <button id="navshort" class="settingslink" onclick="toggleShort(this)" aria-pressed="false" title="Shortlisted cars"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg><span class="bellbadge fn" data-n="short">r   r$  )Úsummaryr]  s     r   Ú_shortlist_navbtnrŒ  î*  sR   € ö ØØ�‰�M 1Ó%€Að9ö ?@¸Ð8HÐHXðZð [ð FHÐ8HÐHXðZð [r&   a  <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.77 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>c                  ó   • g)a{  The signed in user, moved to the top right of the nav bar (Mark
2026-08-25: "move the user icon top right on the nav"), out of the
left panel's own footer now that the panel can collapse to a narrow
rail with no room left for a name and subtitle. Just the round avatar
(Mark 2026-08-25 again: "just icons no outlines"), the real name still
reads on hover via the title attribute.zU<div class="navuser" title="Mark, Right Drive"><div class="navuser-av">MN</div></div>r   r   r&   r   Ú_user_badgerŽ  +  s   € ð cr&   c                  ó   • S[          S3$ )aM  The settings cog, moved off the left panel and into the top nav bar
(Mark 2026-08-27: "move the settings icon from the left panel to
between the user icon and bell, make this icon a cog"), sitting on
every page between the notification bell and the user avatar, same
icon only .settingslink shape already used for the shortlist star.zT<a class="settingslink" href="settings.html" title="Settings" aria-label="Settings">re  )Ú_COG_SVGr   r&   r   r  r  +  s   € ð%Ü%- J¨dð4ð 5r&   c                 óŒ   • [         R                  U 5      nU(       a  SU  SU S[        U5       S3O
[        U5      nSU SU S3$ )Nú<img class="section-logo ú" src="rú   rò   z<h1 class="section">z<span class="setcount">z</span></h1>©Ú_SECTION_LOGOrÄ   rl   )rS   r$   r]  Úlogorö   s        r   Ú_platform_headingr—  #+  sU   € Ü×Ñ˜SÓ!€Dæð )¨¨¨W°T°F¸'Ä$ÀuÃ+ÀÈbÑQÜ˜u›+ð 
à! % Ð(?À¸sÀ,ÐOÐOr&   Ú	logo_fullc                 ó(   • SU =(       d    [          S3$ )a,  The dealership's own logo, at the left of the top nav bar. It used
to live in the left app shell rail, which was removed outright
(2026-08-31: "remove the side panel from the app, its completely
useless and not needed"). Kept rather than dropped with the rail
because it is real content, not a placeholder like the rail's own
Dashboard/Stock/Customers/Reports items were, and because Settings >
Theme lets each dealership upload their own: with no logo anywhere on
the page that upload would quietly do nothing. Links home, the way a
masthead usually does.zB<a class="navlogo" href="cockpit.html" title="BidBrain"><img src="z" alt="Right Drive"></a>)r{  )r˜  s    r   r  r  *+  s!   € ðØ"×-¤gÐ.Ð.FðHð Ir&   rg  zLive Auctionszcockpit.htmlr¬   Ú	Purchaseszpurchases.htmlr\  zUnder Offerzunder-offer.htmlrU  ÚLostzlost-bids.htmlc                 óN   ^ • SR                  U 4S j[         5       5      nSU S3$ )a›  A real, page to page navigation toggle for the app's content pages
(Mark 2026-08-27: "copy the toggle for the auction house and make
toggle for Live Auctions, Purchases, Lost"), built the same .tabs/.tab
shape as the platform toggle it is copied from, just real links (<a>,
not a JS driven filter) since these are genuinely different pages,
not a state change within one. `active` is one of "cockpit"/
"purchases"/"underoffer"/"lostbids". This is now the app's ONLY
navigation, the left app shell rail having been removed outright
(2026-08-31: "remove the side panel from the app, its completely
useless and not needed for this app at this point"), so every real
destination has to be reachable from here. Sits in the top nav bar,
alongside whatever else that page's own .controls already holds (the
platform toggle now lives further down on the live auction page
itself, this is a separate, always in the nav bar control).r   c              3   óš   >#   • U  H@  u  pp4S UT:X  a  SOS SU S[        U5       S[        U5       SU S[        U5       S3v •  MB     g	7f)
z<a class="tabr  r   z" href="r  ú" aria-label="rò   z<span class="pageswitch-label">z</span></a>NrC  )r   rS   Úiconr$   ÚhrefÚactives        €r   r   Ú!_page_switcher.<locals>.<genexpr>U+  sg   øé € ð ò ';Ñ"ˆC�uð  ¨£™°BÐ7°xÀ¸vÀYÌtÐTYË{Èmð \Ü˜E“{�m 2 d VÐ+JÌ4ÐPUË;È-ÐWbõ	dâ&:ùs   ƒAAz<div class="tabs pageswitcher">rš   )r   Ú_PAGE_SWITCHER_PAGES)r¡  Úbuttonss   ` r   r  r  F+  s3   ø€ ð �g‰gô õ ';óó €Gð
 -¨W¨I°VÐ<Ð<r&   c                 ó˜   • SS jnU" SSSS9U" SS5      -   U" SS	5      -   U" S
S5      -   U" SS5      -   U" SS5      -   nSU SU SU  S3$ )aµ  The All/Motorway/Carwow/... platform switcher, moved out of the top
nav bar and down to sit where a section's own auction house icon
already appears (Mark 2026-08-27: "move the current toggle for the
auction house from the nav bar and move it to where the icon of the
auction house is, move the amount of cars to the right of the new
toggle location, use the auction house logos on the toggle", then
"move the filter, ending soonest etc on the same line as the toggle").
Same id="tabs", same .tab/data-tab markup and setTab() JS as before,
only WHERE it renders and WHAT each button shows changed: a real logo
image for every platform that already has one (the same _SECTION_LOGO
dict every section heading uses), plain text for All and for DealerWay
(no captured logo yet, same fallback _platform_heading already falls
back to). The car count that used to sit right after the tabs is gone
on the pages that carry a views control (2026-08-30: the chip already
shows the identical live number, confirmed by switching platform tabs
and watching both read the same figure every time, so the count here
was just the same information twice); Under Offer has no views control
and keeps its own count for exactly that reason. lead_html (the views
chip, where the page has one) renders before the tabs and controls_html
(the starred view chips and the More button) after them, both passed in
rather than built here since their helpers are defined elsewhere in the
file.c                 óØ   • [         R                  U 5      nU(       a  SU  SU S[        U5       S3O
[        U5      nU(       a  SOSnSU SU  S	[        U5       S
[        U5       SU S3$ )Nr’  r“  rú   rò   ztab onr×   z<button class="z" data-tab="z " onclick="setTab(this)" title="rž  r  r”  )rS   r$   rÑ   r–  rö   rÕ   s         r   Ú_btnÚ"_platform_toggle_bar.<locals>._btnt+  s‚   € Ü× Ñ  Ó%ˆæð -¨S¨E°¸¸¸gÄdÈ5ÃkÀ]ÐRTÑUÜ" 5›kð 	æ‰h %ˆØ! #  l°3°%ð 8Ü˜u›+˜ n´T¸%³[°MÀÀEÀ7È)ðUð 	Vr&   rØ  rÙ  T)rÑ   ræ   r  rç   r   rè   r!  rê   r"  rë   r#  z!<div class="platform-toggle-row">z,<div class="tabs platform-toggle" id="tabs">rš   ©Fr   )Úcontrols_htmlÚ	lead_htmlr§  r¤  s       r   Ú_platform_toggle_barr¬  ]+  s’   € ô.Vñ 	ˆU�E˜dÑ#Ù
ˆz˜:Ó
&ñ	'á
ˆx˜Ó
"ñ	#ñ ˆ~˜~Ó
.ñ	/ñ ˆÐ 0Ó
1ñ		2ñ
 ˆ{˜KÓ
(ñ	)ð ð 0ØˆkØ:¸7¸)À6ØˆoØð	ð r&   c           
      ó¢  ^^• / / / / / / S.nU  H4  nU[        UR                  R                  5         R                  U5        M6     SSSSSSS.nS/nS H_  nXH   n	U	(       d  M  S	R	                  UU4S
 jU	 5       5      n
[        X†U   [        U	5      5      nUR                  SU SU SU
 S35        Ma     S	R	                  U5      =(       d    S$ )zŒRender the shortlist grouped into per platform sections so Motorway and
Carwow cars are never mixed. The tabs above show one section or all.)ræ   rç   rè   rê   rë   rì   r  r   r!  r"  r#  ÚOtherzã
      <section class="platset" data-plat="picks" style="display:none">
        <div class="section-head"><h1 class="section">Top picks<span class="setcount">0</span></h1></div>
        <div class="grid"></div>
      </section>r   c              3   ó>   >#   • U  H  n[        UTT5      v •  M     g 7fr   )rb  )r   r–  rD  rE  s     €€r   r   Ú%_platform_sections.<locals>.<genexpr>¢+  s   øé € ÐMÊÀ1œ¨¨6°:×>Ð>Êùó   ƒz+
      <section class="platset" data-plat="z%">
        <div class="section-head">z!</div>
        <div class="grid">ú</div>
      </section>z<<p class="empty">No cars made the shortlist in this set.</p>)rï   r   rí   rÇ   r   r—  r:   )Ú	shortlistrD  r‹  rE  Úbucketsr–  Úlabelsr—  rS   r—  r™  Úheadings    ` `        r   Ú_platform_sectionsr·  Š+  sö   ù€ ð ¨¸RÐRTÐceÐprÑs€GÛˆØ” §¡§¡Ó-Ñ.×5Ñ5°aÖ8ñ à$°Ø+Ð>NØ%°ñ9€Fðð €Có
 ]ˆØ‰|ˆÞÙØ—‘ÕMÉÓMÓMˆô $ C°©´c¸$³iÓ@ˆØ�
‰
ð +Ø+.¨%ð 0#Ø#* )ð ,Ø ˜'ð "ðö 	ñ ]ð  �7‰7�3‹<×YÐYÐYr&   c                 óJ   • U(       a  SU S3OSnSU S[        U 5       SU S3$ )aO  The house labelled field, the one shape every drawer control uses: a
label above, the control below, one consistent gap underneath. Modelled
on the settings page's own .field, because before this the drawer mixed
three different field shapes with two different spacings and only half
of them carried a label at all (Steven 2026-08-30).z id="r»   r   z<div class="moredr-field"z><label>z</label>rš   rC  )r$   r7  rß  Úidattrs       r   r‡   r‡   ±+  s:   € ö %-ˆu�X�J˜aÑ °"€FØ'¨ x¨x¼¸U»°}ÀHØˆn˜Fð$ð %r&   c                 ó   • SU  U S3$ )z<The two input halves of a range or date field, side by side.z<div class="moredr-pair">rš   r   )Ú
first_htmlÚsecond_htmls     r   rˆ   rˆ   ¼+  s   € à& z l°;°-¸vÐFÐFr&   c                 óº   • SR                  S U=(       d    /  5       5      nUSR                  S U 5       5      -  n[        XSU  SU S[        U5       SU S3	S	9$ )
aä  A dropdown filter for a drawer Filters panel, options built from the
cars actually on today's page. Display only, never touches pricing. The
field's own label carries the name, so the empty option reads "Any"
rather than repeating it.

extra_options are fixed (value, label) pairs offered above the real values,
for a choice the data itself cannot express: Transmission uses it for "Any
automatic", which spans automatic AND semi automatic, unlike picking either
exactly (2026-08-30).r   c              3   ó\   #   • U  H"  u  pS [        U5       S[        U5       S3v •  M$     g7f©ú<option value="rò   ú	</option>NrC  )r   rV   r  s      r   r   Ú!_filter_select.<locals>.<genexpr>Ì+  s0   é € ð 8Ú"5‘v�qð %¤T¨!£W I¨R´°S³	¨{¸)ÕDÚ"5ùs   ‚*,c              3   óX   #   • U  H   nS [        U5       S[        U5       S3v •  M"     g7fr¿  rC  )r   rV   s     r   r   rÂ  Î+  s'   é € ÐTÊVÈ�o¤d¨1£g Y¨b´°a³°	¸ÕCÊVùs   ‚(*ú<select id="ú" class="filtersel" onchange="rž  z"><option value="">Any</option>ú	</select>)rß  r7  ©r   r‡   rl   )Úid_r$   ÚvaluesrÛ  Úextra_optionsrß  r©  s          r   r  r  Á+  s~   € ð �7‰7ñ 8Ø"/×"5°2Ð"5ó8ó 8€DàˆB�G‰GÑTÉVÓTÓTÑT€DÜ�%Ø   Ð%CÀHÀ:ð N!Ü!% e£ Ð-LÈTÈFÐR[ð]ñ^ð ^r&   c                 ód   • U(       a  SU S3OSn[        USU  SU  S[        U5       S3U-   5      $ )zÅA single numeric ceiling for a drawer Filters panel (In stock, fewer
than N). One box, not a min/max pair, because it IS a ceiling. Display
only, reads a numeric data attribute already on the card.r,  rf  r   z<div class="rng one" data-rng="zA" data-ds="stockcount" data-step="1" data-fmt="stock"><input id="z•" type="hidden"><div class="rngbar"><div class="rngfill"></div></div><input type="range" class="rngh rnghi" oninput="onRangeSlide(this)" aria-label="z"><p class="rngout"></p></div>r”   )rÈ  r$   ÚplaceholderÚhintrO  s        r   Ú_filter_numberrÎ  Ô+  s]   € ö 48Ð$ T F¨$Ñ/¸R€Dô �%Ø3°C°5ð 9 à #˜uð %!ô "& e£ ð .1ð2ð 59ñ9ó:ð :r&   c                 óh   • U(       a  SU S3OSnSU  SU SU(       a  SOS S[        U5       S	U S
3$ )a¥  A checkbox filter for a drawer Filters panel. Part of the criteria like
any other filter, so it saves into a view and turns the chip to Custom.
onchange defaults to the cockpit's own render trigger; a page with a
different one (Lost bids uses frameApply, which delegates to its own
render()) passes its own. checked=True is the box's DEFAULT state, the
one "Top picks"/clear all filters returns it to (Steven 2026-09-02).r,  rf  r   zA<div class="moredr-field"><label class="moredr-check"><input id="z" type="checkbox" onchange="r»   rt  r¾   z</span></label>rš   rC  )rÈ  r$   rè  rÍ  rÛ  r)  rO  s          r   rV  rV  æ+  sV   € ö 48Ð$ T F¨$Ñ/¸R€DðØ˜Ð:¸8¸*ÀAÖT[ÁjÐacÐEdð eÜ˜$“Z�L °¨v°Vð=ð >r&   c                  ó
   • / SQ$ )aŒ  (hours, label) pairs for the "Ends within" filter (Mark 2026-08-21:
Auction4Cars and Dealer Auction run over several days, unlike Motorway
and Carwow's fixed 3:30pm close, so how far ahead to look is worth its
own control). A rolling window from right now, matching the live per
second countdown these two platforms already carry, not a calendar day
boundary, so "Today" means the next 24 hours from whenever this is
opened, not "before midnight". Caps at 7 days, the longest a real
Auction4Cars auction runs and the same window the reader itself now
captures (see auction4cars.parse_listing's days_ahead), so every option
here has real data behind it.))r.  ÚToday)é0   ÚTomorrow)éH   zWithin 3 days)é`   zWithin 4 days)éx   zWithin 5 days)é�   zWithin 6 days)é¨   zWithin 7 daysr   r   r&   r   Ú_ENDS_WITHIN_OPTIONSrÙ  ó+  s   € òTð Tr&   c                 ó˜   ^• SR                  U4S jU 5       5      nT(       a  SOSn[        USU  SU S[        U5       SU U S3
5      $ )	aõ  Like _filter_select, but for a fixed set of (value, label) options
that are not built from what happens to be on today's page (the "Ends
within" filter's choices are always the same seven, whether or not a
car in every bucket exists today).

default names one of the options to start on, for a filter whose choices
are the whole set rather than a narrowing of it: the purchases page's own
Show filter always shows one of Due in / In stock / Cancelled / All, so an
empty "Any" would mean nothing there.r   c           	   3   ót   >#   • U  H-  u  pS [        U5       SUT:X  a  SOS S[        U5       S3v •  M/     g7f)rÀ  r»   z	 selectedr   r  rÁ  NrC  )r   rV   r7   rÜ  s      €r   r   Ú'_filter_select_fixed.<locals>.<genexpr>,  sA   øé € ð â‰DˆAð œ$˜q›'˜ !°1¸³<¡KÀRÐ#HÈÌ$ÈqË'ÈÐR[Õ\Úùs   ƒ58z<option value="">Any</option>rÄ  rÅ  rž  rò   rÆ  rÇ  )rÈ  r$   ÚoptionsrÛ  rÜ  r©  Úblanks       `  r   r  r  ,  sl   ø€ ð �7‰7ô áóó €Dö ‰BÐ>€EÜ�%Ø   Ð%CÀHÀ:ð N!Ü!% e£ ¨R°¨w°t°f¸IðGóHð Hr&   c                 óp   • [        USU  SU SU SU SU  SU  S[        U5       S[        U5       S	35      $ )
a  A two handle range slider for the cockpit's Filters panel (Steven
2026-08-30: "any filter that is at (least - upto) needs to be a slider for
ease of use, not two boxes so you have to type it out"). Display only,
reads the numeric data attribute already on each card, never touches
pricing.

Built on two overlapping NATIVE range inputs rather than a library, so
dragging, touch and keyboard all come from the browser. The pair of hidden
inputs keeps the existing {prefix}min / {prefix}max ids, which is what lets
every downstream reader carry on unchanged: captureCriteria, applyCriteria,
resetFilters, readFilterInputs and the filter chips all still just read
bbVal(prefix + 'min').

A handle parked at its end writes an EMPTY value, never the numeric bound,
so an untouched slider is genuinely no filter at all. Without that the
default state would stop equalling DEFAULT, a fresh page would read Custom
and no saved view could ever match.

ds is the card dataset key the bounds are drawn from, step the granularity,
fmt how the readout is worded.r‹   rŒ   r�   rŽ   r�   r�   r‘   r’   r“   r”   )Ú	id_prefixr$   r•   r–   r—   s        r   Ú_filter_rangerá  ,  sk   € ô* �%Ø
% i [°¸B¸4ð @Ø�V˜<¨ uð -Ø�[Ð >¸y¸kð Jô ˜E“{�mð $ä˜E“{�mð $'ð	(ó	)ð 	)r&   c                 ó   ^6• US;   a  UOSnU=(       d	    [         S   nU=(       d	    [         S   nU=(       d	    [         S   nU=(       d	    [         S   nU=(       d	    [         S   nU=(       d    S nU=(       d    S nU=(       d    U=(       d    [        nUc  Un[        U
=(       d    / 5      n
U	(       a  U
R                  U	5        U=(       d    [        R
                  R                  5       nU=(       d
    [        5       nU=(       d    UR                  5       nUR                  S5      nX-    Vs/ s H  nUR                  PM     nn[        S	 U 5       5      m6[        XUT65      n[        S
[        5        S3[        SS9S-   S9n[!        U Vs1 s H<  nUR"                  (       d  M  T6R%                  UR"                  UR"                  5      iM>     sn5      n[!        U Vs1 s H"  nUR&                  (       d  M  UR&                  iM$     snSS9n[!        U Vs1 s H"  nUR(                  (       d  M  UR(                  iM$     sn5      n [!        U Vs1 s H"  nUR*                  (       d  M  UR*                  iM$     sn5      n![!        U Vs1 s H>  nUR,                  (       d  M  UR,                  R/                  5       R1                  5       iM@     sn5      n"[!        U Vs1 s H'  nUR2                  c  M  [5        UR2                  5      iM)     sn5      n#[!        U Vs1 s H'  nUR6                  c  M  [5        UR6                  5      iM)     snS S9n$[!        U Vs1 s H   n[9        U5      (       d  M  [9        U5      iM"     sn5      n%[!        U Vs1 s H"  nUR:                  (       d  M  UR:                  iM$     sn5      n&[!        U Vs1 s HG  nUR<                  (       d  M  UR<                  =(       d    SR/                  5       R?                  5       iMI     sn5      n'[!        U Vs1 s Ho  nUR"                  (       d  M  UR@                  (       d  M)  T6R%                  UR"                  UR"                  5      [C        UR"                  UR@                  5      4iMq     sn5      n([E        S[G        SSUSS95      [E        S[G        SS/ 5      5      -   [E        S[G        SSU V)s/ s H  n)[5        U)5      PM     sn)5      5      -   [E        S[G        S S!U"5      5      -   [E        S"[G        S#S$U S%/S&95      -   [E        S'[G        S(S)U%5      5      -   [E        S*[G        S+S,U$5      5      -   [E        S-[G        S.S/U' V*s/ s H  n*U*R1                  5       PM     sn*5      5      -   [E        S0[I        S1S2S0S3S4S595      -   [E        S6[I        S7S8S6S9S:S595      -   [E        S;[I        S<S=S>S9S:S595      -   [E        S?[I        S@SASBS9S:S59SC-   5      -   [E        SD[K        SESFSGSH5      5      -   [M        SISJSK[N         SL3SM5      -   [M        SNSOSPSQ5      -   n+[E        SR[G        SSSTU#5      5      [E        SU[G        SVSWU!5      5      -   [E        SX[G        SYSZU&5      5      -   [E        S[[Q        S\S][S        5       5      5      -   [E        S^[I        S_S`S^S9S:S595      -   [E        Sa[I        SbScSaSdS4S595      -   n,SeU+-   Sf-   U,-   S-   n-Sg n.ShU." SiSj5      -   Sk-   U." SlSm5      -   U." SnSo5      -   U." S6Sp5      -   U." SqSr5      -   U." SsSt5      -   Su-   n/[U        SvSwU/4SxSySzU--   4/S{S|9n0U=(       d    0 nU=(       d    / n[W        U
SS}9n1Sn2Sn3U(       a4  U(       a-  SRY                  U64S~ jU 5       5      n4S[[        U5       S€U4 S�3n2U(       a1  U(       a*  SRY                  S‚ U 5       5      n5Sƒ[[        U5       S„U5 S�3n3SRY                  / S…PU PS†PU PS‡PU PSˆPU PS‰PU PSŠPU PS‹P[]        5        PSŒPU PS�PU PSŽPU PS�PU PS�P[^         PS‘P[`         PS’P[b         PS“P[d         PS”P[f         PS•P[i        U5       PS–P[k        S—5       PS˜P[l         PS™P[o        U5       PSšPU1 PSšP[q        5        PSšP[s        5        PS›P[u        5        PSœP[w        XÞUS�Sž9 PSŸP[x         PS P[z         PS¡P[|         PS¢PU0 PS£PU PSšP[        5        PSšPU PSšPU2 PSšPU3 PS¤P[�        5        PS¥P[ƒ        S¦S§9 PS¨P[„        R†                  " [ˆ        5       PS©P[„        R†                  " [Š        5       PSªPU PS«P[„        R†                  " U(5       PS¬PU PS­PU(       a  UOS® PS¯P5      $ s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  snf s  sn)f s  sn*f )°N)ÚdrawerÚpopuprã  r­  r®  r¯  r°  r±  r�  c              3   ó8   #   • U  H  oR                   v •  M     g 7fr   )r@   )r   r@  s     r   r   Úrender_page.<locals>.<genexpr>^,  s   é € Ð!?²,¨Q§&¦&²,ùr‘  z<div class="section-controls">rš   Tr‡  zh<button type="button" id="showallbtn" class="showall" onclick="showTopPicks()" hidden>Top picks</button>)rª  r«  )Úreversec                 ó   • [        U 5      $ r   r0  )r   s    r   Ú<lambda>Úrender_page.<locals>.<lambda>{,  s   € ¤3 q¤6r&   r%  r   r@   Ú
filtermakeÚMakezonMakeChange()rä  rA   ÚfiltermodelÚModelr  Ú
filteryearr{  r"   Ú
filterfuelr~  r<  ÚfiltertransmissionÚTransmission)ÚanyautozAny automatic)rÊ  r  ÚfilterenginelitreszEngine sizer  Úfilterownersr�  r  ÚfilterservicehistoryzService historyrÖ  Úfiltermileager  rý  rþ  rÿ  r  Úfilterreserver¤  r«  r�  rP  Ú	filterbidzMax bidÚmaxbidÚguideÚfilterguidezRetail guideÚ	governingzS<p class="filterscope">Max bid plus the flat spread, so either narrows the same</p>ÚstockÚfilterstockmaxzIn stock, fewer thanzAny number in stockz?Set 1 to see only cars you have none of. Set 3 for two or fewerÚfilterpicksonlyz	Top pickszOnly today's z
 top picksz‹Ranked by room to bid, stock gap, your taste, CompMatch and whether the car is new since yesterday. Off by default, tick for just the picksÚfilterheldonlyz	Held backzShow the held back cars insteadz9Passed your rules but could not be priced. Off by defaultrý   ÚfiltergradezCondition grader  Úfilterbodytypez
Body styler  Úfilterlocationrs  r  ÚfilterendswithinzEnds withinr  Ú	filtercapz	CAP cleanr  ÚfilterdistancezDistance, milesé   z`<div class="filtergroup" id="filtergroup-every"><h3 class="filtergroup-head">Every platform</h3>zù</div><div class="filtergroup" id="filtergroup-some"><h3 class="filtergroup-head">Some platforms</h3><p class="filtergroup-note">Only some auction houses publish these. Filtering on one never hides a car from a platform that does not publish it.</p>c                 ó   • SU  SU  SU S3$ )Nz4<button type="button" class="sortrow" data-sortkey="z" onclick="setSort(&#39;z&#39;)">z!<span class="rt"></span></button>r   )rS   r$   s     r   Ú	_sort_rowÚrender_page.<locals>._sort_row¸,  s)   € ØFÀsÀeð L)Ø),¨¨X°e°WÐ<]ð_ð 	`r&   z<div id="sortlist">ÚopportunityzBest opportunityz¹<p class="moredr-note">Auction4Cars, Dealer Auction and DealerWay order by ending soonest under Best opportunity whichever direction is chosen, their auctions run over several days.</p>ÚnewestzNew since yesterdayr<   zBest match for youzCheapest reserver  zCazana priceÚendszEnding soonestzS</div><p class="moredr-note">Click the active sort again to flip its direction.</p>rÍ  rÎ  rÏ  rÐ  rÑ  zSort and filtersr  )rg  c              3   ó>   >#   • U  H  n[        US T5      v •  M     g7f)rj  N©rp  )r   r–  Ú_make_canons     €r   r   ræ  è,  s   øé € ÐNÊÀAœK¨¨6°;×?Ð?Êùr±  zd
      <section id="heldsec">
        <div class="section-head"><h1 class="section held">Held back, z­</h1></div>
        <p class="lead">These pass the rules but a valuation could not be read with confidence, so they are not priced on a guess.</p>
        <div class="grid">r²  c              3   ó:   #   • U  H  n[        US 5      v •  M     g7f)ÚrejectedNr  r�  s     r   r   ræ  ñ,  s   é € ÐMÂH¸q¤¨A¨z× :Ð :ÂHùs   ‚zZ
      <section>
        <div class="section-head"><h1 class="section rejected">Rejected, zj</h1></div>
        <p class="lead">These broke a rule. Each one shows why.</p>
        <div class="grid">zì<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>BidBrain cockpit</title>
<link rel="icon" href="a6  ">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Oswald:wght@600&display=swap" rel="stylesheet">
<style>
  /* Design overhaul, 2026-08-25 (Mark: "lets go live"), reskinning the real
     cockpit around the design system worked out with a purchased shadcn
     kit's own real tokens (oklch values straight from its "underground"
     theme preset, see the design mockup) rather than the original hf-scope
     palette. Deliberately scoped to colours, radius and typography here,
     the existing structure (sidebar, gauges, insight icons, accordions)
     stays as it is, no fake app shell built in, BidBrain still expects to
     sit inside the real Dealer OS's own shell later via the agreed iframe
     plan, not to grow a second competing sidebar of its own. */
  :root {
    --ink:oklch(0.279 0.0299 260.05); --soft:oklch(0.446 0.0313 257.3);
    --line:oklch(0.929 0.0095 255.53); --bg:z; --card:#fff;
    --price:z	; --pill:z	; --icon:aE  ;
    --no:oklch(0.444 0.177 26.9); --hold:oklch(0.468 0.157 37.04); --good:oklch(0.448 0.119 151.33);
    --g1:oklch(0.448 0.119 151.33); --g2:oklch(0.5315 0.0694 156.19); --g3:oklch(0.468 0.157 37.04);
    --g4:oklch(0.444 0.177 26.9); --g5:oklch(0.36 0.14 26.9);
    --motorway:#0a5fb4; --carwow:#7b3ff2;
    --sidebar-bg:u«  ; --radius-xl:14px;
  }
  /* App shell, 2026-08-25 (Mark: "build the full app shell into BidBrain"):
     a persistent left nav plus a floating, rounded, shadowed content panel,
     matching the real layout the design mockup was built around. The page
     itself still scrolls as one, sticky elements inside the panel (the
     control bar, the filter sidebar) work exactly as before, only the outer
     frame is new, so nothing about the existing scroll, sticky or fixed
     positioned behaviour anywhere else on the page needed to change. */
  .appshell { display:flex; align-items:flex-start; gap:0; min-height:100vh; background:var(--sidebar-bg); padding:8px; }
  /* No overflow:hidden here (Mark 2026-08-25: "make the top nav sticky so
     the listing go under as you scroll") â€” it was clipping .controls'
     own position:sticky, see the identical note on the other three
     pages. .controls gets the top corners directly instead. */
  .insetpanel { flex:1; min-width:0; background:var(--bg); border-radius:var(--radius-xl); }
  @media (max-width:860px) {
    /* align-items:stretch: the column flip otherwise inherits the row
       layout's flex-start, the panel shrinks to content width and the
       whole PAGE scrolls sideways (2026-08-29 hardening). */
    .appshell { flex-direction:column; padding:0; align-items:stretch; }
    .insetpanel { border-radius:0; }
  }
  /* Checked whether the cockpit needed the same 760px search box treatment
     the other three pages have (found missing in review, 2026-08-30), and
     it does not, for a reason specific to this page: .navsearch's own
     max-width:36vw ALWAYS binds before a wrap's own flex-grow could ever
     matter, at every width under 760px (measured live: 135px at 375px,
     252px at 700px, both exactly 36vw, the flex rule changed nothing at
     either). The fuller version, copying purchases' own width:100% on
     .navsearch and .controls-actions, was tried too and made the box
     SMALLER, 135px down to 111px, because the cockpit's own action
     cluster (Run now, Shortlisted, Bell, Settings, four icons; the
     Calendar placeholder that sat among them at the time is gone now, see
     the removal note 2026-08-30, freeing room this box was later widened
     into)
     is heavier than the other pages', leaving less room once forced to
     share one line. Neither shape of this fix helps here, so nothing was
     added: keeping an inert rule around would be dead code for no gain. */
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
    font-family:'Plus Jakarta Sans',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; line-height:1.4;
    font-variant-numeric:tabular-nums; -webkit-font-smoothing:antialiased; }
  .topbar { background:var(--ink); color:#fff; padding:16px 22px; }
  .topbar h1 { margin:0; font-size:19px; letter-spacing:.3px; }
  .topbar .sub { color:#9fb1c0; font-size:13px; margin-top:2px; }
  /* Capped well past a normal desktop so the grid has room to grow past two
     columns on a wide monitor, but not left uncapped, so cards do not thin
     out forever on a genuinely huge screen. Tuned with .grid's card min
     width below to land on 2 columns around a 14 inch laptop's width and 5
     around a 34 inch ultrawide (Mark 2026-08-20: "on my 34 inch ultra wide i
     think this should be responsive to 5 cards", 14 inch stayed as it was). */
  .layout { max-width:3560px; margin:0 auto; padding:20px; }
  /* The filters live in the shared More drawer now (v3.4, Steven's
     approved control frame): the old floating modal's frame CSS went with
     it, only the inner field rules survive, rescoped to the drawer panel. */
  .sidebar-title { font-size:12px; text-transform:uppercase; letter-spacing:0.06em; color:var(--soft);
    margin:0; font-weight:700; }
  .filtergroup { margin-bottom:16px; }
  .filtergroup + .filtergroup { border-top:1px solid var(--line); padding-top:12px; }
  .filtergroup-head { font-size:var(--fr-fs-small); font-weight:800; letter-spacing:.06em;
    text-transform:uppercase; color:var(--soft); margin:0 0 2px; }
  .filtergroup-note { font-size:var(--fr-fs-note); color:var(--soft); line-height:1.5;
    margin:0 0 10px; }
  .filterblock[hidden] { display:none; }
  .filterblock { margin-bottom:var(--fr-gap-field); }
  .filterblock .moredr-field { margin-bottom:0; }
  .filterscope { font-size:var(--fr-fs-small); color:var(--soft); line-height:1.45;
    margin:4px 0 0; }
  /* On a single platform every remaining filter applies to every car shown,
     so the per filter scope notes are noise. */
  .filtergroup.single-plat-scope .filterscope,
  .filtergroup.single-plat-scope .filtergroup-note { display:none; }
  .fchip-scope { font-weight:600; color:var(--soft); font-size:var(--fr-fs-small); }
  /* Saved views: name and save the sidebar's current filters and sort, star
     the ones used often (Mark 2026-08-20). Starred ones also show as quick
     buttons on every section heading. */
  .content { min-width:0; }
uŸ  
  /* The heading row above each grid: the platform's logo (or its name if no
     logo has been captured yet) plus the car count on the left, the Cazana
     price sort on the right (Mark 2026-08-20: "move the sort cazana price
     on the same line as the Auction house title"). Zero top margin on the
     very first one so its row starts level with the sidebar's own first
     row, which is what lines the filter box up with the first row of card
     photos below it. */
  .section-head { display:flex; align-items:center; justify-content:space-between; gap:14px;
    flex-wrap:wrap; border-bottom:2px solid var(--line); padding-bottom:8px; margin:26px 0 6px; min-height:22px; }
  .section { font-size:14px; text-transform:uppercase; letter-spacing:1px; color:var(--soft);
    margin:0; display:flex; align-items:center; gap:10px; flex:none; }
  .section.held { color:var(--hold); } .section.rejected { color:var(--no); }
  .section .setcount { background:#eef1f5; color:var(--ink); border-radius:999px; padding:1px 9px;
    font-size:12px; font-weight:700; vertical-align:middle; }
  .section-logo { height:20px; width:auto; display:block; }
  .section-logo.auction4cars { border-radius:4px; }
  .section-logo.dealerauction { height:24px; }  /* finer typographic detail than the others, a touch bigger to stay legible */
  /* The platform switcher, moved here from the top nav bar (Mark 2026-08-27,
     see _platform_toggle_bar): sits at the top of the card area, where a
     section's own icon already appeared, with the car count right after it
     in the same pill style .setcount already uses, and the sort/filter
     controls (Mark, same day: "move the filter, ending soonest etc on the
     same line as the toggle") on the same row too, now living here once
     rather than repeated on every platform section's own heading. Reuses
     .tabs/.tab/.tab.on unchanged, just sized for a logo image rather than
     only ever text. */
  /* Sticky right below the top nav bar (Mark 2026-08-27: "make the 2nd
     line for the auction houses and filters sticky"), same idea as
     .controls's own position:sticky. top was a hardcoded 61px, measured once
     with the run bar hidden: the run bar (.controls .runbar, flex:0 0 100%)
     grows .controls from 61px to 109px while a run is showing, and a failed
     run leaves it up, so the fixed offset let the top bar cover this whole
     row, nothing in it clickable (found in review, 2026-08-30, proven live:
     controlsH 61 to 109, platform tabs 0 of 6 reachable). --controls-h is
     set from the real measured height by a ResizeObserver in the page's own
     script, 61px kept as the fallback so a script failure degrades to the
     old, merely imprecise behaviour rather than breaking the layout outright.
     A solid background stops cards scrolling up showing through behind it
     once it is stuck. */
  .platform-toggle-row { display:flex; align-items:center; gap:12px; flex-wrap:wrap;
    border-bottom:2px solid var(--line); padding-bottom:12px; margin:0 0 20px;
    position:sticky; top:var(--controls-h, 61px); z-index:3; background:var(--bg); }
  .platform-toggle .tab { display:flex; align-items:center; justify-content:center; min-height:26px; }
  .platform-toggle .tab .section-logo { height:18px; }
  .platform-toggle .tab .section-logo.dealerauction { height:21px; }
  /* Once a specific platform tab is picked (not All), only its own section
     ever shows, so its own small logo+count divider (all that is left in a
     platset's own .section-head now the controls moved up to the toggle
     row) would just repeat the toggle above it, hidden for that case. Under
     All, when more than one platform genuinely has cars, it still shows,
     still useful there to tell the grouped sections apart. */
  .single-plat .platset .section-head { display:none; }
  /* The controls cluster: the merged sort and view dropdown, any starred
     saved views, the Cazana price sort, ending soonest sort and Filters,
     pinned to the right of the platform toggle row. */
  .section-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-left:auto; }
  .lead { color:var(--soft); font-size:13px; margin:4px 0 16px; }
  /* Cards in a row stretch to the same height so the CTAs line up; the card
     foot is pinned to the bottom by .hf-utility-actions below. */
  /* Tuned to land on 3 cards wide on a 14 inch laptop's own real content
     width (Mark 2026-08-25: "3 or 4 cards wide... which ever fits
     better"), the same algebra already used to tune this grid on
     2026-08-20, redone now the left panel and its own collapse eat into
     the available width differently. auto-fill still scales up on a
     wider screen with no breakpoint needed, same as before. */
  /* One more card per row at every size (Steven 2026-09-02: on his
     14 inch MacBook it showed 3 across and should show 4): the per card
     minimum dropped from 360px to 290px, so a 1280px window fits 4, a
     1920px monitor 5, a 34 inch ultrawide 9. */
  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(290px,100%),1fr));
    gap:16px; align-items:stretch; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
    box-shadow:0 1px 3px rgba(20,30,40,.08); display:flex; flex-direction:column; }

  /* Slim cards for the held and rejected sections (unchanged design). */
  .body { padding:14px 16px 16px; display:flex; flex-direction:column; gap:12px; flex:1 1 auto; }
  .card.slim header { display:flex; justify-content:space-between; align-items:baseline; gap:10px; }
  .card.slim header h2 { font-size:16px; margin:0; line-height:1.25; }
  .reg { font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px;
    font-size:12.5px; letter-spacing:.5px; white-space:nowrap; box-shadow:inset 0 0 0 1px rgba(0,0,0,.08); }
  .flags { margin:0; padding-left:18px; font-size:12.5px; color:var(--hold); }
  .flags li { margin:2px 0; }
  .card.slim .body { gap:8px; }

  /* â”€â”€ The auction card (design handoff, hf scope) â”€â”€ */
  .hf-card {
    --hf-ink:oklch(0.279 0.0299 260.05); --hf-ink-2:oklch(0.446 0.0313 257.3); --hf-ink-3:oklch(0.646 0.0225 257.28);
    --hf-line:oklch(0.929 0.0095 255.53);
    --hf-price:z; --hf-pill:z$; --hf-pill-soft:color-mix(in srgb, z 12%, white);
    --hf-icon:u»r  ;
    --hf-good:oklch(0.448 0.119 151.33); --hf-good-soft:color-mix(in srgb, oklch(0.448 0.119 151.33) 12%, white);
    --hf-warn:oklch(0.468 0.157 37.04); --hf-warn-soft:oklch(0.941 0.05 60);
    --hf-rose:oklch(0.444 0.177 26.9); --hf-radius:14px;
    font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif; color:var(--hf-ink); font-size:14px; line-height:1.4;
    -webkit-font-smoothing:antialiased; font-variant-numeric:tabular-nums;
    background:#fff; border:1px solid var(--hf-line); border-radius:var(--hf-radius);
    box-shadow:0 1px 2px oklch(0.279 0.0299 260.05 / 0.04), 0 8px 24px -10px oklch(0.279 0.0299 260.05 / 0.14);
    display:flex; flex-direction:column; position:relative;
    transition:box-shadow .15s ease, transform .15s ease; overflow:visible; }
  .hf-card:hover { box-shadow:0 1px 2px oklch(0.279 0.0299 260.05 / 0.05), 0 14px 32px -12px oklch(0.279 0.0299 260.05 / 0.2); transform:translateY(-1px); }
  /* overflow:hidden used to sit directly on .hf-card, clipping the photo's
     own corners, but that also clipped anything else in the card that
     tried to escape it, which is exactly what broke the Details panel
     below (2026-08-30: it could never draw past the card's own bottom
     edge). The clip now lives on this inner wrapper alone, radius
     inherited from the card so the rounded corners look identical.
     .hf-card itself needs an explicit overflow:visible here, not just
     the absence of overflow:hidden: this element also carries the plain
     .card class (shared with the held/rejected slim cards), which
     declares overflow:hidden of its own, same specificity, so leaving
     this property undeclared would have silently left that value in
     effect rather than clearing it (caught live, before ever shipping:
     the panel was still being clipped after this exact change until
     this line was added). */
  .hf-card-inner { border-radius:inherit; overflow:hidden; display:flex; flex-direction:column; flex:1 1 auto; }
  .hf-photo { position:relative; aspect-ratio:4/3; background:#e8eae4; }
  .hf-photo > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }
  .hf-nophoto { position:absolute; inset:0; display:grid; place-items:center;
    color:var(--hf-ink-3); font-size:13px; font-weight:600; }
  .hf-photo > img.hf-gallery-open { cursor:pointer; }
  /* The rest of the gallery sits behind the main photo (Mark 2026-08-21),
     just a quiet count badge bottom right (bottom left is the reg plate,
     top left the stock badges, top right the star), no other card change. */
  .hf-photocount { position:absolute; right:12px; bottom:12px; background:rgba(24,36,32,.72);
    color:#fff; font-size:11.5px; font-weight:700; padding:3px 9px; border-radius:999px;
    pointer-events:none; z-index:2; }
  /* The bottom of the photo darkens into a scrim (2026-08-26, Card Density
     Mockups: the title now sits directly on the photo, needs to read over
     any colour car underneath it), then the title and the reg plate share
     one row on top of it, title on the left truncating with an ellipsis if
     it runs long, the plate fixed width on the right, never overlapping. */
  .hf-photo-scrim { position:absolute; inset:auto 0 0 0; height:58%; z-index:1; pointer-events:none;
    background:linear-gradient(to top, oklch(0.18 0.02 255 / .82), oklch(0.18 0.02 255 / 0) 100%); }
  .hf-photofoot { position:absolute; left:12px; right:12px; bottom:11px; z-index:2;
    display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
  .hf-phototitle { margin:0; flex:1 1 auto; min-width:0; color:#fff; font-size:14.5px; font-weight:700;
    letter-spacing:-0.01em; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
    text-shadow:0 1px 3px oklch(0.18 0.02 255 / .5); }
  .hf-star { position:absolute; top:10px; right:10px; z-index:2;
    width:34px; height:34px; border-radius:999px; border:none; cursor:pointer;
    background:rgba(255,255,255,0.92); color:var(--hf-ink-2);
    display:grid; place-items:center; box-shadow:0 1px 4px rgba(24,36,32,0.18); }
  .hf-star:hover { color:var(--hf-warn); }
  .hf-star.on { color:var(--hf-warn); }
  .hf-star.on svg path { fill:currentColor; }
  /* Registration shown as a yellow UK number plate, with a copy button.
     flex:none so it never shrinks to make room for the title, which
     ellipsis-truncates instead, see .hf-phototitle. */
  .hf-reg { flex:none; display:inline-flex; align-items:center; gap:6px;
    background:#f6d416; color:#111; border:1.5px solid #111; border-radius:6px;
    padding:3px 7px 3px 9px; white-space:nowrap; box-shadow:0 1px 4px rgba(24,36,32,0.3); }
  .hf-reg-num { font-family:'Oswald','Arial Narrow',Arial,sans-serif; font-weight:600;
    font-size:15px; letter-spacing:0.07em; line-height:1; }
  .hf-reg-copy { display:inline-flex; align-items:center; justify-content:center;
    background:none; border:none; padding:0; margin:0; cursor:pointer; color:#111; opacity:0.55; }
  .hf-reg-copy:hover { opacity:1; }
  .hf-reg-copy svg { pointer-events:none; }
  .hf-reg.noplate { background:#e6e6e6; color:#555; border-color:#bbb; }
  .hf-body { padding:20px; flex:1 1 auto; display:flex; flex-direction:column; }
  /* Gap, stock and seen before badges overlay the photo, top left, stacked. */
  .hf-badges { position:absolute; left:12px; top:12px; z-index:2;
    display:flex; flex-direction:column; align-items:flex-start; gap:6px; }
  .hf-gap { background:var(--hf-pill-soft); color:var(--hf-pill);
    font-size:11.5px; font-weight:700; padding:4px 10px 5px; border-radius:999px; white-space:nowrap;
    box-shadow:0 1px 4px rgba(24,36,32,0.15); }
  .hf-gap.seen { background:var(--hf-warn-soft); color:var(--hf-warn); }
  .hf-gap.taste { background:#efe7fb; color:#6d3fc4; }
  /* Top picks rank and New today (Steven 2026-09-02, Step 3): the rank is
     outlined ink so it reads as a position, not a status; New today is
     solid ink so it is the one badge that always stands out. */
  .hf-gap.rank { background:#fff; color:var(--hf-ink); border:1.5px solid var(--hf-ink);
    padding:3px 9px 4px; font-weight:800; }
  .hf-gap.new { background:var(--hf-ink); color:#fff; }
  /* â”€â”€ The car record drawer (Steven 2026-09-02, Step 5 of the review):
     the same 940px slide in panel the purchases page has, on a live
     auction car. Record left, CompMatch / History / Flags right, star,
     hide and the link out in the footer. Every size is the purchases
     drawer's own (.drawer, .dhead, .csplit, .sgr, .sgmoney, .rtabs,
     .vrail), prefixed cd- here so nothing collides with the card's own
     classes on this page. â”€â”€ */
  .cd-scrim { position:fixed; inset:0; background:rgba(20,25,30,.32); z-index:60; }
  .cd-scrim[hidden] { display:none; }
  .cd-drawer { position:fixed; top:0; right:0; bottom:0; width:940px; max-width:96vw; min-width:340px;
    background:#fff; z-index:61; box-shadow:-14px 0 44px rgba(0,0,0,.2); display:flex; flex-direction:column;
    transform:translateX(103%); transition:transform .2s ease; font-size:14px; }
  .cd-drawer.open { transform:none; }
  @media (prefers-reduced-motion: reduce){ .cd-drawer { transition:none; } }
  .cd-head { padding:16px 20px 10px; flex:none; }
  .cd-top { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
  .cd-reg { font-weight:700; background:#f4d000; color:#13202b; padding:2px 9px; border-radius:5px; font-size:12.5px;
    letter-spacing:.5px; white-space:nowrap; box-shadow:inset 0 0 0 1px rgba(0,0,0,.08); }
  .cd-plat { font-size:11px; font-weight:700; color:#fff; padding:2px 8px; border-radius:999px; background:#55606b; }
  .cd-plat.motorway { background:var(--motorway); } .cd-plat.carwow { background:var(--carwow); }
  .cd-plat.auction4cars { background:#e8541e; } .cd-plat.dealerauction { background:#1d2b4f; } .cd-plat.dealerway { background:#0e7c86; }
  .cd-top .hf-gap { box-shadow:none; }
  .cd-actions { display:flex; gap:4px; margin-left:auto; }
  .cd-btn { width:30px; height:30px; border:none; background:none; border-radius:8px; color:var(--soft); cursor:pointer;
    display:flex; align-items:center; justify-content:center; }
  .cd-btn:hover { background:#eceee9; color:var(--ink); }
  .cd-btn.on { color:var(--hf-warn, oklch(0.468 0.157 37.04)); } .cd-btn.on svg path { fill:currentColor; }
  .cd-name { margin:9px 0 0; font-size:17px; line-height:1.35; font-weight:800; letter-spacing:-.01em; }
  .cd-sub { margin:4px 0 0; font-size:12.5px; color:var(--soft); }
  .cd-money { display:flex; gap:22px; align-items:flex-end; padding:10px 20px 14px; border-bottom:1px solid var(--line); flex:none; flex-wrap:wrap; }
  .cd-money .k { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase; color:oklch(0.646 0.0225 257.28); white-space:nowrap; }
  .cd-money .v { font-size:19px; font-weight:800; font-variant-numeric:tabular-nums; }
  .cd-money .v.price { color:var(--price); } .cd-money .v.room { color:var(--pill); } .cd-money .v.under { color:var(--no); }
  .cd-money .v.dim { font-size:14.5px; font-weight:600; color:var(--soft); } .cd-money .v.cm { color:#6d3fc4; }
  .cd-money .cm-block { margin-left:auto; text-align:right; }
  .cd-split { display:flex; align-items:stretch; flex:1; min-height:0; }
  .cd-left { flex:1.3; min-width:0; overflow-y:auto; }
  .cd-right { flex:1; min-width:0; border-left:1px solid var(--line); background:#fbfaf7; display:flex; flex-direction:column; overflow-y:auto; }
  .cd-seclab { display:flex; align-items:center; gap:9px; padding:16px 20px 6px; }
  .cd-seclab b { font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; color:#8a938c; white-space:nowrap; }
  .cd-seclab .ln2 { flex:1; height:1px; background:var(--line); }
  .cd-photos { display:flex; gap:8px; padding:14px 20px 4px; overflow-x:auto; }
  .cd-photos img { width:120px; height:90px; object-fit:cover; border-radius:8px; display:block; flex:none; cursor:pointer; background:#e8eae4; }
  .cd-photos .more { width:120px; height:90px; border-radius:8px; background:#e8eae4; display:grid; place-items:center;
    font-size:12.5px; font-weight:700; color:var(--soft); flex:none; cursor:pointer; }
  .cd-photos .none { font-size:12.5px; color:var(--soft); padding:20px 0; }
  .cd-grid { padding:2px 20px 10px; }
  .cd-row { display:grid; grid-template-columns:minmax(126px,168px) 1fr; gap:0 14px; padding:6px 7px; margin:0 -7px;
    border-radius:7px; font-size:12.5px; align-items:center; }
  .cd-row > span:first-child { color:var(--soft); } .cd-row b { font-weight:600; overflow-wrap:anywhere; }
  .cd-receipt { display:flex; flex-direction:column; background:#fafbf8; border:1px solid #eceee9; border-radius:10px;
    padding:9px 13px; margin:6px 0 5px; max-width:400px; }
  .cd-receipt div { display:flex; justify-content:space-between; align-items:baseline; gap:14px; padding:3px 0; }
  .cd-receipt .l { font-size:11.5px; color:var(--soft); font-weight:600; } .cd-receipt .v { font-size:13px; font-weight:700; font-variant-numeric:tabular-nums; }
  .cd-receipt .v.win { color:var(--good); } .cd-receipt .v.dim { color:var(--soft); font-weight:600; }
  .cd-receipt div.tot { border-top:1px solid #e6e9e3; margin-top:4px; padding-top:7px; }
  .cd-receipt div.tot .l { font-size:10px; text-transform:uppercase; letter-spacing:.05em; font-weight:800; }
  .cd-receipt div.tot .v { font-size:15.5px; font-weight:800; }
  .cd-receipt a { color:var(--icon); text-decoration:none; font-size:11.5px; font-weight:700; }
  .cd-tabs { display:flex; gap:3px; padding:8px 14px 0; border-bottom:1px solid var(--line); flex:none; }
  .cd-tab { border:none; background:none; font:inherit; font-size:12.5px; font-weight:700; color:var(--soft); padding:8px 11px 9px;
    border-bottom:2.5px solid transparent; margin-bottom:-1px; cursor:pointer; display:flex; align-items:center; gap:6px; }
  .cd-tab.on { color:var(--icon); border-bottom-color:var(--icon); }
  .cd-tab .n { background:#eceee9; color:var(--soft); border-radius:999px; font-size:10.5px; font-weight:800; padding:0 6px; }
  .cd-pane[hidden] { display:none; }
  .cd-nextup { margin:14px 16px 12px; border-radius:12px; padding:13px 16px; border:1px solid #dccdf5; background:#f7f2fd; }
  .cd-nextup.quiet { border-color:var(--line); background:#fafbf8; }
  .cd-nulab { font-size:9.5px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; color:#6d3fc4; }
  .cd-nextup.quiet .cd-nulab { color:var(--soft); }
  .cd-nuwhat { font-size:15.5px; font-weight:800; letter-spacing:-.01em; margin:5px 0 3px; color:#6d3fc4; }
  .cd-nuwhy { font-size:12.5px; color:var(--soft); line-height:1.5; } .cd-nuwhy b { color:var(--ink); }
  .cd-rail { padding:2px 18px 12px; }
  .cd-vrow { position:relative; padding:9px 0 9px 30px; }
  .cd-vrow::before { content:""; position:absolute; left:8px; top:30px; bottom:-4px; width:2px; background:var(--line); }
  .cd-vrow:last-child::before { display:none; }
  .cd-vdot { position:absolute; left:0; top:11px; width:18px; height:18px; border-radius:50%; background:#fff; border:2px solid var(--line); }
  .cd-vdot.hit { background:#6d3fc4; border-color:#6d3fc4; } .cd-vdot.seen { background:var(--hf-warn, #b4581a); border-color:var(--hf-warn, #b4581a); }
  .cd-vname { font-weight:700; font-size:13px; } .cd-vsum { font-size:11.5px; color:var(--soft); margin-top:1px; }
  .cd-vprice { float:right; font-weight:800; font-size:13px; font-variant-numeric:tabular-nums; }
  .cd-empty { font-size:12.5px; color:var(--soft); padding:14px 18px; line-height:1.5; }
  .cd-flag { display:flex; gap:9px; align-items:flex-start; font-size:12.5px; color:var(--soft); padding:6px 18px; line-height:1.45; }
  .cd-flag b { color:var(--ink); font-weight:700; }
  .cd-flagic { width:10px; height:10px; border-radius:999px; flex:none; margin-top:5px; background:#c9cec7; }
  .cd-flagic.red { background:var(--no); } .cd-flagic.amber { background:var(--hf-warn, #b4581a); }
  .cd-flagic.green { background:var(--good); } .cd-flagic.info { background:#94a3b8; }
  .cd-foot { display:flex; gap:8px; padding:12px 20px; border-top:1px solid var(--line); background:#fff; flex:none; }
  .cd-cta { flex:1; border:none; background:var(--ink); color:#fff; font:inherit; font-size:13px; font-weight:700; border-radius:10px;
    padding:10px; display:flex; align-items:center; justify-content:center; gap:8px; text-decoration:none; }
  .cd-cta.disabled { background:#c4ccc6; }
  .cd-ghost { border:1px solid var(--line); background:#fff; color:var(--ink); font:inherit; font-size:13px; font-weight:700;
    border-radius:10px; padding:10px 14px; display:flex; align-items:center; gap:7px; cursor:pointer; }
  .cd-ghost.on { color:var(--hf-warn, #b4581a); } .cd-ghost.on svg path { fill:currentColor; }
  .cd-ghost.hide { color:var(--no); }
  @media (max-width:860px) { .cd-split { flex-direction:column; } .cd-right { border-left:none; border-top:1px solid var(--line); } }
  .showall { background:none; border:none; font:inherit; font-size:12.5px; font-weight:700;
    color:var(--soft); cursor:pointer; padding:0 4px; }
  .showall:hover { color:var(--ink); text-decoration:underline; }
  .showall[hidden] { display:none; }
  .hf-gap.ok { background:rgba(255,255,255,0.94); color:var(--hf-ink-2); }
  .hf-gap.many { background:var(--hf-warn-soft); color:var(--hf-warn); }
  /* The Auction4Cars countdown. Amber with time to go, red once it is under an
     hour so a closing lot stands out, grey once it has actually ended. */
  .hf-gap.ends { background:var(--hf-warn-soft); color:var(--hf-warn); }
  .hf-gap.ends.urgent { background:#fbeae7; color:var(--no, #b03a2e); }
  .hf-gap.ends.over { background:rgba(255,255,255,0.94); color:var(--hf-ink-2); }
  /* No margin-top: chips are now the first thing in .hf-body (the title
     moved onto the photo itself, 2026-08-26), .hf-body's own 20px padding
     already gives it room, an extra margin here on top of that left more
     dead space than the Decision Card mockup asked for. */
  .hf-chips { display:flex; flex-wrap:wrap; gap:7px; }
  .hf-chip { background:#f3f5f1; color:var(--hf-ink-2); font-size:12px; font-weight:600;
    padding:3px 9px 4px; border-radius:999px; white-space:nowrap; font-variant-numeric:tabular-nums; }
  /* Engine chip, a touch emphasised since the engine matters for the bans. */
  .hf-chip-engine { background:#eef2ee; color:var(--hf-ink); font-weight:700; }
  /* Automatic / semi auto: a bold solid pill so an auto box stands out at a
     glance against the manual cars, which carry no gearbox pill. */
  .hf-chip-auto { background:#2f6fed; color:#fff; font-weight:800; letter-spacing:0.01em; }
  /* Condition grade, colour coded: 1 green, 2 yellow, 3 amber, 4 and 5 red. */
  .hf-chip-grade { font-weight:700; }
  .hf-chip-grade.g1 { background:#e1f3e6; color:#1c7a43; }
  .hf-chip-grade.g2 { background:#fbf0c4; color:#876b00; }
  .hf-chip-grade.g3 { background:#fce6cf; color:#9a5300; }
  .hf-chip-grade.g4, .hf-chip-grade.g5 { background:#fbe3e0; color:#b3261e; }
  /* Price gauge (2026-08-26, Card Density Mockups: one line, Max bid
     promoted to the big figure). Reserve, Max bid, Retail sit in one
     fixed 3 column row above the track, so there is no collision
     logic to worry about the way a floating label above its own dot
     needed: whichever column, they never overlap. */
  .hf-gauge { margin:20px 2px 4px; }
  /* A narrow card (under 330px, four across on a 1280px window since the
     grid minimum dropped to 290px, 2026-09-02): the three price cells no
     longer fit on one line, so Max bid takes its own line above Reserve
     and Retail rather than colliding with them. A container query on the
     card's own width, not the window's, so it holds at every grid size. */
  /* The whole card scales with its column (Steven 2026-09-02: "you have
     made the cards fit but kept all the elements on the cards the same
     size so now everything is too squashed"): paintCardScale measures the
     grid's real column width and sets --card-zoom so a card is laid out
     as if it were 360px wide and drawn smaller, every chip, figure and
     button shrinking in step. Above 360px it stays 1. */
  .grid .card { zoom:var(--card-zoom, 1); }
  .hf-gauge-row { display:flex; align-items:flex-end; justify-content:space-between; gap:10px; margin-bottom:8px; }
  .hf-glab { display:flex; flex-direction:column; gap:1px; line-height:1.15; min-width:0; }
  .hf-glab.mid { align-items:center; flex:1 1 auto; }
  .hf-glab.end { align-items:flex-end; text-align:right; flex:none; }
  .hf-klabel { font-size:10px; font-weight:700; letter-spacing:0.09em;
    text-transform:uppercase; color:var(--hf-ink-3); white-space:nowrap; }
  .hf-gnum { font-size:14.5px; font-weight:700; font-variant-numeric:tabular-nums; }
  .hf-gnum.dim { color:var(--hf-ink-2); font-weight:600; }
  .hf-gnum.strong { color:var(--hf-ink); font-weight:800; font-size:19px; }
  .hf-retail { font-size:19px; font-weight:800; letter-spacing:-0.02em;
    color:var(--hf-price); font-variant-numeric:tabular-nums; }
  .hf-track { position:relative; height:8px; border-radius:999px; background:#eceee9; }
  .hf-band { position:absolute; inset:0 auto 0 0; border-radius:999px;
    background:linear-gradient(90deg, color-mix(in oklch, var(--hf-price) 38%, white), var(--hf-price)); }
  .hf-dot { position:absolute; top:50%; transform:translate(-50%,-50%);
    width:12px; height:12px; border-radius:999px; background:#fff; border:2.5px solid #cfd5cc; }
  .hf-dot.filled { border-color:var(--hf-price); width:14px; height:14px;
    box-shadow:0 0 0 3px color-mix(in oklch, var(--hf-price) 18%, transparent); }
  .hf-dot.end { border-color:var(--hf-price); background:var(--hf-price); }
  .hf-roompill { font-size:11px; font-weight:700; color:var(--hf-pill);
    background:var(--hf-pill-soft); border-radius:999px; padding:3px 10px 4px; margin-top:2px;
    font-variant-numeric:tabular-nums; white-space:nowrap;
    display:inline-flex; align-items:center; justify-content:center; }
  .hf-roompill.under { background:#f7e9e6; color:var(--hf-rose); }
  /* Suggested bid to win (Mark 2026-08-24, "the bid should be displayed
     on the gauge like the max bid to keep ui looking clean", 2026-08-26,
     "CompMatch's purple dot stays on the track"): its own dot right on
     the SAME main track as the real max bid one, purple to match the
     existing "Your kind of car" badge, the same "learned from your own
     data" visual language, never confused with the real green max bid
     marker. Its own figure sits in a small line under the track, always
     shown (density mode, which could once hide detail, is gone since
     2026-08-30). */
  .hf-dot.suggest { border-color:#6d3fc4; background:#fff; cursor:help; }
  /* min-height is .hf-gnum.suggest's own line box (12.5px font on the
     card's 1.4 line-height), so the empty placeholder version of this
     line is exactly as tall as a filled one and the flags below start
     level on every card. Keep the two in step if that size changes. */
  .hf-suggest-line { display:flex; align-items:baseline; gap:6px; margin-top:9px;
    min-height:17.5px; }
  /* Only the filled line carries a tooltip, so only it gets the help
     cursor; the empty placeholder is not hoverable. */
  .hf-suggest-line[title] { cursor:help; }
  .hf-gnum.suggest { color:#6d3fc4; font-size:12.5px; }
  .hf-suggest-n { font-size:11px; color:var(--hf-ink-3); }
  /* Details: one small pill in the actions row (see .hf-utility-actions
     below), replacing both the old icon row (a mute preview of exactly
     what the panel it opens already says in full, once every icon is a
     click away) and the separate Valuations toggle, folded into the
     same panel as one "what we know" (2026-08-30). No card ever varies
     in height by how much a given car has to say any more: the alignment
     bug this whole redesign started from (a short car's own card
     stretched to match a long neighbour, the leftover space landing as
     one blank void between the flags and the actions row) cannot recur,
     because nothing below the gauge is allowed to grow the card at all
     now, Details draws OVER the card instead. */
  .hf-flag-ic { width:22px; height:22px; border-radius:999px; display:grid; place-items:center; }
  .hf-flag-full.red .hf-flag-ic { background:#f7e9e6; color:var(--hf-rose); }
  .hf-flag-full.amber .hf-flag-ic { background:var(--hf-warn-soft); color:var(--hf-warn); }
  .hf-flag-full.green .hf-flag-ic { background:var(--hf-good-soft); color:var(--hf-good); }
  .hf-flag-full.info .hf-flag-ic { background:#edf0f3; color:#64748b; }
  .hf-flag-full { display:flex; gap:9px; align-items:flex-start; font-size:12.5px; font-weight:500;
    color:var(--hf-ink-2); line-height:1.4; min-width:0; padding:5px 0; }
  .hf-flag-full .hf-flag-ic { flex:none; margin-top:-1px; }
  .hf-flag-full b { font-weight:700; color:var(--hf-ink); }
  .hf-pop-empty { font-size:12.5px; color:var(--hf-ink-3); padding:8px 0; }
  .hf-details-btn { flex:none; display:flex; align-items:center; gap:5px; height:32px; padding:0 11px;
    background:#fff; border:1px solid var(--hf-line); border-radius:999px; color:var(--hf-ink);
    font:inherit; font-size:12px; font-weight:700; cursor:pointer; white-space:nowrap;
    transition:border-color .15s, color .15s, background .15s; }
  .hf-details-btn:hover { border-color:var(--hf-icon); color:var(--hf-icon); background:color-mix(in srgb, var(--hf-icon) 6%, white); }
  .hf-details-btn svg { width:13px; height:13px; }
  .hf-details-btn .hf-details-n { background:#f3f5f1; color:var(--hf-ink-2); font-size:10px; font-weight:800;
    padding:1px 6px; border-radius:999px; transition:background .15s, color .15s; }
  .hf-details-btn:hover .hf-details-n { background:var(--hf-icon); color:#fff; }
  .hf-vals-grid { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
  /* A real button under the value chips, not just the inline Cazana label
     (Mark 2026-08-25: "add this button for the deep link under the
     valuation detail"). flex-basis:100% forces it onto its own row below
     the chips inside the same wrapping flex grid, rather than sitting
     inline as one more chip. */
  .hf-cazana-btn { flex-basis:100%; margin-top:4px; display:inline-flex; align-items:center;
    justify-content:center; gap:7px; padding:9px 14px; border:1px solid var(--hf-line);
    border-radius:9px; background:#fff; color:var(--hf-ink-2); font-size:12.5px; font-weight:700;
    text-decoration:none; }
  .hf-cazana-btn:hover { border-color:var(--hf-icon); color:var(--hf-icon); }
  .hf-val { background:#fff; border:1px solid var(--hf-line); color:var(--hf-ink);
    font-size:12.5px; font-weight:700; border-radius:10px;
    padding:6px 11px 7px; display:inline-flex; align-items:baseline; gap:6px;
    font-variant-numeric:tabular-nums; white-space:nowrap; }
  .hf-val em { font-style:normal; font-size:9.5px; font-weight:800;
    letter-spacing:0.09em; text-transform:uppercase; color:var(--hf-ink-3); }
  /* The Cazana label deep links to its own real lookup page (Mark
     2026-08-25), same size and weight as the plain em label above so it
     does not stand out until hovered, then turns the brand accent with
     an underline to show it is clickable. */
  .hf-val a.hf-val-link { font-size:9.5px; font-weight:800; letter-spacing:0.09em;
    text-transform:uppercase; color:var(--hf-ink-3); text-decoration:none; cursor:pointer; }
  .hf-val a.hf-val-link:hover { color:var(--hf-icon); text-decoration:underline; }
  /* The floating Details panel itself. Lives OUTSIDE .hf-card-inner
     (which clips to the card's rounded corners for the photo), so it
     is never cut off by that clip regardless of how tall it gets, and
     its own `bottom` is set from JS (positionDetailsPop) measured live
     off .hf-utility-actions on every open, not a guessed offset.
     Opens ABOVE its own trigger (Steven 2026-08-30: "show the part that
     comes up when you click details... so it overlays the actual rest
     of the card rather than below"), drawn straight over the gauge and
     chips rather than pushing past the card's own bottom edge, with the
     arrow and the scale-in's own transform-origin both anchored to the
     bottom left corner so it still visibly grows out of the button that
     opened it. */
  .hf-detailpop { position:absolute; left:20px; right:20px; z-index:10; background:#fff;
    border:1px solid var(--hf-line); border-radius:12px;
    box-shadow:0 12px 32px -8px rgba(20,30,40,.28), 0 2px 8px rgba(20,30,40,.1);
    padding:16px 16px 14px; opacity:0; visibility:hidden; pointer-events:none;
    transform:translateY(6px) scale(.98); transform-origin:bottom left;
    transition:opacity .16s ease, transform .16s ease, visibility 0s linear .16s; }
  .hf-detailpop.open { opacity:1; visibility:visible; pointer-events:auto; transform:translateY(0) scale(1);
    transition:opacity .16s ease, transform .16s ease, visibility 0s; }
  .hf-pop-arrow { position:absolute; bottom:-7px; left:14px; width:14px; height:14px; background:#fff;
    border-right:1px solid var(--hf-line); border-bottom:1px solid var(--hf-line); transform:rotate(45deg); }
  .hf-pop-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
  .hf-pop-head span { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--hf-ink-3); }
  .hf-pop-close { width:22px; height:22px; border-radius:50%; border:none; background:#f3f5f1; color:var(--hf-ink-2);
    display:grid; place-items:center; cursor:pointer; flex:none; transition:background .12s, color .12s; }
  .hf-pop-close:hover { background:#e4e7ec; color:var(--hf-ink); }
  .hf-pop-close svg { width:11px; height:11px; }
  .hf-pop-scroll { max-height:52vh; overflow-y:auto; margin:0 -4px; padding:0 4px; }
  .hf-pop-div { height:1px; background:#eef0f2; margin:10px 0; }
  /* The single action row: Details, the platform CTA and "...", left to
     right, one line, one visual weight, bottom anchored inside a
     row-stretched card so it lands at the same height on every card in
     a row (2026-08-30: this used to also anchor the flags, which is why
     it was called .hf-foot; now that nothing above it varies by more
     than a chip wrapping to a second line, the same bottom anchor works
     as originally intended, without the dead space that variable height
     content used to force into it). */
  .hf-utility-actions { display:flex; align-items:center; flex-wrap:nowrap; gap:10px;
    margin-top:auto; padding-top:12px; border-top:1px dashed var(--hf-line); }
  .hf-cta { flex:1 1 auto; border:none; cursor:pointer; text-align:center; text-decoration:none;
    background:var(--hf-ink); color:#fff; font:inherit; font-size:12.5px; font-weight:700;
    padding:8px 12px; border-radius:999px;
    white-space:nowrap; transition:background 0.15s;
    display:inline-flex; align-items:center; justify-content:center; gap:7px; }
  .hf-cta:hover { background:#2a3a34; }
  /* Platform wordmark in white on the dark pill, scaled down to fit the
     compact utility row rather than the old full width button. */
  .platlogo { display:inline-block; background-repeat:no-repeat; background-position:center; background-size:contain; }
  .platlogo.motorway { width:62px; height:13px; background-image:url("zI"); }
  .platlogo.carwow { width:74px; height:9px; background-image:url("zc"); }
  .platlogo.auction4cars { width:59px; height:15px; border-radius:3px; background-image:url("zQ"); }
  .platlogo.dealerauction { width:54px; height:13px; background-image:url("zM"); }
  .platlogo.dealerway { width:83px; height:13px; background-image:url("uE9  "); }
  .hf-cta.disabled { background:#c4ccc6; cursor:default; font-size:12px; padding:8px 12px; }
  /* The "..." overflow menu, same open/close-on-outside-click shape as
     the row's own kebab menus elsewhere in this project. */
  .hf-more { position:relative; flex:none; }
  .hf-more-btn { display:flex; align-items:center; justify-content:center; width:32px; height:32px;
    border:1px solid var(--hf-line); border-radius:999px; background:#fff; color:var(--hf-ink-2); cursor:pointer; }
  .hf-more-btn:hover { color:var(--hf-ink); background:#f6f7f5; }
  /* Opens UPWARD, not down (2026-08-27, found live: the "..." button
     sits right at the bottom of the card, and .hf-card-inner (the photo
     and body's own shared clip, .hf-card itself since 2026-08-30) needs
     overflow:hidden for the photo's own rounded top corners, so a menu
     opening downward from there was being clipped clean off, genuinely
     invisible however correctly it opened in the DOM). Opening upward
     keeps it inside the card's own box, which is always tall enough to
     hold one small menu, so it is never clipped. */
  .hf-more-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:5; min-width:150px;
    background:#fff; border:1px solid var(--hf-line); border-radius:10px;
    box-shadow:0 10px 26px rgba(20,30,40,.15); padding:5px; }
  .hf-more-item { display:block; width:100%; text-align:left; background:none; border:none; cursor:pointer;
    font:inherit; font-size:12.5px; font-weight:600; color:var(--hf-ink-2); padding:8px 10px; border-radius:7px; }
  .hf-more-item:hover { background:#f6f7f5; color:var(--hf-ink); }
  .hidebox { margin-top:10px; }
  .reasons { margin:0; padding-left:18px; font-size:13px; color:var(--no); }
  .card.held .reasons { color:var(--hold); }
  .empty { color:var(--soft); }
  footer { color:var(--soft); font-size:12px; text-align:center; padding:28px 0; }
  /* Back to top button, appears bottom right once the page is scrolled. */
  .totop { position:fixed; right:18px; bottom:18px; z-index:20; width:46px; height:46px; border-radius:50%;
    border:none; background:var(--ink); color:#fff; cursor:pointer; display:flex; align-items:center; justify-content:center;
    box-shadow:0 4px 14px oklch(0.279 0.0299 260.05 / .3); opacity:0; visibility:hidden; transform:translateY(8px);
    transition:opacity .2s ease, transform .2s ease, visibility .2s; }
  .totop.show { opacity:1; visibility:visible; transform:translateY(0); }
  .totop:hover { background:#000; }

  /* Control bar: search, platform, sort, show only my bids. */
  .controls { position:sticky; top:0; z-index:4; background:#fff; border-bottom:1px solid var(--line);
    padding:11px 22px; display:flex; flex-wrap:wrap; gap:10px; align-items:center;
    box-shadow:0 2px 8px oklch(0.279 0.0299 260.05 / .05); border-radius:var(--radius-xl) var(--radius-xl) 0 0; }
  .controls select { font-size:14px; padding:8px 10px; border:1px solid var(--line);
    border-radius:10px; background:#fff; }
  /* Stylised BidBrain wordmark, top left inline with the tabs. */
  .controls .brand { font-size:18px; font-weight:800; letter-spacing:-0.02em; color:var(--hf-ink, #182420);
    margin-right:4px; white-space:nowrap; text-decoration:none; cursor:pointer; }
  .controls .brand span { color:var(--icon, #1f8a5b); }
  .controls label { font-size:14px; font-weight:600; display:flex; align-items:center; gap:7px; cursor:pointer; }
  .tabs { display:inline-flex; background:var(--sidebar-bg); border-radius:11px; padding:3px; gap:2px; }
  .tab { border:none; background:none; font-size:13px; font-weight:700; color:var(--soft); cursor:pointer;
    padding:7px 14px; border-radius:8px; }
  .tab.on { background:#fff; color:var(--ink); box-shadow:0 1px 3px oklch(0.279 0.0299 260.05 / .15); }
  /* The Live Auctions/Purchases/Lost page switcher (Mark 2026-08-27: "copy
     the toggle for the auction house and make toggle for Live Autions,
     Purchases, Lost"), same .tabs/.tab shape, real links not JS state. */
  .pageswitcher .tab { display:flex; align-items:center; gap:6px; text-decoration:none; }
  .pageswitcher .tab svg { width:15px; height:15px; flex:none; }
  /* Same wrapper shape as Sync now/Save settings' own action cluster on
     every other page (Mark 2026-08-25: "make sure the 'run now' button is
     in the same place as 'save' and sync on other pages"), so Run now and
     the icon row sit in one right aligned group rather than trailing
     loose after the tabs; .runbtn keeps its own margin-left:auto too,
     harmless, a browser only ever spends one auto margin per row. */
  /* The dealership logo at the left of the nav bar, where the left rail
     used to carry it (2026-08-31). */
  .navlogo { flex:none; display:flex; align-items:center; height:34px; margin-right:4px; }
  .navlogo img { height:26px; width:auto; max-width:150px; object-fit:contain; display:block; }
  .controls-actions { display:flex; align-items:center; gap:4px; margin-left:auto; }
  /* Moved out of the Filters panel onto the nav bar itself, next to Run
     now (the same refresh icon shape as purchases/didn't win's own sync
     button, see the comment on it below) (Mark 2026-08-26: "move the
     search from all the filters to next to the refresh on the nav bar"),
     same #search id and oninput as before so applyView()'s own filtering
     is unchanged, just reachable without opening Filters first. */
  .navsearch-wrap { position:relative; display:inline-flex; align-items:center; }
  .navsearch-wrap svg { position:absolute; left:11px; color:var(--soft); pointer-events:none; }
  /* -webkit-appearance:none strips Safari's own native search-field chrome
     on type="search", which reserves internal space for its own icon
     invisible to the CSS box model (padding/width look fine, the text
     still clips) â€” this box already draws its own magnifying glass
     (.navsearch-wrap svg), so the native one was never wanted anyway. */
  .navsearch { height:34px; padding:0 12px 0 32px; border:1px solid var(--line); border-radius:9px;
    font:inherit; font-size:13px; background:var(--sidebar-bg); color:var(--ink); width:230px; max-width:36vw;
    -webkit-appearance:none; appearance:none; }
  .navsearch::placeholder { color:var(--soft); }
  /* Icon only, no outline (Mark 2026-08-25: "starting from the top right
     user icon, notification bell, star, and a refresh icon all just icons
     no outlines to match the left panel look"), replacing the old
     coloured pill Run now button. Same id and onclick; setRunning() only
     ever disables it now â€” the sync light owns its colour, glyph and
     title on every page (2026-09-01 sweep). */
  .controls .runbtn { position:relative; margin-left:auto; display:flex; align-items:center; justify-content:center;
    width:34px; height:34px; border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .controls .runbtn:hover { background:var(--bg); color:var(--ink); }
  .controls .runbtn:disabled { color:var(--line); cursor:default; }
  .controls .runbtn:disabled:hover { background:none; }
  @keyframes navspin { to { transform:rotate(360deg); } }
  .controls .settingslink { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; color:var(--soft, #64757f); text-decoration:none; background:none; cursor:pointer;
    margin-left:4px; }
  .controls .settingslink:hover { background:var(--bg); color:var(--hf-ink, #182420); }
  .controls .settingslink.on { color:#d39e00; }
  .controls .settingslink.on svg { fill:currentColor; }
  /* Notification bell: a dropdown of every health notice the daily run wrote,
     replacing what used to be a permanent stack of banners across the top. */
  .bellwrap { position:relative; margin-left:4px; }
  /* Signed in user, just the round avatar now, no name text (Mark
     2026-08-25: "all just icons no outlines"), the name is still on the
     title attribute as a tooltip. Same spacing as purchases and didn't
     win's own icon row (Mark 2026-08-26: "the icons on purchases and
     didnt win looks to be spaced great make the live auctions match
     this"), margin-left:4px throughout rather than 0/0/2. */
  .navuser { display:flex; align-items:center; margin-left:4px; }
  .navuser-av { width:32px; height:32px; border-radius:50%; background:var(--line); flex:none;
    display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:var(--ink); cursor:default; }
  .bellbtn { position:relative; display:flex; align-items:center; justify-content:center; width:34px; height:34px;
    border:none; border-radius:9px; background:none; color:var(--soft); cursor:pointer; padding:0; }
  .bellbtn:hover { background:var(--bg); color:var(--ink); }
  /* Red when there is something to actually see, not just the small badge
     count (Mark 2026-08-25: "make the bell go red when notifications"). */
  /* box-sizing:border-box shrinks the badge's own content area to 12px
     (16px minus the 2px top+bottom border), but a plain line-height:16px
     still laid the digit out at the full 16px, so it overflowed past the
     visible circle, reading as cut off at the bottom (Mark 2026-08-26:
     "fix the ios red flags they seen to cut off at the bottom"). Flex
     centring is immune to that box-sizing/line-height mismatch, same
     mechanism every other icon button here already centres its own SVG
     with. */
  /* Missing on this page until 2026-08-30: with no :empty rule, a 0
     shortlist badge (an empty span at first render, then a literal "0"
     once applyView runs) painted as a bare red dot, then a red bubble
     reading 0, the one page of the five that got this wrong, the other
     four already hide an empty badge outright. */
  .bellbadge:empty { display:none; }
  .bellbadge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; padding:0 3px;
    border-radius:8px; background:var(--no); color:#fff; font-size:10px; font-weight:800;
    box-sizing:border-box; display:flex; align-items:center; justify-content:center; border:2px solid #fff; }
  .bellpanel { position:absolute; top:calc(100% + 8px); right:0; width:320px; max-height:360px; overflow-y:auto;
    background:#fff; border:1px solid var(--line); border-radius:14px; padding:8px;
    box-shadow:0 10px 28px oklch(0.279 0.0299 260.05 / .16); z-index:10; }
  .bellpanel[hidden] { display:none; }
  .bellnote { display:block; margin:0; padding:9px 10px; border-radius:8px; font-size:13px; line-height:1.4;
    color:var(--soft); text-decoration:none; }
  .bellnote + .bellnote, .bellnote + a.bellnote, a.bellnote + .bellnote { margin-top:2px; }
  .bellnote.alert { background:oklch(0.98 0.016 73.68); color:oklch(0.468 0.157 37.04); font-weight:600; }
  .bellnote-link { cursor:pointer; }
  .bellnote-link:hover { filter:brightness(.96); text-decoration:underline; }
  .bellempty { margin:0; padding:14px 10px; font-size:13px; color:var(--soft); text-align:center; }

  /* The listing's full photo gallery, hidden behind the main card photo
     and opened here on click (Mark 2026-08-21). One shared overlay reused
     by every card, openGallery() fills it in for whichever car was
     clicked. */
  .gallery-overlay { position:fixed; inset:0; background:rgba(10,16,14,.92); z-index:70;
    display:flex; align-items:center; justify-content:center; flex-direction:column; }
  .gallery-overlay[hidden] { display:none; }
  .gallery-img { max-width:min(92vw, 1100px); max-height:70vh; object-fit:contain;
    border-radius:8px; box-shadow:0 20px 60px rgba(0,0,0,.5); }
  .gallery-close { position:absolute; top:18px; right:22px; background:none; border:none;
    color:#fff; cursor:pointer; opacity:.85; display:flex; align-items:center; justify-content:center; }
  .gallery-close:hover { opacity:1; }
  .gallery-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.12);
    color:#fff; border:none; width:48px; height:48px; border-radius:999px; font-size:26px;
    cursor:pointer; display:flex; align-items:center; justify-content:center; }
  .gallery-nav:hover { background:rgba(255,255,255,.22); }
  .gallery-prev { left:24px; }
  .gallery-next { right:24px; }
  .gallery-count { color:#fff; font-size:13px; font-weight:700; opacity:.8; margin-top:14px; }
  .gallery-thumbs { display:flex; gap:8px; overflow-x:auto; max-width:92vw; padding:14px 6px 4px; }
  .gallery-thumb { width:56px; height:40px; object-fit:cover; border-radius:5px; cursor:pointer;
    opacity:.55; border:2px solid transparent; flex:none; }
  .gallery-thumb:hover { opacity:.85; }
  .gallery-thumb.on { opacity:1; border-color:#fff; }

  /* Quiet warning banner, for example the Glass's login countdown. */
  .notice { max-width:3520px; width:calc(100% - 40px); margin:14px auto 0; padding:11px 16px;
    box-sizing:border-box; background:#fdf3e0; border:1px solid #ecd9a8; border-left:4px solid var(--g3);
    border-radius:10px; font-size:13.5px; font-weight:600; color:#7a5a00; }

  /* Shortlisted count, now a standalone toggle in the top nav bar, left of
     Run now (Mark 2026-08-20, was a chip in a filter row above the layout). */
  /* View on platform button on held back cards. */
  .viewbtn { display:inline-flex; align-items:center; gap:6px; margin-top:10px; text-decoration:none;
    font-size:13px; font-weight:700; color:var(--ink); background:#fff; border:1px solid var(--line);
    border-radius:9px; padding:8px 13px; }
  .viewbtn:hover { background:#f1f3f2; border-color:#c2c9d0; }

  /* Hide: a quiet link under the bid button that opens a tidy reason panel. */
  .hide-row { text-align:center; margin-top:2px; }
  .hidelink { background:none; border:none; color:var(--soft); font-size:12.5px; cursor:pointer; padding:4px; }
  .hidelink:hover { color:var(--no); text-decoration:underline; }
  .hidebox { display:flex; flex-direction:column; gap:8px; margin-top:4px; background:#fbf3f1;
    border:1px solid #ecd9d5; border-radius:10px; padding:10px; }
  .hidebox[hidden] { display:none; }
  .hidebox input { border:1px solid var(--line); border-radius:9px; padding:9px; font-size:14px; }
  .hidebtns { display:flex; gap:8px; }
  .hidebox .confirm { flex:1; background:var(--no); color:#fff; border:none; border-radius:9px; padding:9px 12px; font-weight:700; cursor:pointer; }
  .hidebox .cancel { flex:1; background:#eef1f5; border:none; border-radius:9px; padding:9px 12px; cursor:pointer; }
  .card.gone { display:none; }

</style>
</head>
<body>
<div class="appshell" id="appshell">
<div class="insetpanel">
  <div class="controls">
    z
    rg  zF
    <div class="controls-actions">
      <div class="navsearch-wrap">aâ  <input id="search" class="navsearch" type="search" placeholder="Search reg, make or model" oninput="applyView()"></div>
      <button id="runbtn" class="runbtn" onclick="openRunModal()" aria-label="Run now" title="Run now"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg></button>
      z
      z
    </div>
    z
  </div>
  rÁ   )rÌ   ap  
  <div id="cdrawer-scrim" class="cd-scrim" hidden onclick="closeCarDrawer()"></div>
  <div id="cdrawer" class="cd-drawer" role="dialog" aria-label="Car record"></div>
  <div id="gallery-overlay" class="gallery-overlay" hidden onclick="if(event.target===this)closeGallery()">
    <button type="button" class="gallery-close" onclick="closeGallery()" aria-label="Close">z{</button>
    <button type="button" class="gallery-nav gallery-prev" onclick="galleryStep(-1)" aria-label="Previous photo">z¬</button>
    <img id="gallery-img" class="gallery-img" alt="">
    <button type="button" class="gallery-nav gallery-next" onclick="galleryStep(1)" aria-label="Next photo">z‰</button>
    <div id="gallery-count" class="gallery-count"></div>
    <div id="gallery-thumbs" class="gallery-thumbs"></div>
  </div>
  z8
  <div class="layout">
    <div class="content">
      a  
    </div>
  </div>
  <footer>BidBrain Phase 2. Read live from Motorway, Carwow and Auction4Cars. No platform APIs, no bids placed.</footer>
</div>
</div>
  <div id="flash"></div>
  <button id="totop" class="totop" aria-label="Back to top" title="Back to top" onclick="window.scrollTo({top:0,behavior:'smooth'})"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg></button>
  <script>Ú
FrÈ  zÙ
    // The one source of truth for which platforms publish what, straight
    // from render._FILTER_SCOPE so the drawer's own scope notes and the
    // matching rule can never drift apart.
    const FILTER_SCOPE = z;
    const LENIENT_FILTERS = a)(  ;
    // Criteria a saved view can carry that these boxes cannot show
    // (2026-09-06, Dealer OS's phone and drawer): several makes, models,
    // service histories, grades or body styles at once as a comma list, and
    // the year, engine size and owners ranges. This screen's own boxes are
    // unchanged (Steven's standing rule): a view with one of those applies
    // through bbCarry instead, the box reads Any, and the list, the count
    // and the view chip all honour the carried value. Touching that box by
    // hand drops its carried value, so what you see is what filters.
    let bbCarry = {};
    const CARRY_LISTS = ['make','model','servicehistory','grade','bodytype'];
    const CARRY_RANGES = {year: ['yearmin','yearmax'], enginelitres: ['enginelitresmin','enginelitresmax'], owners: ['ownersmin','ownersmax']};
    function carryInto(k){
      for(const f of CARRY_LISTS){
        if(k[f]) delete bbCarry[f];
        else if(bbCarry[f]) k[f] = bbCarry[f];
      }
      for(const f in CARRY_RANGES){
        const [lo, hi] = CARRY_RANGES[f];
        if(k[f]){ delete bbCarry[lo]; delete bbCarry[hi]; }
        else {
          if(bbCarry[lo]) k[lo] = bbCarry[lo];
          if(bbCarry[hi]) k[hi] = bbCarry[hi];
        }
      }
      return k;
    }
    function carryFrom(c){
      bbCarry = {};
      for(const f of CARRY_LISTS){
        if(c[f] && String(c[f]).includes(',')) bbCarry[f] = String(c[f]);
      }
      for(const f in CARRY_RANGES){
        for(const key of CARRY_RANGES[f]) if(c[key]) bbCarry[key] = String(c[key]);
      }
    }

    // The filter engine, criteria driven (2026-08-30). It used to read the
    // 25 filter boxes straight off the DOM, which meant it could only ever
    // answer one question: what matches what is typed in right now. Taking
    // the values as an argument instead lets the same logic answer it for
    // ANY set of values, which is what live counts on a saved view need.
    // crit uses captureCriteria's own key names so a stored view can be
    // passed in unchanged.
    //
    // Each card's own dataset is read once and cached on the element: the
    // cards are static for the life of the page, and repeating attribute
    // reads for every card times every view is the slow part.
    // NOT cardData: that name is already taken further down by the shortlist
    // star's own payload builder, which returns typed keys (max_bid,
    // cap_clean) rather than the raw dataset. Declaring a second cardData
    // silently lost the hoist to it, so every lookup here returned undefined
    // for governing, modelfamily and endsat, and the matcher quietly stopped
    // agreeing with the old one (caught 2026-08-30 by the differential test).
    function cardDataset(c){
      if(!c.__bb) c.__bb = Object.assign({}, c.dataset);
      return c.__bb;
    }
    function critInRange(val, min, max){
      if(!min && !max) return true;
      const n = parseFloat(val);
      if(isNaN(n)) return false;  // no reading for this car, and a range is set
      if(min && n < parseFloat(min)) return false;
      if(max && n > parseFloat(max)) return false;
      return true;
    }
    function critScoped(d, field){
      const plats = FILTER_SCOPE[field];
      return !plats || plats.includes(d.platform);
    }
    // Exact match: skip when this platform never publishes the fact,
    // otherwise the card must match. A blank on a LENIENT field (read from
    // free text or an optional detail page, so blank means "not stated"
    // rather than "no") passes instead of being hidden.
    function critIs(d, field, want, val){
      if(!want || !critScoped(d, field)) return true;
      if(!val) return LENIENT_FILTERS.includes(field);
      // A comma separated want is "any of these" (Dealer OS's several at
      // once for Make, Model, Service history, Condition grade and Body
      // style, 2026-09-06); one value is one value, exactly as before.
      const have = String(val).toLowerCase();
      return String(want).split(',').map(w => w.trim().toLowerCase()).filter(Boolean).some(w => w === have);
    }
    // A range never matches a car with no figure at all: a car with no price
    // must never sneak into a budget. Same platform exemption. The lenient
    // fields (engine size, owners) let a blank through on a range too, the
    // same as they do on an exact match: not stated is not a reason to hide.
    function critRange(d, field, val, min, max){
      if((!min && !max) || !critScoped(d, field)) return true;
      if((val === undefined || val === null || val === '') && LENIENT_FILTERS.includes(field)) return true;
      return critInRange(val, min, max);
    }
    // Once an Auction4Cars or Dealer Auction lot's auction ends, drop the card
    // from view rather than leaving it greyed out. A card with no end time
    // (Motorway, Carwow) is unaffected. Used by cardMatches AND by the counts,
    // so it sits out here rather than inside applyView.
    function notEnded(c){
      const e = cardDataset(c).endsat;
      return !e || (new Date(e) - new Date()) > 0;
    }
    function cardMatches(c, k){
      const d = cardDataset(c);
      if(k.search){
        const q = String(k.search).toLowerCase().trim();
        if(q && !(d.search || '').includes(q)) return false;
      }
      // Ends within: a car with no auction_ends_at at all (Motorway, Carwow,
      // both closing at the fixed 3:30pm daily sale) is not something this
      // filter has an opinion on, so it always passes; only a car that
      // actually carries an end time is held to the window, measured as real
      // elapsed time from now, the same rolling clock the countdown badge
      // uses, not a calendar day cut off.
      if(k.endswithin && d.endsat
         && !((new Date(d.endsat) - new Date()) <= parseFloat(k.endswithin) * 3600000)) return false;
      if(!notEnded(c)) return false;
      // Held back is a filter now, off by default, and it is the one filter
      // that decides WHICH set of cars you are looking at rather than
      // narrowing one set: on shows only the held cars, off shows only the
      // priced ones (2026-08-30).
      if(k.heldonly === '1'){ if(!d.held) return false; }
      else if(d.held) return false;
      // Top picks (Steven 2026-09-02, Step 3): on by default, only the 25
      // ranked cars. A held car never carries a rank, so with picks on the
      // held filter above already decides that case.
      if(k.picksonly === '1' && !c.dataset.pickrank) return false;
      // "In stock, fewer than N": strictly under N, so 1 means none in stock.
      // A car with no stock reading at all is never let through a set ceiling,
      // the same rule every numeric range here already follows.
      if(k.stockmax){
        const sc = parseFloat(d.stockcount);
        if(isNaN(sc) || !(sc < parseFloat(k.stockmax))) return false;
      }
      return critIs(d, 'make', k.make, d.make)
        && critIs(d, 'model', k.model, d.modelfamily)
        && critIs(d, 'year', k.year, d.year)
        && critRange(d, 'year', d.year, k.yearmin, k.yearmax)
        && critIs(d, 'fuel', k.fuel, d.fuel)
        && (k.transmission === 'anyauto'
              ? (critScoped(d, 'transmission') ? d.auto === '1' : true)
              : critIs(d, 'transmission', k.transmission, d.transmission))
        && critIs(d, 'enginelitres', k.enginelitres, d.enginelitres)
        && critRange(d, 'enginelitres', d.enginelitres, k.enginelitresmin, k.enginelitresmax)
        && critIs(d, 'owners', k.owners, d.owners)
        && critRange(d, 'owners', d.owners, k.ownersmin, k.ownersmax)
        && critIs(d, 'servicehistory', k.servicehistory, d.servicehistory)
        && critIs(d, 'grade', k.grade, d.grade)
        && critIs(d, 'bodytype', k.bodytype, d.bodytype)
        && critIs(d, 'location', k.location, d.location)
        && critRange(d, 'mileage', d.mileage, k.mileagemin, k.mileagemax)
        && critRange(d, 'reserve', d.reserve, k.reservemin, k.reservemax)
        && critRange(d, 'bid', d.maxbid, k.bidmin, k.bidmax)
        && critRange(d, 'guide', d.governing, k.guidemin, k.guidemax)
        && critRange(d, 'cap', d.cap, k.capmin, k.capmax)
        && critRange(d, 'distance', d.distance, k.distancemin, k.distancemax);
    }
    // The filter boxes as a criteria object, the same shape captureCriteria
    // returns, so the live list and a saved view go through one code path.
    function readFilterInputs(){
      return carryInto({
        make: bbVal('filtermake'), model: bbVal('filtermodel'), year: bbVal('filteryear'),
        transmission: bbVal('filtertransmission'), bodytype: bbVal('filterbodytype'),
        endswithin: bbVal('filterendswithin'), fuel: bbVal('filterfuel'),
        enginelitres: bbVal('filterenginelitres'), owners: bbVal('filterowners'),
        servicehistory: bbVal('filterservicehistory'), grade: bbVal('filtergrade'),
        location: bbVal('filterlocation'),
        mileagemin: bbVal('filtermileagemin'), mileagemax: bbVal('filtermileagemax'),
        reservemin: bbVal('filterreservemin'), reservemax: bbVal('filterreservemax'),
        bidmin: bbVal('filterbidmin'), bidmax: bbVal('filterbidmax'),
        guidemin: bbVal('filterguidemin'), guidemax: bbVal('filterguidemax'),
        capmin: bbVal('filtercapmin'), capmax: bbVal('filtercapmax'),
        distancemin: bbVal('filterdistancemin'), distancemax: bbVal('filterdistancemax'),
        stockmax: bbVal('filterstockmax'),
        heldonly: bbChecked('filterheldonly') ? '1' : '',
        // '0' not '' when off: '' reads as "use the default" everywhere a
        // view is compared (viewVal), and the default here is ON, so off
        // has to be its own real value or "All cars" could never be told
        // apart from "Top picks".
        picksonly: bbChecked('filterpicksonly') ? '1' : '0',
        // The search is part of the criteria like anything else. It used to
        // be applied separately by applyView while cardMatches knew nothing
        // about it, so a saved view carrying a search, or the chip's own
        // count while one was typed, promised a number that ignored it
        // (2026-08-30: predicted 4 where the page showed 0).
        search: bbVal('search'),
      });
    }
    const SALE_DATE = "z";
    const MODEL_FAMILIES = u¢ê  ;
    function updateModelOptions(){
      const make = bbVal('filtermake');
      const sel = bbEl('filtermodel');
      if(!sel) return;
      const cur = sel.value;
      const fams = [...new Set(MODEL_FAMILIES.filter(p => !make || p[0] === make).map(p => p[1]))].sort();
      sel.innerHTML = '<option value="">Any</option>' +
        fams.map(f => `<option value="${f}">${f}</option>`).join('');
      sel.value = fams.includes(cur) ? cur : '';
    }
    // Where this page's own numbers live, for the range sliders' bounds.
    function rangeValues(ds){
      return [...document.querySelectorAll('.card')]
        .map(c => parseFloat(cardDataset(c)[ds]))
        .filter(n => !isNaN(n));
    }
    function onMakeChange(){ updateModelOptions(); applyView(); }
    updateModelOptions();
    // Paint the sliders once on load. Until this runs they carry the browser's
    // own defaults (0 to 100, thumb at 50) rather than this page's real bounds,
    // so a first drag would clamp to 100 and write nonsense. Caught live: the
    // Reserve readout said "Up to Â£50" on a fresh page.
    paintRanges();
    function copyReg(ev, reg){
      ev.preventDefault(); ev.stopPropagation();
      (navigator.clipboard ? navigator.clipboard.writeText(reg) : Promise.reject())
        .then(()=>flash('Copied '+reg)).catch(()=>flash('Could not copy, select it by hand.', false));
    }
    async function post(url, body){
      const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
      if(!r.ok) throw new Error('save failed');
      return r.json();
    }
    function cardData(el){ const c=el.closest('.card'); return {
      reg:c.dataset.reg, make:c.dataset.make, model:c.dataset.model, name:c.dataset.name,
      max_bid: c.dataset.maxbid?+c.dataset.maxbid:null, reserve: c.dataset.reserve?+c.dataset.reserve:null,
      year: c.dataset.year?+c.dataset.year:null, mileage: c.dataset.mileage?+c.dataset.mileage:null,
      grade: c.dataset.grade?+c.dataset.grade:null, fuel: c.dataset.fuel||null,
      body_type: c.dataset.bodytype||null, cap_clean: c.dataset.cap?+c.dataset.cap:null,
      platform: c.dataset.platform||null, listing_url: c.dataset.listingurl||null }; }
    async function toggleBid(btn){
      const on = btn.getAttribute('aria-pressed') !== 'true';
      try {
        await post('/api/bid', {...cardData(btn), sale_date:SALE_DATE, on});
        btn.setAttribute('aria-pressed', on); btn.classList.toggle('on', on);
        btn.title = on ? 'On your shortlist' : 'Add to my shortlist';
        // Re-run the view so the Shortlisted count and (if that filter is on)
        // the grid update as cars are starred and unstarred.
        applyView();
      } catch(e){ flash('Could not save. Open the page at its web address, not from a file.', false); }
    }
    function openHide(btn){ btn.closest('.card').querySelector('.hidebox').hidden = false; }
    function cancelHide(btn){ btn.closest('.hidebox').hidden = true; }
    // The floating Details panel (2026-08-30, replacing the old
    // expand-in-place flags list plus the separate Valuations toggle,
    // and with them the Comfortable/Compact switch, which only ever
    // existed to bulk open or close those two, nothing left for it to
    // control once nothing expands in place any more). Opens ABOVE its
    // own trigger, overlaying the gauge and chips rather than pushing
    // the actions row down, so the card's own height never changes,
    // whatever this one car has to say: the alignment bug this whole
    // redesign started from cannot recur, there is nothing left that
    // varies in height. `bottom` is measured live off the actions row
    // on every open, never a guessed offset, so it still lands right
    // whatever this card's own chip count did to the rows above it.
    // Only one open across the whole page at a time.
    function closeAllDetails(){
      document.querySelectorAll('.hf-detailpop.open').forEach(p => p.classList.remove('open'));
    }
    function positionDetailsPop(card){
      const pop = card.querySelector('.hf-detailpop');
      const actions = card.querySelector('.hf-utility-actions');
      if(!pop || !actions) return;
      const cardRect = card.getBoundingClientRect();
      const actionsRect = actions.getBoundingClientRect();
      pop.style.bottom = Math.round(cardRect.bottom - actionsRect.top + 10) + 'px';
    }
    function toggleDetails(btn){
      const card = btn.closest('.hf-card');
      const pop = card.querySelector('.hf-detailpop');
      if(!pop) return;
      const opening = !pop.classList.contains('open');
      closeAllDetails();
      if(opening){ positionDetailsPop(card); pop.classList.add('open'); }
    }
    document.addEventListener('click', function(ev){
      if(ev.target.closest('.hf-detailpop') || ev.target.closest('.hf-details-btn')) return;
      closeAllDetails();
    });
    window.addEventListener('resize', function(){
      const open = document.querySelector('.hf-detailpop.open');
      if(open) positionDetailsPop(open.closest('.hf-card'));
    });
    // ---- the car record drawer (Steven 2026-09-02, Step 5 of the review) ----
    // Built from the JSON the card itself carries (data-drawer, see
    // render._drawer_data), so it needs no fetch and always matches the card.
    let drawerCard = null, drawerTab = 'compmatch';
    // Details on a card opens whichever this dealership chose in Settings
    // > Look (Steven 2026-09-02: "mark might prefer it one way and me
    // another"): the drawer, or the pop-up it replaced, which is still
    // rendered in every card for exactly this.
    function openDetails(btn){
      if(DETAILS_STYLE === 'popup') toggleDetails(btn);
      else openCarDrawer(btn);
    }
    function cdEsc(s){ const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
    function cdGbp(n){ return (n === null || n === undefined || isNaN(n)) ? 'â€“' : 'Â£' + Math.round(+n).toLocaleString('en-GB'); }
    function cdDate(iso){
      if(!iso) return '';
      const d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
      if(isNaN(d)) return iso;
      return d.toLocaleDateString('en-GB', {day:'numeric', month:'short'}) + (d.getFullYear() !== new Date().getFullYear() ? ' ' + d.getFullYear() : '');
    }
    function openCarDrawer(btn){
      const card = btn.closest('.hf-card');
      if(!card) return;
      let d;
      try { d = JSON.parse(card.dataset.drawer || '{}'); } catch(e){ console.warn('BidBrain: bad drawer data', e); return; }
      drawerCard = card;
      closeAllDetails();
      paintCarDrawer(d);
      const s = bbEl('cdrawer-scrim'); if(s) s.hidden = false;
      const dr = bbEl('cdrawer'); if(dr) dr.classList.add('open');
    }
    function closeCarDrawer(){
      const dr = bbEl('cdrawer'); if(dr) dr.classList.remove('open');
      const s = bbEl('cdrawer-scrim'); if(s) s.hidden = true;
      drawerCard = null;
    }
    function carDrawerOpen(){ const dr = bbEl('cdrawer'); return !!(dr && dr.classList.contains('open')); }
    function drawerTabTo(t){
      drawerTab = t;
      document.querySelectorAll('#cdrawer .cd-tab').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
      document.querySelectorAll('#cdrawer .cd-pane').forEach(p => { p.hidden = p.dataset.pane !== t; });
    }
    function drawerStarOn(){ return !!(drawerCard && drawerCard.querySelector('.starbtn.on')); }
    function paintDrawerStar(){
      const on = drawerStarOn();
      document.querySelectorAll('#cdrawer .cd-starbtn').forEach(b => {
        b.classList.toggle('on', on);
        const t = b.querySelector('.cd-starlabel'); if(t) t.textContent = on ? 'Starred' : 'Star this car';
        b.title = on ? 'On your shortlist' : 'Add to my shortlist';
      });
    }
    async function drawerStar(){
      if(!drawerCard) return;
      const sb = drawerCard.querySelector('.starbtn');
      if(!sb) return;
      await toggleBid(sb);
      paintDrawerStar();
    }
    function drawerHide(){
      if(!drawerCard) return;
      const card = drawerCard;
      closeCarDrawer();
      const box = card.querySelector('.hidebox');
      if(box){ box.hidden = false; card.scrollIntoView({block:'center'}); const i = box.querySelector('.hidereason'); if(i) i.focus(); }
    }
    function drawerPhoto(i){
      if(!drawerCard) return;
      const img = drawerCard.querySelector('.hf-photo img.hf-gallery-open');
      if(img){ openGallery(img); if(typeof galleryTo === 'function') galleryTo(i); }
    }
    function paintCarDrawer(d){
      const p = d.pricing || null;
      const reserve = d.reserve;
      const room = (p && p.max_bid !== null && reserve !== null) ? p.max_bid - reserve : null;
      const cm = d.compmatch;
      const h = d.history || {};
      const flags = d.flags || [];
      // Header.
      let badges = '';
      if(d.pick_rank) badges += '<span class="hf-gap rank">#' + d.pick_rank + ' pick</span>';
      if(d.is_new) badges += '<span class="hf-gap new">New today</span>';
      if(d.taste) badges += '<span class="hf-gap taste">Your kind of car</span>';
      const subBits = [];
      if(d.ends_at){ subBits.push('Auction ends ' + cdDate(d.ends_at)); }
      else subBits.push('Sale closes today at 3:30pm');
      if(d.stock_count !== null && d.stock_count !== undefined) subBits.push(d.stock_count + ' of these in stock');
      const prevN = Object.values(h.previous || {}).reduce((a,b) => a + b, 0);
      if(prevN) subBits.push('seen ' + prevN + ' time' + (prevN === 1 ? '' : 's') + ' before');
      const head =
        '<div class="cd-head"><div class="cd-top">' +
        '<span class="cd-reg">' + cdEsc(d.pretty_reg || d.reg || 'no plate') + '</span>' +
        '<span class="cd-plat ' + cdEsc(d.platform || '') + '">' + cdEsc(d.source || '') + '</span>' + badges +
        '<span class="cd-actions">' +
        '<button type="button" class="cd-btn cd-starbtn" onclick="drawerStar()" aria-label="Star">' + STAR_SVG_JS + '</button>' +
        '<button type="button" class="cd-btn" onclick="closeCarDrawer()" aria-label="Close">' +
        '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg></button>' +
        '</span></div>' +
        '<h2 class="cd-name">' + cdEsc(d.name) + (d.derivative ? ' ' + cdEsc(d.derivative) : '') + '</h2>' +
        '<p class="cd-sub">' + cdEsc(subBits.join(' Â· ')) + '</p></div>';
      // The money strip.
      let money = '<div class="cd-money">' +
        '<div><div class="k">' + cdEsc(d.reserve_label || 'Reserve') + '</div><div class="v dim">' + cdGbp(reserve) + '</div></div>';
      if(p){
        money += '<div><div class="k">Max bid</div><div class="v">' + cdGbp(p.max_bid) + '</div></div>';
        if(room !== null) money += '<div><div class="k">Room</div><div class="v ' + (room >= 0 ? 'room' : 'under') + '">' + (room >= 0 ? '+' : 'âˆ’') + cdGbp(Math.abs(room)) + '</div></div>';
        money += '<div><div class="k">Retails for Â· ' + cdEsc(p.governing_source || '') + '</div><div class="v price">' + cdGbp(p.governing_value) + '</div></div>';
      } else {
        money += '<div><div class="k">Max bid</div><div class="v dim">not priced</div></div>';
      }
      if(cm) money += '<div class="cm-block"><div class="k">Likely to win at</div><div class="v cm">' + cdGbp(cm.suggested) + '</div></div>';
      money += '</div>';
      // Left: photos, the car, the receipt.
      const photos = d.photos || [];
      let ph = '<div class="cd-photos">';
      if(!photos.length) ph += '<span class="none">No photo yet</span>';
      photos.slice(0, 3).forEach((u, i) => { ph += '<img src="' + cdEsc(u) + '" alt="" loading="lazy" onclick="drawerPhoto(' + i + ')">'; });
      if(photos.length > 3) ph += '<div class="more" onclick="drawerPhoto(3)">+' + (photos.length - 3) + ' photos</div>';
      ph += '</div>';
      const row = (k, v) => (v === null || v === undefined || v === '') ? '' : '<div class="cd-row"><span>' + k + '</span><b>' + cdEsc(v) + '</b></div>';
      const sh = (d.service_history || '').toLowerCase();
      const shText = sh === 'full' ? 'Full' : sh === 'partial' ? 'Partial' : (sh === 'none' || sh === 'no') ? 'None' : (sh ? d.service_history : 'Not confirmed');
      let owned = '';
      if(d.keeper_start){
        const ks = new Date(d.keeper_start + 'T00:00:00');
        if(!isNaN(ks)){
          const months = Math.max(0, (new Date().getFullYear() - ks.getFullYear()) * 12 + (new Date().getMonth() - ks.getMonth()));
          owned = ks.toLocaleDateString('en-GB', {month:'short', year:'numeric'}) + ' (' + months + ' month' + (months === 1 ? '' : 's') + ')';
        }
      }
      const loc = [d.location, (d.distance_miles !== null && d.distance_miles !== undefined) ? Math.round(d.distance_miles) + ' miles' : ''].filter(Boolean).join(' Â· ');
      const facts = '<div class="cd-seclab"><b>Car</b><span class="ln2"></span></div><div class="cd-grid">' +
        row('Year', d.year) + row('Mileage', d.mileage !== null && d.mileage !== undefined ? (+d.mileage).toLocaleString('en-GB') + ' mi' : null) +
        row('Engine', d.engine) + row('Gearbox', d.gearbox) + row('Body', d.body_type) +
        row('Previous owners', d.owners) + row('Condition grade', d.grade !== null && d.grade !== undefined ? 'Grade ' + d.grade : null) +
        row('Service history', shText) + row('Owned since', owned) + row('Location', loc) +
        (d.vat_qualifying ? row('VAT', 'VAT qualifying') : '') +
        (d.selling_vrm ? row('Sells on plate', d.selling_vrm) : '') + '</div>';
      let receipt = '';
      if(p){
        const up = (p.calc_cazana && p.cazana_retail) ? Math.round((p.calc_cazana / p.cazana_retail - 1) * 100) :
                   (p.calc_glass && p.glass_retail) ? Math.round((p.calc_glass / p.glass_retail - 1) * 100) : null;
        receipt = '<div class="cd-seclab"><b>How the max bid was worked out</b><span class="ln2"></span></div><div class="cd-grid"><div class="cd-receipt">';
        if(p.cazana_retail !== null && p.cazana_retail !== undefined){
          receipt += '<div><span class="l">Cazana retail' + (d.cazana_url ? ' Â· <a href="' + cdEsc(d.cazana_url) + '" target="_blank" rel="noopener">open</a>' : '') + '</span><span class="v">' + cdGbp(p.cazana_retail) + '</span></div>';
          receipt += '<div><span class="l">Plus ' + (up !== null ? up + '% ' : '') + 'uplift</span><span class="v">' + cdGbp(p.calc_cazana) + '</span></div>';
        } else {
          receipt += '<div><span class="l">Cazana retail</span><span class="v dim">' + (d.glass_checked ? 'no match' : 'not read') + '</span></div>';
        }
        if(p.glass_retail !== null && p.glass_retail !== undefined){
          receipt += '<div><span class="l">Glassâ€™s retail</span><span class="v">' + cdGbp(p.glass_retail) + '</span></div>';
          receipt += '<div><span class="l">Plus ' + (up !== null ? up + '% ' : '') + 'uplift</span><span class="v">' + cdGbp(p.calc_glass) + '</span></div>';
        } else {
          receipt += '<div><span class="l">Glassâ€™s retail</span><span class="v dim">' + (d.glass_checked ? 'no match' : 'not run') + '</span></div>';
        }
        receipt += '<div class="tot"><span class="l">Retails for Â· ' + cdEsc(p.governing_source || '') + '</span><span class="v">' + cdGbp(p.governing_value) + '</span></div>';
        receipt += '<div><span class="l">Less margin, prep, fees</span><span class="v">âˆ’' + cdGbp(p.spread) + '</span></div>';
        receipt += '<div class="tot"><span class="l">Max bid</span><span class="v win">' + cdGbp(p.max_bid) + '</span></div>';
        receipt += '<div><span class="l">' + cdEsc(d.reserve_label || 'Reserve') + '</span><span class="v">' + cdGbp(reserve) + '</span></div>';
        if(d.cap_clean !== null && d.cap_clean !== undefined) receipt += '<div><span class="l">CAP Clean</span><span class="v">' + cdGbp(d.cap_clean) + '</span></div>';
        receipt += '</div></div>';
      }
      // Right: CompMatch, History, Flags.
      let cmPane = '';
      if(cm){
        const how = [];
        how.push('The 70th percentile of what it actually took to win <b>' + cm.n + ' similar car' + (cm.n === 1 ? '' : 's') + '</b> in the last 18 months');
        if(cm.like_for_like) how.push('matched on mileage, year, gearbox and engine size');
        else how.push('the same make and model, not enough close matches to narrow further');
        if(cm.ratio_based) how.push('scaled to this carâ€™s own Cazana retail');
        if(cm.capped) how.push('capped at this carâ€™s own retail value');
        cmPane = '<div class="cd-nextup"><div class="cd-nulab">Likely to win at</div><div class="cd-nuwhat">' + cdGbp(cm.suggested) + '</div>' +
          '<div class="cd-nuwhy">' + how.join(', ') + '.</div></div>';
        const comps = cm.comps || [];
        if(comps.length){
          cmPane += '<div class="cd-seclab" style="padding-top:4px"><b>The comparable sales</b><span class="ln2"></span></div><div class="cd-rail">';
          comps.forEach(c => {
            const bits = [];
            if(c.mileage) bits.push((+c.mileage).toLocaleString('en-GB') + ' mi');
            if(c.transmission) bits.push(c.transmission);
            if(c.date) bits.push(cdDate(c.date));
            cmPane += '<div class="cd-vrow"><span class="cd-vdot hit"></span><span class="cd-vprice">' + cdGbp(c.amount) + '</span>' +
              '<div class="cd-vname">' + cdEsc((c.year ? c.year + ' ' : '') + (c.name || '')) + '</div><div class="cd-vsum">' + cdEsc(bits.join(' Â· ')) + '</div></div>';
          });
          cmPane += '</div>';
        }
      } else {
        cmPane = '<div class="cd-nextup quiet"><div class="cd-nulab">No suggestion yet</div>' +
          '<div class="cd-nuwhy">CompMatch has no comparable sales for this make, model and gearbox in the last 18 months. It fills in as you bid on more of these.</div></div>';
      }
      let histPane = '';
      const prev = h.previous || {};
      const prevKeys = Object.keys(prev);
      if(prevKeys.length){
        histPane += '<div class="cd-seclab"><b>Seen before</b><span class="ln2"></span></div><div class="cd-rail">';
        prevKeys.forEach(pl => { histPane += '<div class="cd-vrow"><span class="cd-vdot seen"></span><div class="cd-vname">' + cdEsc(pl) + '</div><div class="cd-vsum">' + prev[pl] + ' earlier sale' + (prev[pl] === 1 ? '' : 's') + ' in the last 30 days</div></div>'; });
        histPane += '</div>';
      }
      if((h.same_day || []).length > 1) histPane += '<div class="cd-flag"><span class="cd-flagic amber"></span>On ' + cdEsc(h.same_day.join(' and ')) + ' today.</div>';
      const res = h.reserves || [];
      if(res.length){
        histPane += '<div class="cd-seclab"><b>' + cdEsc(d.reserve_label || 'Reserve') + ' each time</b><span class="ln2"></span></div><div class="cd-rail">';
        histPane += '<div class="cd-vrow"><span class="cd-vdot hit"></span><span class="cd-vprice">' + cdGbp(reserve) + '</span><div class="cd-vname">Today</div></div>';
        res.forEach(r => { histPane += '<div class="cd-vrow"><span class="cd-vdot"></span><span class="cd-vprice">' + cdGbp(r.reserve) + '</span><div class="cd-vname">' + cdEsc(cdDate(r.sale_date)) + '</div></div>'; });
        histPane += '</div>';
      }
      const sold = h.sold;
      if(sold && sold.n_sold){
        histPane += '<div class="cd-seclab"><b>Your record with this model</b><span class="ln2"></span></div><div class="cd-grid">' +
          row('Sold before', sold.n_sold) +
          row('Average days to sell', sold.avg_days_to_sell !== null && sold.avg_days_to_sell !== undefined ? Math.round(sold.avg_days_to_sell) : null) +
          row('Average prep', sold.avg_prep_total !== null && sold.avg_prep_total !== undefined ? cdGbp(sold.avg_prep_total) : null) +
          row('Average margin after prep', sold.avg_margin !== null && sold.avg_margin !== undefined ? cdGbp(sold.avg_margin) : null) + '</div>';
      }
      if(d.stock_count !== null && d.stock_count !== undefined) histPane += '<div class="cd-flag"><span class="cd-flagic ' + (d.stock_level === 'gap' ? 'green' : d.stock_level === 'too_many' ? 'amber' : 'info') + '"></span>' + d.stock_count + ' of this model in stock right now.</div>';
      if(!histPane) histPane = '<div class="cd-empty">First time BidBrain has seen this car, and no earlier sales of this model on your own record.</div>';
      let flagPane = '';
      if(flags.length) flags.forEach(f => { flagPane += '<div class="cd-flag"><span class="cd-flagic ' + cdEsc(f.kind) + '"></span><span>' + cdEsc(f.text) + '</span></div>'; });
      else flagPane = '<div class="cd-empty">Nothing to flag on this car.</div>';
      const right = '<div class="cd-right"><div class="cd-tabs">' +
        '<button type="button" class="cd-tab" data-tab="compmatch" onclick="drawerTabTo(&#39;compmatch&#39;)">CompMatch</button>' +
        '<button type="button" class="cd-tab" data-tab="history" onclick="drawerTabTo(&#39;history&#39;)">History' + (prevKeys.length || res.length ? ' <span class="n">' + (prevKeys.length + res.length) + '</span>' : '') + '</button>' +
        '<button type="button" class="cd-tab" data-tab="flags" onclick="drawerTabTo(&#39;flags&#39;)">Flags' + (flags.length ? ' <span class="n">' + flags.length + '</span>' : '') + '</button>' +
        '</div><div class="cd-pane" data-pane="compmatch">' + cmPane + '</div>' +
        '<div class="cd-pane" data-pane="history" hidden>' + histPane + '</div>' +
        '<div class="cd-pane" data-pane="flags" hidden>' + flagPane + '</div></div>';
      const cta = d.listing_url
        ? '<a class="cd-cta" href="' + cdEsc(d.listing_url) + '" target="_blank" rel="noopener">View on ' + cdEsc(d.source || 'platform') + ' â†—</a>'
        : '<span class="cd-cta disabled">No listing link</span>';
      const foot = '<div class="cd-foot">' + cta +
        '<button type="button" class="cd-ghost cd-starbtn" onclick="drawerStar()">' + STAR_SVG_JS + '<span class="cd-starlabel">Star this car</span></button>' +
        '<button type="button" class="cd-ghost hide" onclick="drawerHide()">Hide this car</button></div>';
      const dr = bbEl('cdrawer');
      if(!dr) return;
      dr.innerHTML = head + money + '<div class="cd-split"><div class="cd-left">' + ph + facts + receipt + '</div>' + right + '</div>' + foot;
      drawerTabTo(drawerTab === 'compmatch' && !cm && flags.length ? 'flags' : drawerTab);
      paintDrawerStar();
    }
    const STAR_SVG_JS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l2.8 5.9 6.2.8-4.6 4.4 1.2 6.2-5.6-3.1-5.6 3.1 1.2-6.2L3 9.7l6.2-.8z"></path></svg>';
    // The "..." overflow menu on each card's own utility row (2026-08-26,
    // Card Density Mockups), same open-one-close-others-and-outside-click
    // shape already used elsewhere in this project.
    function toggleCardMenu(btn){
      const menu = btn.nextElementSibling;
      if(!menu) return;
      const opening = menu.hasAttribute('hidden');
      document.querySelectorAll('.hf-more-menu').forEach(m => m.setAttribute('hidden',''));
      document.querySelectorAll('.hf-more-btn').forEach(b => b.setAttribute('aria-expanded','false'));
      if(opening){ menu.removeAttribute('hidden'); btn.setAttribute('aria-expanded','true'); }
    }
    document.addEventListener('click', function(ev){
      if(ev.target.closest('.hf-more')) return;
      document.querySelectorAll('.hf-more-menu').forEach(m => m.setAttribute('hidden',''));
      document.querySelectorAll('.hf-more-btn').forEach(b => b.setAttribute('aria-expanded','false'));
    });
    async function confirmHide(btn){
      const reason = btn.closest('.hidebox').querySelector('.hidereason').value.trim();
      try {
        await post('/api/hide', {...cardData(btn), reason});
        btn.closest('.card').classList.add('gone'); applyView();
      } catch(e){ flash('Could not save. Open the page at its web address, not from a file.', false); }
    }
    let currentTab = 'all';
    function setTab(btn){
      currentTab = btn.dataset.tab;
      document.querySelectorAll('#tabs .tab').forEach(t => t.classList.toggle('on', t === btn));
      // Below 860px #tabs scrolls inside itself (2026-08-30 review), so the
      // one you just picked can end up off the strip on reload.
      btn.scrollIntoView({inline:'nearest', block:'nearest'});
      // A single platform picked means only its own section can ever show,
      // so that section's own small logo+count marker (redundant with the
      // toggle right above it) hides, see .single-plat in the CSS.
      const shell = bbEl('appshell');
      if(shell) shell.classList.toggle('single-plat', currentTab !== 'all');
      paintFilterScope();
      applyView();
    }
    // Viewing one platform hides the filters it does not publish outright,
    // rather than leaving a Condition grade box on a page of Auction4Cars
    // cars that can never answer it. Any such filter still holding a value
    // is cleared as it goes, so a hidden control can never quietly narrow
    // the list from off screen. Back on All, everything returns.
    function paintFilterScope(){
      let cleared = false;
      document.querySelectorAll('.filterblock').forEach(b => {
        const plats = (b.dataset.platforms || '').split(' ').filter(Boolean);
        const applies = currentTab === 'all' || plats.includes(currentTab);
        b.hidden = !applies;
        if(!applies){
          b.querySelectorAll('select, input').forEach(el => {
            if(el.value){ el.value = ''; cleared = true; }
          });
        }
      });
      // On one platform every remaining filter applies to every car shown,
      // so the scope notes are noise there.
      const some = bbEl('filtergroup-some');
      if(some) some.classList.toggle('single-plat-scope', currentTab !== 'all');
      return cleared;
    }
    // ONE honest sort list (v3.4, the plan's locked decision 3): five
    // options in the drawer, each with its own direction, exactly one in
    // charge, replacing the old merged dropdown plus two standalone chips
    // that could silently disagree with what the screen showed. Each sort
    // starts on its own natural direction (DEFAULT_DIR), so the defaults
    // order exactly as they always have; clicking the active sort again
    // flips it.
    const DEFAULT_DIR = {opportunity: 'desc', newest: 'desc', match: 'desc', reserve: 'asc',
                          cazana: 'asc', ends: 'asc'};
    let currentDir = DEFAULT_DIR.opportunity;
    function sortCards(vis, sort, plat){
      const dir = currentDir === 'desc' ? -1 : 1;
      // A card with no real end time (Motorway, Carwow always close at the
      // fixed 3:30pm daily sale) sorts to the back rather than being hidden,
      // this is an ordering, not a filter, missing data just has nothing to
      // rank by.
      const endsMs = c => c.dataset.endsat ? new Date(c.dataset.endsat).getTime() : Infinity;
      // New since yesterday (Steven 2026-09-02, Step 3): cars not seen on
      // any auction house in the last 30 days first, each group in pick
      // score order, so "what's new" reads best first.
      if(sort === 'newest'){
        const nv = c => (+c.dataset.isnew || 0) * 1000 + (+c.dataset.pickscore || 0);
        vis.sort((a,b) => dir * (nv(a) - nv(b)));
        return;
      }
      // With the Top picks filter on and the default sort untouched, the
      // list reads in rank order, #1 first, whatever platform.
      if(sort === 'opportunity' && bbChecked('filterpicksonly')){
        vis.sort((a,b) => (+a.dataset.pickrank || 999) - (+b.dataset.pickrank || 999));
        return;
      }
      if(sort === 'cazana'){
        vis.sort((a,b) => dir * ((+a.dataset.cazanaprice||0) - (+b.dataset.cazanaprice||0)));
        return;
      }
      if(sort === 'ends'){
        vis.sort((a,b) => dir * (endsMs(a) - endsMs(b)));
        return;
      }
      // Auction4Cars, Dealer Auction and DealerWay run over several days
      // with their own end time each, unlike Motorway and Carwow's one
      // fixed daily close, so under Best opportunity these order by ending
      // soonest first, which says something real about how much time is
      // left to bid. Kept verbatim from before the sort rework, and no
      // longer hidden: the Sort panel's own note says so in plain sight.
      if(sort==='opportunity' && (plat==='auction4cars' || plat==='dealerauction' || plat==='dealerway')){
        vis.sort((a,b) => endsMs(a) - endsMs(b));
        return;
      }
      if(sort==='opportunity') vis.sort((a,b)=>
        // Most room first (Cazana over reserve, kept to one valuation source
        // for every car so the ranking is consistent), stock position ignored.
        dir * ((+a.dataset.cazanaheadroom||0) - (+b.dataset.cazanaheadroom||0)));
      else if(sort==='match') vis.sort((a,b)=>{
        // Best match to your taste first, ties broken by most room (Cazana).
        const d = dir * ((+a.dataset.taste||0) - (+b.dataset.taste||0));
        return d || dir * ((+a.dataset.cazanaheadroom||0) - (+b.dataset.cazanaheadroom||0));
      });
      else vis.sort((a,b)=> dir * ((+a.dataset.reserve||0) - (+b.dataset.reserve||0)));  // cheapest reserve
    }
    function setSort(key){
      if(currentSort === key){
        currentDir = currentDir === 'desc' ? 'asc' : 'desc';
      } else {
        currentSort = key;
        currentDir = DEFAULT_DIR[key] || 'asc';
      }
      paintSortRows();
      applyView();
    }
    function paintSortRows(){
      document.querySelectorAll('#sortlist .sortrow').forEach(b => {
        const on = b.dataset.sortkey === currentSort;
        b.classList.toggle('on', on);
        const rt = b.querySelector('.rt');
        if(rt) rt.textContent = on ? (currentDir === 'desc' ? 'â–¾' : 'â–´') : '';
      });
    }
    // Clears every filter (search, make/model/year/transmission/body style,
    // ends within and the three price ranges) back to its default in one go,
    // plus the sort, its direction and the slice. The platform tab lives in
    // its own row and is left alone. Also clears the Shortlisted star's own
    // pressed state (before this rework, resetting while Shortlisted was on
    // left the star looking active with the filter genuinely off, a real
    // bug the audit flagged).
    function resetFilters(){
      clearFilterState();
      paintRanges();
      applyView();
    }
    // Everything resetFilters does EXCEPT rendering, so applying a saved view
    // can start from a known blank state without painting the page twice
    // (2026-08-30). Every count on the views menu is recomputed each pass now,
    // so a wasted repaint is no longer free.
    function clearFilterState(){
      bbSetVal('search', '');
      currentSort = 'opportunity';
      currentDir = DEFAULT_DIR.opportunity;
      shortOnly = false;
      const nb = bbEl('navshort');
      if(nb){ nb.classList.remove('on'); nb.setAttribute('aria-pressed', false); }
      paintSortRows();
      bbCarry = {};
      bbSetVal('filtermake', '');
      updateModelOptions();
      ['filtermodel','filteryear','filtertransmission','filterbodytype','filterendswithin',
       'filterfuel','filterenginelitres','filterowners','filterservicehistory',
       'filtergrade','filterlocation',
       'filtermileagemin','filtermileagemax',
       'filterreservemin','filterreservemax','filterbidmin','filterbidmax',
       'filterguidemin','filterguidemax',
       'filtercapmin','filtercapmax','filterdistancemin','filterdistancemax',
       'filterstockmax'].forEach(id => bbSetVal(id, ''));
      bbSetChecked('filterheldonly', false);
      // The default is All cars: clearing everything lands on the page's
      // opening view, Top picks is one tap away (showTopPicks).
      bbSetChecked('filterpicksonly', false);
    }
    // The "Top picks" row and the link beside the chip: the default view
    // with the Top picks filter on, nothing else touched.
    function showTopPicks(){
      bbSetChecked('filterpicksonly', true);
      applyView();
    }
    // Saved views (Mark 2026-08-20): the current sidebar filters and sort
    // saved under a name, starred ones show as a one click quick button next
    // to the sort and view dropdown on every section heading. The list lives
    // on the server (bidbrain/db.py saved_views table), fetched on load same
    // as the shortlist star sync, so it is never baked stale into a render
    // and stays the same across every device open on the cockpit.
    function captureCriteria(){
      // A write path: a view captured off a broken page would be PERSISTED
      // wrong, so unlike a filter read this refuses rather than degrading
      // to blanks (golden rule 5, never quietly produce wrong data).
      const ids = ['search','filtermake','filtermodel','filteryear','filtertransmission','filterbodytype',
        'filterendswithin','filterfuel','filterenginelitres','filterowners','filterservicehistory',
        'filtergrade','filterlocation','filtermileagemin','filtermileagemax',
        'filterreservemin','filterreservemax','filterbidmin','filterbidmax',
        'filterguidemin','filterguidemax',
        'filtercapmin','filtercapmax','filterdistancemin','filterdistancemax'];
      if(ids.some(id => !bbEl(id))){
        console.error('BidBrain: a filter input is missing, refusing to capture a saved view from a broken page.');
        return null;
      }
      return carryInto({
        sort: currentSort, direction: currentDir,
        search: bbVal('search'),
        mileagemin: bbVal('filtermileagemin'),
        mileagemax: bbVal('filtermileagemax'),
        fuel: bbVal('filterfuel'),
        enginelitres: bbVal('filterenginelitres'),
        owners: bbVal('filterowners'),
        servicehistory: bbVal('filterservicehistory'),
        grade: bbVal('filtergrade'),
        location: bbVal('filterlocation'),
        capmin: bbVal('filtercapmin'),
        capmax: bbVal('filtercapmax'),
        distancemin: bbVal('filterdistancemin'),
        distancemax: bbVal('filterdistancemax'),
        make: bbVal('filtermake'),
        model: bbVal('filtermodel'),
        year: bbVal('filteryear'),
        transmission: bbVal('filtertransmission'),
        bodytype: bbVal('filterbodytype'),
        endswithin: bbVal('filterendswithin'),
        reservemin: bbVal('filterreservemin'),
        reservemax: bbVal('filterreservemax'),
        bidmin: bbVal('filterbidmin'),
        bidmax: bbVal('filterbidmax'),
        guidemin: bbVal('filterguidemin'),
        guidemax: bbVal('filterguidemax'),
        stockmax: bbVal('filterstockmax'),
        heldonly: bbChecked('filterheldonly') ? '1' : '',
        picksonly: bbChecked('filterpicksonly') ? '1' : '0',
      });
    }
    function applyCriteria(c){
      carryFrom(c);
      const one = v => (v && String(v).includes(',')) ? '' : (v || '');
      currentSort = c.sort || 'opportunity';
      currentDir = c.direction || DEFAULT_DIR[currentSort] || 'asc';
      // c.view is a dead key: views saved before 2026-08-30 still carry it
      // from when the head bar chip was a slice switch. Deliberately ignored
      // rather than migrated, so an old view keeps working untouched.
      paintSortRows();
      // Gated on key presence: a view saved BEFORE search and mileage were
      // part of the criteria has no opinion on them, so applying it leaves
      // a typed search alone, exactly as it always did (the adversarial
      // review caught the ungated version wiping it). Every view saved
      // from now on carries the keys, even blank, so it genuinely owns
      // the whole page state.
      if(c.search !== undefined) bbSetVal('search', c.search || '');
      if(c.mileagemin !== undefined) bbSetVal('filtermileagemin', c.mileagemin || '');
      if(c.mileagemax !== undefined) bbSetVal('filtermileagemax', c.mileagemax || '');
      // Same key presence gate: a view saved before these existed has no
      // opinion on them and leaves them exactly as they are.
      [['fuel','filterfuel'], ['enginelitres','filterenginelitres'],
       ['owners','filterowners'], ['servicehistory','filterservicehistory'],
       ['grade','filtergrade'], ['location','filterlocation'],
       ['capmin','filtercapmin'], ['capmax','filtercapmax'],
       ['distancemin','filterdistancemin'], ['distancemax','filterdistancemax']
      ].forEach(pair => {
        if(c[pair[0]] !== undefined) bbSetVal(pair[1], one(c[pair[0]]));
      });
      bbSetVal('filtermake', one(c.make));
      updateModelOptions();
      bbSetVal('filtermodel', one(c.model));
      bbSetVal('filteryear', c.year || '');
      bbSetVal('filtertransmission', c.transmission || '');
      bbSetVal('filterbodytype', one(c.bodytype));
      bbSetVal('filterendswithin', c.endswithin || '');
      bbSetVal('filterreservemin', c.reservemin || '');
      bbSetVal('filterreservemax', c.reservemax || '');
      bbSetVal('filterbidmin', c.bidmin || '');
      bbSetVal('filterbidmax', c.bidmax || '');
      bbSetVal('filterguidemin', c.guidemin || '');
      bbSetVal('filterguidemax', c.guidemax || '');
      if(c.stockmax !== undefined) bbSetVal('filterstockmax', c.stockmax || '');
      if(c.heldonly !== undefined) bbSetChecked('filterheldonly', c.heldonly === '1');
      // A view saved before Top picks existed has no key and leaves the
      // box exactly as it is, same gate as every filter added after a
      // view could be saved.
      if(c.picksonly !== undefined) bbSetChecked('filterpicksonly', c.picksonly !== '0');
      paintRanges();
      applyView();
    }
    function escHtml(s){ const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
    let SAVED_VIEWS = [];
    const PIN_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>';
    const VIEWX_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="M6 6l12 12"></path></svg>';
    function toggleViewMenu(ev){
      if(ev) ev.stopPropagation();
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(!m) return;
      const open = m.hidden;
      m.hidden = !open;
      if(c) c.setAttribute('aria-expanded', open);
      if(open) paintViewChip();
    }
    function closeViewMenu(){
      const m = bbEl('viewmenu'), c = bbEl('viewchip');
      if(m) m.hidden = true;
      if(c) c.setAttribute('aria-expanded', false);
    }
    document.addEventListener('click', () => closeViewMenu());
    // Views arrive from /api/views on load and again after every save,
    // delete or pin. This only stores them: paintViewChip is the single
    // place a row is built, so the two can never drift (an older second
    // builder here drew the pre 2026-08-30 star rows with no counts).
    function renderViewsList(views){
      SAVED_VIEWS = views || [];
      paintViewChip();
    }
    // Every view's own count, computed the same way the list itself is
    // filtered, against the population you are actually looking at (platform
    // tab and the shortlist). So each number means the same thing: how many
    // cars you would see if you clicked it.
    function viewCount(criteria){
      let n = 0;
      document.querySelectorAll('.card').forEach(c => {
        const sec = c.closest('.platset');
        const plat = c.dataset.platform;
        if(sec && !(currentTab === 'all' || currentTab === sec.dataset.plat)) return;
        if(!sec && !(currentTab === 'all' || currentTab === plat)) return;
        if(!starOk(c)) return;
        if(cardMatches(c, criteria)) n++;
      });
      return n;
    }
    function paintViewChip(shownNow){
      const name = activeViewName();
      bbSetText('viewchiplabel', name);
      const chip = bbEl('viewchip');
      if(chip) chip.classList.toggle('custom', name === 'Custom');
      // The chip carries the same count its own menu rows do. applyView has
      // the real total in hand so it passes it in; a repaint from anywhere
      // else (a save, a fetch) counts for itself.
      bbSetText('viewchipn', shownNow === undefined
        ? viewCount(captureCriteria() || {}) : shownNow);
      const list = bbEl('viewslist');
      if(list){
        // Pinned first, then the rest, each with a live count.
        const rows = SAVED_VIEWS.slice().sort((a,b) => (b.starred?1:0) - (a.starred?1:0));
        list.innerHTML = rows.length ? rows.map(v => (
          '<div class="viewrow' + (name === v.name ? ' on' : '') + '" data-id="' + v.id + '" ' +
          'onclick="applySavedViewById(' + v.id + ')">' +
          '<button type="button" class="viewpin' + (v.starred ? ' on' : '') + '" ' +
          'onclick="event.stopPropagation();toggleViewStar(' + v.id + ',' + (!v.starred) + ')" ' +
          'title="' + (v.starred ? 'Unpin' : 'Pin to the top') + '">' + PIN_SVG + '</button>' +
          '<span class="viewname-btn">' + escHtml(v.name) + '</span>' +
          '<span class="viewn">' + viewCount(v.criteria) + '</span>' +
          '<button type="button" class="viewdel" title="Delete this view" ' +
          'onclick="event.stopPropagation();deleteSavedView(' + v.id + ')">' + VIEWX_SVG + '</button>' +
          '</div>'
        )).join('') : '<p class="viewsempty">No saved views yet.</p>';
      }
      const allRow = bbEl('viewallrow');
      if(allRow){
        allRow.classList.toggle('on', name === 'All cars');
        bbSetText('viewalln', viewCount({}));
      }
      // The Top picks row counts WITH its own filter on (it read the full
      // 109 before, the same number as All cars, Steven 2026-09-02).
      const picksRow = bbEl('viewtoppicksrow');
      const picksN = viewCount({picksonly:'1'});
      if(picksRow){
        picksRow.classList.toggle('on', name === 'Top picks');
        bbSetText('viewtoppicksn', picksN);
      }
      // "Top picks N" beside the chip only while the picks filter is off.
      const sa = bbEl('showallbtn');
      if(sa){
        sa.hidden = bbChecked('filterpicksonly') || !picksN;
        sa.textContent = 'Top picks ' + picksN;
      }
      const save = bbEl('viewsaverow');
      if(save) save.classList.toggle('hot', name === 'Custom');
      const nameBox = bbEl('viewname');
      if(nameBox) nameBox.placeholder = name === 'Custom' ? 'Name this view' : 'Name a view';
    }
    function applySavedViewById(id){
      const v = SAVED_VIEWS.find(x => x.id === id);
      if(!v) return;
      // Clear first, so the state a view produces is the state that was
      // saved, whatever was on screen before. applyCriteria only writes the
      // keys a view carries, so without this an older view silently kept
      // any newer filter you had set, and the derived label could then
      // never read that view's own name back.
      clearFilterState();
      applyCriteria(v.criteria);
    }
    async function saveCurrentView(){
      const nameEl = bbEl('viewname');
      const name = nameEl ? (nameEl.value || '').trim() : '';
      if(!name){ flash('Give the view a name first.'); return; }
      const criteria = captureCriteria();
      if(!criteria){ flash('Could not read the filters, the view was not saved.', false); return; }
      try {
        const r = await post('/api/save-view', {name, criteria});
        if(nameEl) nameEl.value = '';
        renderViewsList(r.views);
        flash('View saved.');
      } catch(e){ flash('Could not save the view.', false); }
    }
    async function toggleViewStar(id, on){
      try {
        const r = await post('/api/star-view', {id, starred: on});
        renderViewsList(r.views);
      } catch(e){ flash('Could not update the view.', false); }
    }
    async function deleteSavedView(id){
      if(!confirm('Delete this saved view?')) return;
      try {
        const r = await post('/api/delete-view', {id});
        renderViewsList(r.views);
      } catch(e){ flash('Could not delete the view.', false); }
    }
    fetch('/api/views').then(r => r.json()).then(d => { if(d && d.ok) renderViewsList(d.views); })
      .catch(e => console.warn('BidBrain: could not load the saved views.', e));
    // Notification bell: opens a dropdown of every health notice from the last
    // run, closes on a second click or a click anywhere outside it.
    // Which cars am I looking at: the platform tabs and this. Deliberately
    // NOT part of a view, so turning it on while you are on a saved view
    // keeps you on that view, just pointed at a smaller set of cars.
    let shortOnly = false;
    let currentSort = 'opportunity';
    // The Shortlisted toggle in the top nav bar: click to show only starred
    // cars, click again back to All. It drives the same slice the head bar
    // chip does (Shortlisted is a real slice option there too, so the chip
    // never lies about what the list is showing the way the old dropdown
    // stuck on All used to while this filter was on).
    function toggleShort(btn){
      shortOnly = !shortOnly;
      btn.classList.toggle('on', shortOnly);
      btn.setAttribute('aria-pressed', shortOnly);
      applyView();
    }
    // Only the shortlist is left here. Gap fillers and Auto only became
    // ordinary filters on 2026-08-30 (stock count, and Any automatic on the
    // Transmission select), and Held back is decided by cardMatches from the
    // criteria, so no branch of this is about the list's shape any more.
    // The one population test left. Gap fillers, Auto only and Held back all
    // became real filters, and this is not a filter at all.
    function starOk(c){
      return !shortOnly || !!c.querySelector('.starbtn.on');
    }
    // The keys a view is actually made of. activeViewName compares over THIS
    // list and nothing else, treating a missing key as empty.
    //
    // Comparing over the union of both sides instead would be the quiet
    // killer: stored views carry different key sets (the real 'audi' view has
    // 28 keys including a now dead 'view: all', an older one has 14, new ones
    // have another set again), so a union comparison would never match and a
    // view would read Custom the instant you clicked it, which is the one
    // behaviour this whole control exists for.
    const VIEW_KEYS = ['sort','direction','search','make','model','year','fuel',
      'transmission','enginelitres','owners','servicehistory','grade','bodytype',
      'location','endswithin','mileagemin','mileagemax','reservemin','reservemax',
      'bidmin','bidmax','guidemin','guidemax','capmin','capmax',
      'distancemin','distancemax','stockmax','heldonly','picksonly',
      'yearmin','yearmax','enginelitresmin','enginelitresmax','ownersmin','ownersmax'];
    const VIEW_DEFAULTS = {sort:'opportunity', direction:DEFAULT_DIR['opportunity'] || 'asc',
                            picksonly:'0'};
    function viewVal(o, k){
      const v = (o && o[k] !== undefined && o[k] !== null) ? String(o[k]) : '';
      return v === '' ? (VIEW_DEFAULTS[k] || '') : v;
    }
    function sameView(a, b){
      return VIEW_KEYS.every(k => viewVal(a, k) === viewVal(b, k));
    }
    function isDefaultView(c){ return sameView(c, {}); }
    // Derived on every repaint, never stored, so it cannot go stale: the name
    // of the view you are exactly on, "All cars" when nothing is applied, or
    // "Custom" the moment you deviate from both.
    function activeViewName(){
      const c = captureCriteria();
      if(!c) return 'All cars';
      // A saved name wins over "All cars", see the same note on purchases:
      // clicking a view you named must show that name back, even when the
      // state it restores happens to equal the default.
      const hit = (SAVED_VIEWS || []).find(v => sameView(c, v.criteria));
      if(hit) return hit.name;
      if(isDefaultView(c)) return 'All cars';
      if(sameView(c, {picksonly:'1'})) return 'Top picks';
      return 'Custom';
    }
    // One merged list for Top picks under All, per platform sections
    // otherwise (Steven 2026-09-02). Each card remembers its home section
    // the first time through, so the move is always reversible.
    function arrangeSections(){
      const picks = document.querySelector('.platset[data-plat="picks"] .grid');
      if(!picks) return;
      const merge = (currentTab === 'all') && bbChecked('filterpicksonly');
      document.querySelectorAll('.platset .card').forEach(c => {
        const sec = c.closest('.platset');
        if(!c.dataset.home && sec.dataset.plat !== 'picks') c.dataset.home = sec.dataset.plat;
        if(merge && sec.dataset.plat !== 'picks') picks.appendChild(c);
        if(!merge && sec.dataset.plat === 'picks'){
          const home = document.querySelector('.platset[data-plat="' + c.dataset.home + '"] .grid');
          if(home) home.appendChild(c);
        }
      });
    }
    function applyView(){
      const sort = currentSort;
      // One criteria object drives the whole pass. cardMatches is the same
      // logic that was inlined here before, lifted out so a saved view's own
      // criteria can be measured against the cards without touching the
      // boxes on screen (2026-08-30).
      const crit = readFilterInputs();
      const okFilters = c => cardMatches(c, crit);
      const _y = window.scrollY;
      arrangeSections();
      // Just the Shortlisted badge now: every other count is a view's own,
      // computed by viewCount against that view's criteria.
      const counts = {short:0};
      // The real, currently visible count beside the platform toggle (Mark
      // 2026-08-27): this one respects search and every filter, so it always
      // matches what is actually on screen.
      let visibleTotal = 0;
      document.querySelectorAll('.platset').forEach(sec => {
        const sectionOn = sec.dataset.plat === 'picks'
          ? (currentTab === 'all' && bbChecked('filterpicksonly'))
          : (currentTab === 'all' || currentTab === sec.dataset.plat);
        const grid = sec.querySelector('.grid');
        if(!grid) return;
        const cards = [...grid.querySelectorAll('.card')];
        let shown = 0;
        cards.forEach(c => {
          if(c.classList.contains('gone')) { c.style.display = 'none'; return; }
          // Counts reflect the active platform tab, not the search or category,
          // but a lot whose own auction has genuinely ended is excluded from
          // every count here too, not just filtered out of what's shown: a
          // starred Auction4Cars/Dealer Auction/DealerWay lot that closed days
          // ago can never actually be displayed (notEnded hides it in okFilters
          // below unconditionally), so counting its star toward the Shortlisted
          // badge was a real bug, the badge could say 4 while the Shortlisted
          // view itself showed nothing, since none of those 4 could ever pass
          // notEnded once their own lot had closed.
          if(sectionOn && notEnded(c) && c.querySelector('.starbtn.on')) counts.short++;
          const vis = sectionOn && starOk(c) && okFilters(c);
          c.style.display = vis ? '' : 'none';
          if(vis) shown++;
        });
        const vis = cards.filter(c => c.style.display !== 'none');
        sortCards(vis, sort, sec.dataset.plat);
        vis.forEach(c => grid.appendChild(c));
        const cnt = sec.querySelector('.setcount');
        if(cnt) cnt.textContent = shown;
        sec.style.display = (sectionOn && shown > 0) ? '' : 'none';
        visibleTotal += shown;
      });
      const heldSec = bbEl('heldsec');
      if(heldSec) {
        let shown = 0;
        const heldCards = [...heldSec.querySelectorAll('.card')];
        heldCards.forEach(c => {
          const platOn = (currentTab === 'all' || currentTab === c.dataset.platform);
          const vis = platOn && okFilters(c);
          c.style.display = vis ? '' : 'none';
          if(vis) shown++;
        });
        // Held cards were never sorted at all, they rendered in database
        // order, which only went unnoticed while "held back" was a separate
        // mode rather than a filter you layer (2026-08-30). They are built
        // by _plain_card and were never priced, so they carry none of the
        // sort keys: every one of them scores zero under Best opportunity
        // and the order stays arbitrary. Reserve is the one key they do
        // carry, so sort on that whatever is picked, cheapest first, rather
        // than leave the order meaningless.
        const heldGrid = heldSec.querySelector('.grid');
        if(heldGrid) {
          heldCards.filter(c => c.style.display !== 'none')
            .sort((a, b) => (+a.dataset.reserve || Infinity) - (+b.dataset.reserve || Infinity))
            .forEach(c => heldGrid.appendChild(c));
        }
        heldSec.style.display = (shown > 0) ? '' : 'none';
        visibleTotal += shown;
      }
      // The chip's label and every view's own count are repainted as part of
      // the normal render, so none of them can disagree with the list.
      paintViewChip(visibleTotal);
      const shortEl = document.querySelector('#navshort .fn[data-n="short"]');
      if(shortEl) shortEl.textContent = counts.short ? String(counts.short) : '';
      paintFrame(visibleTotal);
      paintCardScale();
      window.scrollTo(0, _y);  // reordering detaches cards and would jump to top
    }
    // This page's own frame config: one list drives the chips row, the
    // More badge and the drawer's footer count. Declared before the first
    // applyView() call below, never after (the TDZ trap this file has hit
    // before). The frame installs no Escape handler here (this page's own
    // gallery handler owns the ladder and calls frameEscape itself).
    function selLabel(id){
      const el = bbEl(id);
      if(!el || !el.value) return null;
      const opt = el.options[el.selectedIndex];
      return opt ? opt.text : el.value;
    }
    function rangeChipLabel(label, prefix){
      const lo = bbVal(prefix + 'min'), hi = bbVal(prefix + 'max');
      if(!lo && !hi) return null;
      const f = v => 'Â£' + Number(v).toLocaleString('en-GB');
      if(lo && hi) return label + ' ' + f(lo) + ' to ' + f(hi);
      return lo ? label + ' from ' + f(lo) : label + ' up to ' + f(hi);
    }
    function scopeSuffix(field){
      const plats = field && FILTER_SCOPE[field];
      if(!plats) return '';
      const names = plats.map(p => PLATFORM_LABELS[p] || p);
      const joined = names.length === 1 ? names[0]
        : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1];
      return ' <span class="fchip-scope">' + bbEsc(joined + ' only') + '</span>';
    }
    const PLATFORM_LABELS = {motorway: 'Motorway', carwow: 'Carwow',
      auction4cars: 'Auction4Cars', dealerauction: 'Dealer Auction', dealerway: 'DealerWay'};
    function selFilter(id, labelFn, field){
      return { ids: [id],
        label(){
          const v = bbVal(id);
          return v ? bbEsc(labelFn ? labelFn(v) : v) + scopeSuffix(field) : null;
        },
        clear(){ bbSetVal(id, ''); } };
    }
    const FRAME = {
      title: 'Sort and filters',
      filters: [
        { ids: ['filtermake'],
          label(){ const v = bbVal('filtermake'); return v ? bbEsc(v) : null; },
          clear(){ bbSetVal('filtermake', ''); updateModelOptions(); } },
        selFilter('filtermodel'),
        selFilter('filteryear'),
        selFilter('filterfuel'),
        selFilter('filtertransmission'),
        selFilter('filterenginelitres', v => v + ' litre'),
        selFilter('filterowners', v => v === '1' ? '1 owner' : v + ' owners'),
        selFilter('filterservicehistory', v => v + ' service history'),
        selFilter('filtergrade', v => 'Grade ' + v, 'grade'),
        selFilter('filterbodytype', null, 'bodytype'),
        selFilter('filterlocation', null, 'location'),
        { ids: ['filtercapmin', 'filtercapmax'],
          label(){ return rangeChipLabel('CAP clean', 'filtercap'); },
          clear(){ bbSetVal('filtercapmin', ''); bbSetVal('filtercapmax', ''); } },
        { ids: ['filterdistancemin', 'filterdistancemax'],
          label(){
            const lo = bbVal('filterdistancemin'), hi = bbVal('filterdistancemax');
            if(!lo && !hi) return null;
            if(lo && hi) return 'Distance ' + lo + ' to ' + hi + ' miles';
            return lo ? 'Distance from ' + lo + ' miles' : 'Distance up to ' + hi + ' miles';
          },
          clear(){ bbSetVal('filterdistancemin', ''); bbSetVal('filterdistancemax', ''); } },
        { ids: ['filterstockmax'],
          label(){
            const v = bbVal('filterstockmax');
            if(!v) return null;
            return v === '1' ? 'None in stock' : 'Fewer than ' + bbEsc(v) + ' in stock';
          },
          clear(){ bbSetVal('filterstockmax', ''); } },
        { ids: ['filterheldonly'],
          label(){ return bbChecked('filterheldonly') ? 'Held back only' : null; },
          clear(){ bbSetChecked('filterheldonly', false); } },
        { ids: ['filterendswithin'],
          label(){ return bbVal('filterendswithin')
            ? bbEsc('Ends ' + (selLabel('filterendswithin') || '').toLowerCase()) + scopeSuffix('endswithin')
            : null; },
          clear(){ bbSetVal('filterendswithin', ''); } },
        { ids: ['filtermileagemin', 'filtermileagemax'],
          label(){
            const lo = bbVal('filtermileagemin'), hi = bbVal('filtermileagemax');
            if(!lo && !hi) return null;
            const f = v => Number(v).toLocaleString('en-GB') + ' miles';
            if(lo && hi) return 'Mileage ' + Number(lo).toLocaleString('en-GB') + ' to ' + f(hi);
            return lo ? 'Mileage from ' + f(lo) : 'Mileage up to ' + f(hi);
          },
          clear(){ bbSetVal('filtermileagemin', ''); bbSetVal('filtermileagemax', ''); } },
        { ids: ['filterreservemin', 'filterreservemax'],
          label(){ return rangeChipLabel('Reserve', 'filterreserve'); },
          clear(){ bbSetVal('filterreservemin', ''); bbSetVal('filterreservemax', ''); } },
        { ids: ['filterbidmin', 'filterbidmax'],
          label(){ return rangeChipLabel('Max bid', 'filterbid'); },
          clear(){ bbSetVal('filterbidmin', ''); bbSetVal('filterbidmax', ''); } },
        { ids: ['filterguidemin', 'filterguidemax'],
          label(){ return rangeChipLabel('Retail guide', 'filterguide'); },
          clear(){ bbSetVal('filterguidemin', ''); bbSetVal('filterguidemax', ''); } },
        { ids: ['search'], q: true,
          label(){
            const q = (bbVal('search') || '').trim();
            return q ? 'â€œ' + bbEsc(q) + 'â€�' : null;
          },
          clear(){ bbSetVal('search', ''); } },
      ],
      apply(){
        applyView();
      },
      onSection(k){ if(k === 'sort') paintSortRows(); },
    };
    // Price ceiling (Settings > Cockpit display, Mark 2026-08-24: "no
    // point seeing these cars in the live view... need this editable",
    // extended the same day to cover Reserve too, "make sure the Reserve
    // price... doesnt go above 11500 as well"). Display only, pre fills
    // the sidebar's own existing Max bid AND Reserve filters on load
    // rather than a separate mechanism, so Reset (or just clearing either
    // box) shows everything again for that session, and pricing, gating
    // and every count are completely untouched by it.
    paintFilterScope();
    const DETAILS_STYLE = 'z%';
    const DEFAULT_PRICE_CEILING = Únullu€"  ;
    if(DEFAULT_PRICE_CEILING !== null){
      const ceilBid = bbEl('filterbidmax'), ceilRes = bbEl('filterreservemax');
      if(ceilBid && ceilRes){
        ceilBid.value = DEFAULT_PRICE_CEILING;
        ceilRes.value = DEFAULT_PRICE_CEILING;
      } else {
        // The one read that must NEVER degrade silently: skipping it would
        // quietly show cars above the configured ceiling (golden rule 5).
        bbPageNotice('The price ceiling of Â£' + DEFAULT_PRICE_CEILING +
          ' from Settings could not be applied, so cars above it may be showing. ' +
          'Rebuild this page with python3 daily_run.py, render only flag, or report this.');
      }
    }
    applyView();

    // Keeps --controls-h (the toolbar's own sticky offset, see the CSS note
    // above .platform-toggle-row) equal to the real height of .controls,
    // which changes whenever the run bar shows, hides or changes its own
    // message (renderProgress calls this directly, right after it), and
    // whenever the page switcher wraps to a second line on a narrower
    // window (the resize listener below). A ResizeObserver is also attached
    // as a backstop for anything neither of those two catches, but it is
    // NOT relied on alone: this tool's own environment does not fire one
    // even for a trivial isolated element (checked live before trusting it,
    // 2026-08-30), and a real browser can still coalesce or delay one by a
    // frame, so the two direct calls are what actually keep this correct.
    // Scale every card to its column so nothing inside is squashed: the
    // grid's own resolved column width (zoom independent, straight from
    // grid-template-columns) against the 360px the card was designed at.
    function paintCardScale(){
      document.querySelectorAll('.platset .grid').forEach(g => {
        const cols = getComputedStyle(g).gridTemplateColumns.split(' ').map(parseFloat).filter(n => n > 0);
        if(!cols.length) return;
        const z = Math.min(1, Math.max(0.7, cols[0] / 360));
        g.style.setProperty('--card-zoom', z.toFixed(3));
      });
    }
    window.addEventListener('resize', () => paintCardScale());
    function syncControlsHeight(){
      const bar = document.querySelector('.controls');
      if(bar) document.documentElement.style.setProperty('--controls-h', bar.getBoundingClientRect().height + 'px');
    }
    syncControlsHeight();
    window.addEventListener('resize', syncControlsHeight);
    if(typeof ResizeObserver !== 'undefined'){
      const bar = document.querySelector('.controls');
      if(bar) new ResizeObserver(syncControlsHeight).observe(bar);
    }

    // Deep link straight into the shortlist filter (Mark 2026-08-25: "make
    // the shortlists cars button on all nav bar screens"), same ?param
    // pattern settings.html's own ?tab= already uses, so the star button
    // other pages now carry in their own nav bar can jump here with the
    // filter already active rather than landing on the unfiltered cockpit
    // and needing a second click.
    if(new URLSearchParams(location.search).get('view') === 'shortlist'){
      const navshortBtn = bbEl('navshort');
      if(navshortBtn) toggleShort(navshortBtn);
    }
    window.RUN_PAGE_RELOAD = true;  // the passes that rewrite this static page reload it on a clean finish

    // Auction4Cars countdown: each lot has its own end time (3, 5 or 7 day
    // auctions), unlike Motorway and Carwow which both close everything at
    // 3:30pm, so this is worth a live ticking clock rather than a static date.
    function paintCountdowns(){
      // Mark 2026-08-21: once a lot's own auction ends, its card should
      // disappear from view, not just sit there greyed out. The actual
      // hiding happens through the real filter pipeline (notEnded, inside
      // applyView's okFilters), already ended cards are dropped there on
      // every page load; this only needs to notice a card crossing over to
      // ended DURING an open session and ask applyView to re run once, so it
      // vanishes live rather than waiting for the next filter click.
      let justEnded = false;
      document.querySelectorAll('.hf-gap.ends[data-endsat]').forEach(el => {
        const ends = new Date(el.dataset.endsat);
        const ms = ends - new Date();
        if(isNaN(ms)){ return; }
        if(ms <= 0){
          if(!el.classList.contains('over')) justEnded = true;
          el.textContent = 'Auction ended';
          el.classList.add('over'); el.classList.remove('urgent');
          return;
        }
        const totalSecs = Math.floor(ms / 1000);
        const h = Math.floor(totalSecs / 3600), m = Math.floor(totalSecs % 3600 / 60), s = totalSecs % 60;
        // Under an hour, count down to the second, a real ticking clock for
        // the lots close enough to matter; an hour or more out, minutes are
        // all that's useful, seconds there would just be noise.
        el.textContent = h > 0 ? `Ends in ${h}h ${m}m` : `Ends in ${m}m ${s}s`;
        el.classList.toggle('urgent', h === 0);
      });
      if(justEnded) applyView();
    }
    paintCountdowns();
    setInterval(paintCountdowns, 1000);

    // Sync the star (shortlist) state from the server on load, so a reload always
    // shows the current shortlist, even cars starred after the page was last
    // built. The stars live in the database; the page just reflects them.
    function _norm(r){ return String(r||'').toUpperCase().replace(/\s/g,''); }
    fetch('/api/bids?sale_date=' + encodeURIComponent(SALE_DATE)).then(r=>r.json()).then(d=>{
      if(!d || !d.ok || !d.regs) return;
      const set = new Set(d.regs.map(_norm));
      document.querySelectorAll('.card').forEach(c=>{
        const btn = c.querySelector('.starbtn'); if(!btn) return;
        const on = set.has(_norm(c.dataset.reg));
        btn.classList.toggle('on', on); btn.setAttribute('aria-pressed', on);
        btn.title = on ? 'On your shortlist' : 'Add to my shortlist';
      });
      applyView();
    }).catch(e => console.warn('BidBrain: could not sync the shortlist stars on load.', e));

    // The full listing gallery, hidden behind the card's main photo until
    // clicked (Mark 2026-08-21). One shared overlay for every card; imgEl
    // is the clicked <img>, its data-gallery attribute carries the JSON
    // list of every photo for that car.
    let galleryUrls = [];
    let galleryIndex = 0;
    function openGallery(imgEl){
      try { galleryUrls = JSON.parse(imgEl.dataset.gallery || '[]'); } catch(e){ galleryUrls = []; }
      if(!galleryUrls.length) return;
      galleryIndex = 0;
      paintGallery();
      const gov = bbEl('gallery-overlay');
      if(gov) gov.hidden = false;
    }
    function closeGallery(){ const gov = bbEl('gallery-overlay'); if(gov) gov.hidden = true; }
    function galleryStep(dir){
      if(!galleryUrls.length) return;
      galleryIndex = (galleryIndex + dir + galleryUrls.length) % galleryUrls.length;
      paintGallery();
    }
    function galleryTo(i){ galleryIndex = i; paintGallery(); }
    function paintGallery(){
      const gimg = bbEl('gallery-img'), gcount = bbEl('gallery-count'), gthumbs = bbEl('gallery-thumbs');
      if(!gimg || !gcount || !gthumbs) return;
      gimg.src = galleryUrls[galleryIndex];
      gcount.textContent = (galleryIndex + 1) + ' / ' + galleryUrls.length;
      gthumbs.innerHTML = galleryUrls.map((u, i) =>
        '<img class="gallery-thumb' + (i === galleryIndex ? ' on' : '') + '" src="' + u +
        '" onclick="galleryTo(' + i + ')">').join('');
    }
    document.addEventListener('keydown', e => {
      const ov = bbEl('gallery-overlay');
      if(ov && !ov.hidden){
        if(e.key === 'Escape') closeGallery();
        else if(e.key === 'ArrowLeft') galleryStep(-1);
        else if(e.key === 'ArrowRight') galleryStep(1);
        return;
      }
      if(e.key !== 'Escape') return;
      // One layer per press: a card's own open Details panel first, then
      // the More drawer's own panel, then the drawer itself (frameEscape
      // handles both). The slice menu rung this used to check for is
      // gone: Didn't win, the last page that ever rendered one, moved to
      // the views chip 2026-08-30, and frameEscape already covers that
      // menu on its own (see the viewmenu check inside it).
      if(carDrawerOpen()){ closeCarDrawer(); return; }
      if(document.querySelector('.hf-detailpop.open')){ closeAllDetails(); return; }
      frameEscape();
    });
    // Back to top button: show it once the page is scrolled down a bit.
    const _totop = bbEl('totop');
    function _toggleTotop(){ if(_totop) _totop.classList.toggle('show', window.scrollY > 400); }
    window.addEventListener('scroll', _toggleTotop, {passive:true});
    _toggleTotop();
  </script>
</body>
</html>
)Fr   r  rà   rÇ   r“  r”  r•  rM   Ú	isoformatr–  r   rX   r·  r¬  r‚   r  rP   r@   rÄ   r  r<  rØ  r"   r6   rœ   rý   rj   r  r  r  rÙ  r   rA   rI   r8  r  rá  rÎ  rV  r:  r  rÙ  r    rr  r   r:   r}   r>  r?  rõ   r@  rA  r  r  r  rŒ  r  rŽ  rä   rØ   Ú
_CLOSE_SVGÚ_CHEV_LEFT_SVGÚ_CHEV_RIGHT_SVGr„   rn   r§   rÅ   rÆ   r-  Ú_LENIENT_FILTERS)7r³  rj  r  r•  Úshow_excludedÚshow_rejectedrD  Ú	sale_dater‹  Únoticer[  rË   Údefault_price_ceilingrÉ   rÊ   r­  r®  r¯  r°  r±  r²  r³  r´  Údetails_styler˜  r–  Ú_filter_carsÚplatform_sectionsÚplatform_toggle_barr@  Ú_makesÚ_yearsÚ_transmissionsÚ_body_typesÚ_fuelsÚ_gradesÚ_ownersÚ_litresÚ
_locationsÚ
_historiesÚmodel_familiesÚyÚhÚ_everyÚ_someÚfilter_controlsr
  Ú	sort_rowsÚmore_drawerÚbellÚheld_sectionÚrejected_sectionÚ	held_htmlÚrejected_htmlr  s7                                                         @r   Úrender_pager;  6,  s˜  ø€ ð &3Ð6IÓ%I‘MÈx€MØ×7Ô+¨JÑ7€HØ×@Ô!1°-Ñ!@€KØ×=Ô/°Ñ=€JØ×=Ô/°Ñ=€JØ!×FÔ%5°oÑ%F€MØ%×-¨€OØ'×/¨4ÐØ!×AÐ%5×A¼€MØÑØ%ˆô �7—=˜bÓ!€GÞØ�‰�vÔØ×*”X—]‘]×(Ñ(Ó*€EØ�_”s“u€FØ×.˜UŸ_™_Ó.€IØ—‘˜zÓ*€Ià#,Ò#3Ó4Ò#3˜a�A—E”EÑ#3€LÐ4ô
 "Ñ!?±,Ó!?Ó?€KÜ*¨9¸gÀ{ÓSÐô /Ø6´y³{°mÀ6ÐJô  DÑ)ð -Wñ Wñ	XÐô ¹lÓUºl¸ÈaÏfÍfÓ4�[—_‘_ Q§V¡V¨Q¯V©VÖ4¹lÑUÓV€FÜ¡\Ó<¢\ °Q·VµV“V�Q—V”V¡\Ñ<ÀdÑK€FÜ±\ÓT²\°ÀQÇ^Å^›^˜QŸ^œ^±\ÑTÓU€Nô ©|ÓKª|¨!¸q¿{½{›+˜!Ÿ+œ+©|ÑKÓL€KÜ±\ÓL²\°ÀQÇVÅVÓ+�Q—V‘V—\‘\“^×)Ñ)Ö+±\ÑLÓM€FÜ©LÓPªL q¸A¿G¹G“l”c˜!Ÿ'™'–l©LÑPÓQ€GÜ©\ÓRª\¨¸Q¿X¹X“m”c˜!Ÿ(™(–m©\ÑRÙ)ñ+€Gä±ÓS²¨AÄÐPQ×ARÓ'”n QÖ'±ÑSÓT€GÜ©\ÓHª\¨¸Q¿Z½Z›˜Ÿœ©\ÑHÓI€Jô Ù".óEÚ".˜Q°!×2CÕ2Có C˜!×+Ñ+×1¨r×8Ñ8Ó:×@Ñ@ÖBÙ".ñEó F€Jô
 Ù'3óKÚ'3 !°q·vµvó _ØBCÇ'Å'ó _˜kŸo™o¨a¯f©f°a·f±fÓ=¼}ÈQÏVÉVÐUV×U\ÑU\Ó?]Ó^Ù'3ñKó L€Nô 	�fœn¨\¸6À6ÐTdÑeÓfÜ
˜¤°¸wÈÓ!KÓ
Lñ	Mä
˜¤¨|¸VÑV\ÓE]ÒV\ÐQRÄcÈ!ÆfÑV\ÑE]Ó ^Ó
_ñ	`ô ˜¤¨|¸VÀVÓ LÓ
Mñ	Nô ˜¬Ø  .°.Ø7Ð8ñ):ó ;ñ		;ô ˜¬Ð7KÈ]Ð\cÓ(dÓ
eñ	fô ˜¤.°ÀÈ7Ó"SÓ
Tñ	Uô Ð(¬.Ð9OÐQbÙMWÓ9XÊZÈ¸!¿'¹'¾)ÉZÑ9Xó+Zó [ñ
	[ô ˜	¤=°À)ÐPYÐ`dÐjqÑ#rÓ
sñ	tô ˜	¤=°À)ÐPYÐ`cÐipÑ#qÓ
rñ	sô ˜œ}¨[¸)ÈÐWZÐ`gÑhÓ
iñ	jô ˜¤¨}¸nÐQ\ÐcfÐlsÑ!tð;ñ";ó <ñ	<ô" ˜¤ØÐ4Ð6KØMó"Oó Pñ#	Pô( Ð)¨;¸-ÌÐGXÐXbÐ8cð^ó_ñ)	_ô. Ð(¨+Ð7XØSóUñ/	Uð ô6 	�gœ~¨mÐ=NÐPWÓXÓYÜ
˜
¤NÐ3CÀ\ÐS^Ó$_Ó
`ñ	aä
˜
¤NÐ3CÀZÐQ[Ó$\Ó
]ñ	^ô ˜Ô&:Ð;MÈ}Ü;OÓ;Qó'Só Tñ	Tô
 ˜œ}¨[¸+È%ÐVYÐ_fÑgÓ
hñ	iô ˜
¤MÐ2BÐDUÐZdÐklÐryÑ$zÓ
{ñ	|ð 
ð	;Ø=Cñ	DðGQñ	Qð ñ	ð ñ	ð ò`ð 	Ù
�MÐ#5Ó
6ñ	7ð6ñ	6ñ
 �HÐ3Ó
4ñ	5ñ �GÐ1Ó
2ñ	3ñ �IÐ1Ó
2ñ	3ñ �H˜nÓ
-ñ	.ñ �FÐ,Ó
-ñ		.ðXñ	Xð ô Ø	�˜Ð#Ø	�Ið
,à.=ñ
>ð	?ð ð  ñ!€Kð,  ×% 2€LØ+×1¨rÐô ˜¨Ñ.€Dà€LØÐÞžØ—G‘GÔNÉÓNÓNˆ	ðGäGJÈ4ÃyÀkð Rà$˜+ð &ð	ˆö žØŸ™ÑMÁHÓMÓMˆð JäJMÈhË-Èð Yà(˜/ð *ð	Ð÷i"ó i"ð ð i"ð &�ði"ð'-ði"ð. .6¨Jð/i"ð.7ð/i"ð0 ˆMð1i"ð0 #ð1i"ð0 $. ,ð1i"ð0 /8ð1i"ð0 9C°|ð1i"ð0Dð1i"ð:  �ð;i"ð:M!ð;i"ôT ƒ€ðUi"ðTYðUi"ðF ˆ}ðGi"ðF )ðGi"ðF *4¨ðGi"ðF 5YðGi"ðF ZdÐXdðGi"ðFeðGi"ðH ˆlðIi"ðHuHðIi"ôr IVÀðsi"ðrWEðsi"ôt FSÀOðui"ðtT_ðui"ôv `hÐ^hðwi"ðviMðwi"ôx N[ÈOðyi"ðx\Iðyi"ôz JWÈð{i"ðzLXð{i"ôR ˆoÓÐðSi"ðR ðSi"ôT �IÓÐðUi"ðT #ðUi"ôX $/ -ðYi"ðX0ðYi"ô\ ˜Ó!Ð"ð]i"ð\#ð]i"ð^ €fð_i"ð^ð_i"ô` ÓÐðai"ð`ðai"ôb ƒ}€oðci"ðbðci"ôf ÓÐðgi"ðfðgi"ôj Ð%¸<ÐU^Ñ_Ð`ðki"ðja]ðki"ôr ^hÐ\hðsi"ðrirðsi"ôt sAð  rBðui"ðtBmðui"ôx n}Ðl}ðyi"ðx~ðyi"ð@ €-ðAi"ð@ðAi"ðF ÐðGi"ðFðGi"ôH ÓÐðIi"ðHðIi"ðJ ÐðKi"ðJðKi"ðL €nðMi"ðLðMi"ðN ÐðOi"ðNðOi"ô^ ‹;ˆ-ð_i"ð^ð_i"ô` ˜%Ñ Ð !ðai"ð`"ðai"ôh Ÿ*š*¤]Ó3Ð4ðii"ðh5ðii"ôj "ŸZšZÔ(8Ó9Ð:ðki"ðjz;ðki"ð^ "�{ð_i"ð^#ð_i"ô` !ŸJšJ ~Ó6Ð7ðai"ð`N8ðai"ð|? *˜?ð}?i"ð|?+#ð}?i"ö~? =RÑ#8ÐW]Ð"^ð?i"ð~?j_ô?i"ð i"ùòA 5ùò0 VùÚ<ùÚTùò LùÚLùÚPùÚRùâSùÚHùòEùò
Kùò F^ùò :Ys�   Ä/f
ÆfÆ(*fÇ"fÇ;fÈfÈ3fÉfÉ,fÊf#Ê%,f#Ë!f(Ë6f(Ìf-Ì4f-Íf2Í5f2Îf7Î-f7Ïf<Ï&5f<Ð+gÑgÑA
gÓ&gÕ8g)r   r«  r�  )rÐ  )T)NNNrÂ   )r   )Ústatr   )NNr©  )r   r   )úapplyView()Nr   )r   r=  F)r=  N)NTNNNNNNNNNNNNNNNNNNN)“Ú__doc__rh   rÅ   r\   r   r“  rà  r   r   r%   r;   rI   rX   r]   r   ÚdirnameÚabspathÚ__file__r^   r„  rž  rd   ró   rô   rõ   r>  r?  r  r@  ÚDW_LOGOrA  r{  r  rj   rl   rn   rs   r{   r}   r‚   r„   r‰   r˜   r    r§   rx   rÞ   ry   r¤   rÃ   rØ   r£   rä   rw   r¢   rï   r÷   r  r
  r  r  r6  r-  r  r&  r4  r8  r=  rA  rD  rJ  rM  rR  rz  rn  r�  r  Ú_BAG_SVGÚ
_CROSS_SVGÚ_HOURGLASS_SVGrZ  r^  rr  ru  rÈ   r|  Úcompiler�  r‘  r’  rž  rÌ  r9  r<  r=  rB  r  r  r  r;  rÎ  r  r:  r  rb  rp  rˆ  r‹  rš  r   r¢  r«  r  rj  rz  r@  rB  ri  ry  r¨  rM  rX  r]  r…  r  r�   r  rŒ  Ú_APPNAV_STAR_SVGrŽ  r  r•  r—  r  r£  r  r¬  r·  r‡   rˆ   r  rÎ  rV  rÙ  r  rá  r;  r   r&   r   Ú<module>rH     s]  ðñ
ó Û Û 	Û 	Û ß -ò6ò WÐ ò&òRðB �'‰'�,‰,�r—w‘w—‘ r§w¡w§¡°r·w±w·±ÀxÓ7PÓ'QÓRÐT\Ó
]€ð€
ð	€ò*ñ Ð!Ó
"€Ù
Ð!Ó
"€ñ Ð#Ó$€ñ Ð-Ó.€ÙÐ-Ó.€ñ
 Ð!Ó
"€ñ Ð-Ó.€ñ Ð!Ó
"€ÙÐ-Ó.€ñ Ð!Ó
"€ÙÐ#Ó$€ð@�3ô @ò/òd	ò4Yðh�Cô hðV	C�3ô CðE˜3ô EðI˜sð I¨3ð I°3ô Iñ)˜cð )¨#ð )ÐPSõ )ñ&¨sõ ñ:H cõ Hðv€ð6ZÐ ðzp€ðd]€ðJ QØdØ\Ø5Ø6ØÐ+¨T°4¸*ÀdÐ9KÐLØ:Ø5ØMØ5ØEØ7Ø\ðÐ ð" TXØ"*ôNðbR�Sô RðZ˜ô Zð€ðG€òTò;ô$Mò 2ò
Lòò(	#ò6 V€â4Ø˜Ð!Ø�Ø˜;Ð'Ú=Ú@ñ€ò @Ð òWò
	7ò9ò	ôTô
eòOò0ò5ð_€	ð
B€	ð <€ðr€ðT€ð=€
ðe€ð  pÐ ò.ô8-òpÐ ô+#òR
ð —
’
ðfóg€ð —
’
Ð@ÓA€Ø—
’
ÐiÓj€òFòRmBð`t€ðd€ðsÐ ð@€ðO€
ð=€ð<€ðWÐ ò
sñ ð	\ó]ñ Ð^Ó_Ùð	;ó<ñ ð	7ó8ñ ÐtÓuÙÐ|Ó}Ùð	góhñ ÐaÓbÙð	códñ ð	;ó<ñ)€ð4 €ò.ôb`ôF	'òT$òNñ0M cõ Mñ`]¨#õ ]ñ@> õ >òB`ðiH@˜#ô iH@ðXQ )°9È<Ø&Ð=RØ"°NØ!1ÀÐW_Ø(ñ	*€ò$\€à¨7ÀÐV]Ø#*Ø%¸ÈgñW€
ð �-€ð eð bØoØoØ?ð xØ6Ø]ð mØZð mñ€ð Ð(>ÈðQñRð "Ð,FÐP[ðAñBð Ð;ÀWðFñGð  Ð*CÈ[ð;ñ<ð Ð'<Àkð:ñ;ð Ð$SÐ]hðHñIð#€ð0L9˜#ð L9 $ô L9ð^i@˜#ô i@ðV@@˜Cô @@ðF_@˜ô _@ðD+-€ðj€ñ
* õ *ðZ[ #ô [ð*HÐ ð
c�Sô cð5˜3ô 5ð %°ÈØ#*¸ñB€òPñI˜ð I sõ Ið* Ð  /°>ÐBØ�(˜KÐ)9Ð:Ø�> =Ð2DÐEØ�˜VÐ%5Ð6ð	Ð ò=ô.*ôZ$ZñN%°õ %ðG cô Gð
 NRØô^ô&:ô$
>òTôHô&)ðB FJØIMØ8<Ø=AØ#'ØMQØ"ØKOØ"ñl%ð (+öl%r&   