From cd3cd0a6cf2d73c59193a681c956bd34031c264c Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Wed, 26 Aug 2026 22:07:20 -0500 Subject: [PATCH 1/2] feat: masonry board layout, full-bleed width, contradicted hidden by default The fixed 1320px column wasted horizontal space on wide displays and the auto-fit grid stretched every card in a row to the tallest one, leaving ragged whitespace under short events. - main goes full-bleed with fluid gutters instead of a capped column - feed becomes true masonry: 1px auto-rows plus a per-card row span, so cards pack by their own height. Row spacing is the child margin, not row-gap, because a row-gap is charged per 1px row and would coarsen the quantization. - every #feed mutation now goes through setFeed(), which assigns the row span. A node added without one collapses to 1px and paints over the footer, so the chokepoint is load-bearing, not tidiness. - contradicted events drop off the board unless their pill is selected. The pill still carries the true count, so hidden never reads as gone. Card minimum drops 440px to 360px to give masonry more columns to work with. Co-Authored-By: Claude Opus 5 (1M context) --- fintick/dashboard.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/fintick/dashboard.py b/fintick/dashboard.py index 3eff9d1..5a434b5 100644 --- a/fintick/dashboard.py +++ b/fintick/dashboard.py @@ -202,8 +202,10 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, .tape{flex:1 1 auto;min-width:0;min-height:46px;overflow:hidden;display:flex;align-items:center;border:1px solid var(--line);background:var(--panel);white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}.tape-track{display:inline-flex;min-width:max-content;animation:crawl var(--duration,50s) linear infinite}.tape:hover .tape-track{animation-play-state:paused}.tape-group{display:inline-flex}.tick{font-size:12.5px;color:var(--muted)}.tick::before{content:"◆";color:var(--line);margin:0 20px}.tick.breaking{color:var(--breaking)}.tick.confirmed{color:var(--confirmed)}.tick.unconfirmed{color:var(--unconfirmed)} a.tick{text-decoration:none;cursor:pointer}a.tick:hover{text-decoration:underline;text-underline-offset:3px}a.tick:focus-visible{outline:2px solid var(--amber);outline-offset:2px} @keyframes crawl{to{transform:translateX(-50%)}} -main{width:min(1320px,calc(100% - 32px));margin:0 auto;padding:38px 0 76px}.board-head{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:18px}.board-head h2{margin:0;font-size:15px;letter-spacing:.15em;text-transform:uppercase}.board-head p{margin:7px 0 0;color:var(--muted);font-size:11px}.updated{font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums} -.feed{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:16px}.card{position:relative;min-width:0;padding:22px;border:1px solid var(--line);border-top:3px solid var(--state);background:linear-gradient(145deg,var(--card-sheen),transparent 45%),var(--surface)}.card.breaking{--state:var(--breaking);background:linear-gradient(145deg,color-mix(in srgb,var(--breaking) 12%,transparent),transparent 48%),var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--breaking) 12%,transparent),0 15px 50px var(--shadow)}.card.confirmed{--state:var(--confirmed)}.card.developing{--state:var(--developing)}.card.contradicted{--state:var(--contradicted)}.card.unconfirmed{--state:var(--unconfirmed)} +main{width:100%;margin:0;padding:38px clamp(18px,4vw,60px) 76px}.board-head{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:18px}.board-head h2{margin:0;font-size:15px;letter-spacing:.15em;text-transform:uppercase}.board-head p{margin:7px 0 0;color:var(--muted);font-size:11px}.updated{font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums} +/* Masonry rows: 1px keeps span quantization sub-pixel. Row spacing is the child + margin, not row-gap — a row-gap is charged per row and would coarsen the grid. */ +.feed{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));grid-auto-rows:1px;align-items:start;column-gap:16px;row-gap:0}.feed>*{margin-bottom:16px}.card{position:relative;min-width:0;padding:22px;border:1px solid var(--line);border-top:3px solid var(--state);background:linear-gradient(145deg,var(--card-sheen),transparent 45%),var(--surface)}.card.breaking{--state:var(--breaking);background:linear-gradient(145deg,color-mix(in srgb,var(--breaking) 12%,transparent),transparent 48%),var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--breaking) 12%,transparent),0 15px 50px var(--shadow)}.card.confirmed{--state:var(--confirmed)}.card.developing{--state:var(--developing)}.card.contradicted{--state:var(--contradicted)}.card.unconfirmed{--state:var(--unconfirmed)} .card{scroll-margin-top:164px}.card:target{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow);animation:flash 1.1s ease-out}@keyframes flash{0%{box-shadow:0 0 0 3px var(--amber),0 0 30px var(--amber)}100%{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow)}} .badge{display:inline-flex;align-items:center;min-height:32px;padding:7px 10px;border:1px solid color-mix(in srgb,var(--state) 55%,transparent);background:color-mix(in srgb,var(--state) 11%,transparent);color:var(--state);font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase}.badge::before{content:"";width:7px;height:7px;border-radius:50%;margin-right:8px;background:var(--state);box-shadow:0 0 9px var(--state)} .card-meta{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:16px}.importance{color:var(--amber);font-size:9px;letter-spacing:2px}.importance i{color:color-mix(in srgb,var(--text) 22%,transparent);font-style:normal}.headline{margin:0 0 10px;font:650 clamp(17px,1.7vw,21px)/1.38 "IBM Plex Mono","SFMono-Regular",Consolas,monospace;overflow-wrap:anywhere}.summary{margin:0;color:color-mix(in srgb,var(--text) 78%,transparent);font-size:13px;line-height:1.6}.origin{margin-top:14px;color:var(--muted);font-size:10px;letter-spacing:.04em}.origin strong{color:var(--text)} @@ -248,7 +250,11 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, function renderCost(cost){const node=$('cost');if(!node)return;node.replaceChildren();const c=cost&&typeof cost==='object'?cost:{},labels={hour:'1H',day:'24H',week:'7D',month:'30D'};node.append(element('span','lead','inference cost'));for(const key of ['hour','day','week','month']){const w=c[key]||{},usd=Number(w.usd)||0,calls=Number(w.calls)||0,span=element('span','');span.append(document.createTextNode(labels[key]+' '),element('b','','$'+usd.toFixed(usd<1?4:2)));if(calls)span.append(element('span','calls',' ('+calls+' call'+(calls===1?'':'s')+')'));node.append(span)}} const FILTERS=[['all','all'],['breaking','breaking'],['unconfirmed','unconfirmed'],['developing','developing'],['confirmed','confirmed'],['contradicted','contradicted']]; const activeFilters=new Set(); -function statusCount(items,status){return status==='all'?items.length:items.filter(x=>x.status===status).length} +// Contradicted events are off the board unless their pill is selected. The pill still +// carries the real count, so hidden never means gone. +const DEFAULT_HIDDEN=new Set(['contradicted']); +function boardPool(items){return items.filter(x=>!DEFAULT_HIDDEN.has(x.status)||activeFilters.has(x.status))} +function statusCount(items,status){return status==='all'?boardPool(items).length:items.filter(x=>x.status===status).length} function renderMetrics(items){const metrics=$('metrics');metrics.replaceChildren();for(const [status,label] of FILTERS){const on=status==='all'?activeFilters.size===0:activeFilters.has(status),box=element('button','metric '+status+(on?' active':''));box.type='button';box.setAttribute('aria-pressed',String(on));box.append(element('span','',label),element('b','',String(statusCount(items,status))));box.addEventListener('click',()=>{if(status==='all')activeFilters.clear();else if(activeFilters.has(status))activeFilters.delete(status);else activeFilters.add(status);applyFilter()});metrics.append(box)}} function tick(item,hidden){const cls='tick '+safeStatus(item.status),text=String(item.headline||'');if(item.id===undefined||item.id===null)return element('span',cls,text);const link=element('a',cls,text);link.href='#evt-'+item.id;link.title='Jump to this event';if(hidden)link.tabIndex=-1;return link} function renderTape(items){const tape=$('tape');tape.replaceChildren();if(!items.length){tape.append(element('span','tick','No events yet'));return}function group(hidden){const node=element('div','tape-group');if(hidden)node.setAttribute('aria-hidden','true');for(const item of items.slice(0,24))node.append(tick(item,hidden));return node}tape.append(group(false),group(true));tape.style.setProperty('--duration',Math.max(38,items.length*8)+'s')} @@ -257,17 +263,24 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, const instruments=Array.isArray(item.instruments)?item.instruments:[];if(instruments.length){const chips=element('div','chips');for(const instrument of instruments){const direction=['up','down','flat'].includes(instrument.direction)?instrument.direction:'flat';const marker=direction==='up'?'▲ ':direction==='down'?'▼ ':'— ';const chip=element('span','chip '+direction,marker+String(instrument.symbol||instrument.name||''));chip.title=String(instrument.name||instrument.symbol||'Instrument');chips.append(chip)}card.append(chips)} const links=Array.isArray(item.validations)?item.validations:[];if(links.length){const list=element('ul','sources');for(const story of links){if(!story||!story.url)continue;const li=element('li'),link=element('a','',String(story.title||story.url));link.href=String(story.url);link.target='_blank';link.rel='noopener noreferrer';li.append(link,document.createTextNode(' '),element('span','publisher','— '+String(story.publisher||'external news')));list.append(li)}if(list.childNodes.length)card.append(list)}const lag=lagText(item.lead_seconds);if(lag)card.append(element('p','lag',lag));return card} let lastItems=[],lastGeneratedAt=null; -function visibleItems(){return activeFilters.size===0?lastItems:lastItems.filter(x=>activeFilters.has(x.status))} -function renderFeed(){const feed=$('feed'),items=visibleItems(),sel=[...activeFilters];feed.replaceChildren();if(!items.length)feed.append(element('div','empty',lastItems.length?('No '+(sel.length?sel.join(' / ')+' ':'')+'events to show.'):'No aggregated events yet. Ingest the stream, then run FinTick aggregate.'));else for(const item of items)feed.append(renderCard(item));const generated=new Date(lastGeneratedAt),total=lastItems.length,shown=items.length,count=sel.length===0?total+' events':shown+' of '+total+' · '+sel.join(' + '),stamp=Number.isNaN(generated.valueOf())?'':' · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('updated').textContent=count+stamp} +function visibleItems(){const pool=boardPool(lastItems);return activeFilters.size===0?pool:pool.filter(x=>activeFilters.has(x.status))} +function renderFeed(){const items=visibleItems(),sel=[...activeFilters];setFeed(items.length?items.map(item=>renderCard(item)):[element('div','empty',lastItems.length?('No '+(sel.length?sel.join(' / ')+' ':'')+'events to show.'):'No aggregated events yet. Ingest the stream, then run FinTick aggregate.')]);const generated=new Date(lastGeneratedAt),total=boardPool(lastItems).length,shown=items.length,count=sel.length===0?total+' events':shown+' of '+total+' · '+sel.join(' + '),stamp=Number.isNaN(generated.valueOf())?'':' · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('updated').textContent=count+stamp} +// Every #feed mutation goes through setFeed: the CSS declares 1px rows, so a node +// added without a matching row span collapses to 1px and paints over the footer. +function setFeed(nodes){const feed=$('feed');feed.replaceChildren();for(const node of nodes)feed.append(node);layoutMasonry()} +function layoutMasonry(){const feed=$('feed');if(!feed)return;const cards=[...feed.children];if(!cards.length)return;const styles=getComputedStyle(feed),row=parseFloat(styles.gridAutoRows)||1,gap=parseFloat(styles.rowGap)||0,spacer=parseFloat(getComputedStyle(cards[0]).marginBottom)||0;try{for(const c of cards)c.style.gridRowEnd='';const heights=cards.map(c=>c.getBoundingClientRect().height);cards.forEach((c,i)=>{const h=heights[i];if(h)c.style.gridRowEnd='span '+Math.max(1,Math.ceil((h+spacer+gap)/(row+gap)))});feed.style.gridAutoRows=''}catch(error){/* Half-spanned cards overlap and are unreadable; a plain grid is not. */ +for(const c of cards)c.style.gridRowEnd='';feed.style.gridAutoRows='auto'}} function applyFilter(){renderMetrics(lastItems);renderFeed()} -function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(lastItems);applyFilter()} +function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(boardPool(lastItems));applyFilter()} function currentTheme(){const set=document.documentElement.dataset.theme;if(set==='light'||set==='dark')return set;return matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'} function syncThemeIcon(){$('theme-toggle').textContent=currentTheme()==='light'?'☀':'☾'} function toggleTheme(){const next=currentTheme()==='light'?'dark':'light';document.documentElement.dataset.theme=next;try{localStorage.setItem('fintick-theme',next)}catch(e){}syncThemeIcon()} $('theme-toggle').addEventListener('click',toggleTheme);syncThemeIcon(); matchMedia('(prefers-color-scheme: light)').addEventListener('change',()=>{if(!document.documentElement.dataset.theme)syncThemeIcon()}); -let refreshInFlight=false;async function refresh(){if(refreshInFlight)return;refreshInFlight=true;try{const response=await fetch('/api/feed',{cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);render(await response.json())}catch(error){$('connection').textContent='RECONNECTING';$('pulse').classList.add('error');if(!$('feed').querySelector('.card'))$('feed').replaceChildren(element('div','empty error-message','Event feed unavailable. FinTick will retry automatically.'))}finally{refreshInFlight=false}} -refresh(); setInterval(refresh, 20000); +let refreshInFlight=false;async function refresh(){if(refreshInFlight)return;refreshInFlight=true;try{const response=await fetch('/api/feed',{cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);render(await response.json())}catch(error){$('connection').textContent='RECONNECTING';$('pulse').classList.add('error');if(!$('feed').querySelector('.card'))setFeed([element('div','empty error-message','Event feed unavailable. FinTick will retry automatically.')])}finally{refreshInFlight=false}} +let masonryTimer=null;function scheduleMasonry(){if(masonryTimer)clearTimeout(masonryTimer);masonryTimer=setTimeout(layoutMasonry,120)} +addEventListener('resize',scheduleMasonry);if(document.fonts&&document.fonts.ready)document.fonts.ready.then(layoutMasonry); +layoutMasonry(); refresh(); setInterval(refresh, 20000); ''' From 5e85ced75e171d5aba81d9d64c590af5b12e2d90 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Thu, 27 Aug 2026 00:15:53 -0500 Subject: [PATCH 2/2] feat: masonry the fact grids, derive the target offset from the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes found while verifying the board layout in a browser. The fact grids inside each card had the same problem the board just lost: every row stretched to its tallest box, so a one-line price sat in a box padded out to match a three-line yield note next to it. They now pack by their own height using the same row-span technique, so a short fact rises into the gap beside a tall one. The packing maths is now shared as packGrid() rather than duplicated. Fact grids pack before the cards are measured, because reclaiming their dead space shortens the card, and measuring first would bake in the taller pre-pack height and leave a gap under the content. Separately, scroll-margin-top was a hardcoded 164px while the header measures 169px, so clicking a ticker headline landed the card five pixels behind the header. A constant cannot be right here: the operator telemetry rows are hidden from public visitors, so the header is a different height per viewer, and it wraps taller as the viewport narrows — 319px at mobile width, where the old constant would have hidden the card by 155px. The offset now derives from the measured header, republished on resize and after each render. Verified in a browser over 100 real events: no card or fact overlaps and no horizontal overflow at either 1920px (4 columns) or 492px (1 column), fact boxes no longer stretch, targeted cards clear the header by 14px at both widths, and contradicted events stay off the board while their pill keeps the true count. Co-Authored-By: Claude Opus 5 (1M context) --- fintick/dashboard.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/fintick/dashboard.py b/fintick/dashboard.py index 5a434b5..fd4bb7a 100644 --- a/fintick/dashboard.py +++ b/fintick/dashboard.py @@ -206,10 +206,10 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, /* Masonry rows: 1px keeps span quantization sub-pixel. Row spacing is the child margin, not row-gap — a row-gap is charged per row and would coarsen the grid. */ .feed{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));grid-auto-rows:1px;align-items:start;column-gap:16px;row-gap:0}.feed>*{margin-bottom:16px}.card{position:relative;min-width:0;padding:22px;border:1px solid var(--line);border-top:3px solid var(--state);background:linear-gradient(145deg,var(--card-sheen),transparent 45%),var(--surface)}.card.breaking{--state:var(--breaking);background:linear-gradient(145deg,color-mix(in srgb,var(--breaking) 12%,transparent),transparent 48%),var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--breaking) 12%,transparent),0 15px 50px var(--shadow)}.card.confirmed{--state:var(--confirmed)}.card.developing{--state:var(--developing)}.card.contradicted{--state:var(--contradicted)}.card.unconfirmed{--state:var(--unconfirmed)} -.card{scroll-margin-top:164px}.card:target{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow);animation:flash 1.1s ease-out}@keyframes flash{0%{box-shadow:0 0 0 3px var(--amber),0 0 30px var(--amber)}100%{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow)}} +.card{scroll-margin-top:calc(var(--header-h,164px) + 14px)}.card:target{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow);animation:flash 1.1s ease-out}@keyframes flash{0%{box-shadow:0 0 0 3px var(--amber),0 0 30px var(--amber)}100%{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow)}} .badge{display:inline-flex;align-items:center;min-height:32px;padding:7px 10px;border:1px solid color-mix(in srgb,var(--state) 55%,transparent);background:color-mix(in srgb,var(--state) 11%,transparent);color:var(--state);font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase}.badge::before{content:"";width:7px;height:7px;border-radius:50%;margin-right:8px;background:var(--state);box-shadow:0 0 9px var(--state)} .card-meta{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:16px}.importance{color:var(--amber);font-size:9px;letter-spacing:2px}.importance i{color:color-mix(in srgb,var(--text) 22%,transparent);font-style:normal}.headline{margin:0 0 10px;font:650 clamp(17px,1.7vw,21px)/1.38 "IBM Plex Mono","SFMono-Regular",Consolas,monospace;overflow-wrap:anywhere}.summary{margin:0;color:color-mix(in srgb,var(--text) 78%,transparent);font-size:13px;line-height:1.6}.origin{margin-top:14px;color:var(--muted);font-size:10px;letter-spacing:.04em}.origin strong{color:var(--text)} -.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:8px;margin-top:18px}.fact{padding:10px;border:1px solid var(--inset-line);background:var(--lift)}.fact b{display:block;color:var(--text);font-size:14px;margin-bottom:3px}.fact span{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em} +.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));grid-auto-rows:1px;align-items:start;column-gap:8px;row-gap:0;margin-top:18px}.fact{margin-bottom:8px;padding:10px;border:1px solid var(--inset-line);background:var(--lift)}.fact b{display:block;color:var(--text);font-size:14px;margin-bottom:3px}.fact span{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em} .chips{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}.chip{padding:5px 8px;border:1px solid var(--inset-line);background:var(--lift);font-size:10px}.chip.up{color:var(--confirmed);border-color:color-mix(in srgb,var(--confirmed) 45%,var(--line))}.chip.down{color:var(--breaking);border-color:color-mix(in srgb,var(--breaking) 45%,var(--line))} .sources{margin:18px 0 0;padding:15px 0 0;border-top:1px solid var(--line);list-style:none}.sources li+li{margin-top:9px}.sources a{color:var(--cyan);font-size:11px;line-height:1.45;text-decoration:underline;text-decoration-color:rgba(117,203,208,.35);text-underline-offset:3px}.sources a:focus-visible{outline:2px solid var(--amber);outline-offset:3px}.publisher{color:var(--muted);font-size:10px}.lag{margin-top:12px;color:var(--confirmed);font-size:10px}.empty{grid-column:1/-1;padding:90px 24px;border:1px dashed var(--line);color:var(--muted);text-align:center;line-height:1.8}.error-message{color:var(--breaking)} footer{padding:20px;border-top:1px solid var(--line);color:var(--dim);text-align:center;font-size:9px;letter-spacing:.1em} @@ -268,10 +268,19 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, // Every #feed mutation goes through setFeed: the CSS declares 1px rows, so a node // added without a matching row span collapses to 1px and paints over the footer. function setFeed(nodes){const feed=$('feed');feed.replaceChildren();for(const node of nodes)feed.append(node);layoutMasonry()} -function layoutMasonry(){const feed=$('feed');if(!feed)return;const cards=[...feed.children];if(!cards.length)return;const styles=getComputedStyle(feed),row=parseFloat(styles.gridAutoRows)||1,gap=parseFloat(styles.rowGap)||0,spacer=parseFloat(getComputedStyle(cards[0]).marginBottom)||0;try{for(const c of cards)c.style.gridRowEnd='';const heights=cards.map(c=>c.getBoundingClientRect().height);cards.forEach((c,i)=>{const h=heights[i];if(h)c.style.gridRowEnd='span '+Math.max(1,Math.ceil((h+spacer+gap)/(row+gap)))});feed.style.gridAutoRows=''}catch(error){/* Half-spanned cards overlap and are unreadable; a plain grid is not. */ -for(const c of cards)c.style.gridRowEnd='';feed.style.gridAutoRows='auto'}} +// Pack one 1px-row grid: measure every child, give it a row span matching its own +// height. Used for both the board and the fact grids inside each card. +function packGrid(grid){const kids=[...grid.children];if(!kids.length)return;const styles=getComputedStyle(grid),row=parseFloat(styles.gridAutoRows)||1,gap=parseFloat(styles.rowGap)||0,spacer=parseFloat(getComputedStyle(kids[0]).marginBottom)||0;for(const k of kids)k.style.gridRowEnd='';const heights=kids.map(k=>k.getBoundingClientRect().height);kids.forEach((k,i)=>{const h=heights[i];if(h)k.style.gridRowEnd='span '+Math.max(1,Math.ceil((h+spacer+gap)/(row+gap)))})} +function unpackGrid(grid){for(const k of grid.children)k.style.gridRowEnd='';grid.style.gridAutoRows='auto'} +function layoutMasonry(){const feed=$('feed');if(!feed)return;const cards=[...feed.children];if(!cards.length)return;const factGrids=[...feed.querySelectorAll('.facts')];try{ +// Facts pack FIRST: reclaiming their dead space shortens the card, so measuring +// cards before this would bake in the taller pre-pack height and leave a gap. +for(const grid of factGrids)packGrid(grid);packGrid(feed);feed.style.gridAutoRows=''}catch(error){/* Half-spanned cards overlap and are unreadable; a plain grid is not. */ +for(const grid of factGrids)unpackGrid(grid);unpackGrid(feed)}} +function syncHeaderOffset(){const header=document.querySelector('header');if(!header)return;const h=Math.round(header.getBoundingClientRect().height);if(h)document.documentElement.style.setProperty('--header-h',h+'px')} +addEventListener('resize',syncHeaderOffset);syncHeaderOffset(); function applyFilter(){renderMetrics(lastItems);renderFeed()} -function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(boardPool(lastItems));applyFilter()} +function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(boardPool(lastItems));applyFilter();syncHeaderOffset()} function currentTheme(){const set=document.documentElement.dataset.theme;if(set==='light'||set==='dark')return set;return matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'} function syncThemeIcon(){$('theme-toggle').textContent=currentTheme()==='light'?'☀':'☾'} function toggleTheme(){const next=currentTheme()==='light'?'dark':'light';document.documentElement.dataset.theme=next;try{localStorage.setItem('fintick-theme',next)}catch(e){}syncThemeIcon()}