demo: search.html — Live Market explainer rewrite + fp-bar viewport-paint + compact contract cards

Four UI changes landing together since they all polish Section ① and
Section ② of the public demo:

1. Section ① (Live Market — Chicago) explainer rewritten data-source-
   first ("Live from City of Chicago Open Data...") with bolded dial
   names so a skimmer can map the visual to the prose. Drops the
   "internal calendar" jargon and the slightly-overclaiming "rest of
   the page is reacting" framing — downstream sections read the same
   feed but don't react to the per-shift filter, so the new copy says
   "this row is its heartbeat" instead.

2. Fill-probability bar gets a left-to-right paint reveal (clip-path
   inset animation) so the green→gold→orange→red gradient reads as a
   *timeline growing* instead of a static heatmap with a "danger zone"
   at the right. Followed by a 30%-wide shimmer sweep on a 3.4s loop
   for live-signal feel.

3. Paint trigger moved from on-render to IntersectionObserver — by
   the time the user scrolls to Section ② the on-render animation had
   already finished. Now each bar paints in over 2.8s when it enters
   viewport (threshold 0.2, 350ms entry delay). Single shared observer,
   unobserve()s after firing so the watch list trends to zero.

4. Contract cards now compact-by-default with click-to-expand. New
   summary strip shows revenue / margin / fill-by-1wk / top candidate
   so scanners get the punchline without expanding. Click anywhere on
   the card surface (excluding inner content) to expand the full FP
   curve, economics grid, candidates list, and Project Index. Project
   Index auto-opens with the parent card so users actually find the
   build signals — but only on user-driven expand (avoiding 20× OSHA
   scrapes on page load). grid-template-rows: 0fr → 1fr animation
   handles the smooth height transition.

All four animations honor prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-28 05:56:48 -05:00
parent 3c6d2c5f74
commit dcf4c9a8e7

View File

@ -202,6 +202,147 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
50% { opacity:1; transform:scale(1.1) } 50% { opacity:1; transform:scale(1.1) }
} }
/* ─── Fill-probability bar — viewport-triggered paint + shimmer ─────
The bar paints in left-to-right via a clip-path animation so the
green → gold → orange → red gradient reads as a *timeline growing*
instead of a static heatmap with "danger zone" at the right edge.
Triggering: the .fp-bar starts in a fully-clipped state and only
paints when JS adds .lit via IntersectionObserver — fires when the
bar enters viewport, so a fast scroller sees every bar animate as
it scrolls past, instead of missing the show on first paint.
Timing: 350ms entry delay (lets the eye lock onto the bar before
motion starts) + 2800ms paint (slow enough to actually watch the
timeline build). Easing matches the card-in stagger (cubic-bezier
.2 .7 .2 1) so the page reads as one consistent motion language.
Shimmer: 30%-wide highlight sweeps across every ~3.4s on a 3400ms
delay (350 entry + 2800 paint + 250 dwell) so the two motions
never compete — the bar finishes drawing, *then* it pulses. */
@keyframes fp-paint {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes fp-shimmer {
0% { transform: translateX(-100%); opacity: 0 }
10% { opacity: 1 }
60% { transform: translateX(260%); opacity: 1 }
61%, 100% { transform: translateX(260%); opacity: 0 }
}
.fp-bar {
position: relative;
/* Hidden until .lit is added — IntersectionObserver fires the paint
animation by toggling the class, which is the only thing that
unmasks the clip-path. */
clip-path: inset(0 100% 0 0);
}
.fp-bar.lit {
animation: fp-paint 2800ms cubic-bezier(.2,.7,.2,1) 350ms both;
}
.fp-bar::after {
content: "";
position: absolute;
top: 0; left: 0;
width: 30%;
height: 100%;
background: linear-gradient(90deg,
transparent 0%,
rgba(255,255,255,0.18) 50%,
transparent 100%);
pointer-events: none;
}
.fp-bar.lit::after {
animation: fp-shimmer 3.4s ease-in-out 3400ms infinite;
}
@media (prefers-reduced-motion: reduce) {
/* Skip the clip-path hide too — otherwise reduced-motion users
would see permanently invisible bars. The .lit class still gets
added by JS but the animation is `none`, so bar just appears. */
.fp-bar { clip-path: none }
.fp-bar.lit { animation: none }
.fp-bar.lit::after { animation: none; opacity: 0 }
}
/* ─── Staffer's Console: compact contract card with click-to-expand ─
Each card stays compact by default so a coordinator scanning 20+
contracts sees ~5 cards per viewport instead of ~1.5. The summary
strip below the pills shows revenue / margin / fill-by-1wk / top
candidate so scanners get the punchline without expanding. The
whole card surface is clickable; clicks inside the expanded details
don't bubble to the toggle (so contractor links / SMS copy still
work). When a card expands, its Project Index auto-opens too —
solves the "users aren't finding the build signals" gap without
firing 20× OSHA scrapes on page load. */
.contract-card {
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease;
}
.contract-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0,0,0,0.32), 0 0 0 1px #30363d;
}
.contract-card.expanded { cursor: default }
.contract-card.expanded:hover { transform: none }
.contract-card .card-strip {
background: #0d1117;
border: 1px solid #171d27;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 11px;
color: #8b949e;
align-items: baseline;
}
.contract-card .card-strip strong {
font-weight: 600;
font-variant-numeric: tabular-nums;
margin-right: 3px;
}
/* Modern smooth-expand: grid-template-rows transitions cleanly to
the actual content height. Safari 17.4+ / Chrome 117+ / FF 117+. */
.contract-card .card-details {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 380ms cubic-bezier(.2,.7,.2,1);
}
.contract-card.expanded .card-details { grid-template-rows: 1fr }
.contract-card .card-details > .card-details-inner {
overflow: hidden;
min-height: 0;
}
.contract-card.expanded .card-details > .card-details-inner {
padding-top: 4px;
}
.contract-card .card-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0 2px;
font-size: 10px;
color: #545d68;
letter-spacing: 1.5px;
text-transform: uppercase;
user-select: none;
}
.contract-card .card-toggle .chevron {
display: inline-block;
transition: transform 320ms cubic-bezier(.2,.7,.2,1);
font-size: 12px;
line-height: 1;
}
.contract-card.expanded .card-toggle .chevron { transform: rotate(180deg) }
@media (prefers-reduced-motion: reduce) {
.contract-card { transition: none }
.contract-card:hover { transform: none }
.contract-card .card-details { transition: none }
.contract-card .card-toggle .chevron { transition: none }
}
/* Bottom section-jump nav — mobile only */ /* Bottom section-jump nav — mobile only */
/* Desktop: top nav handles navigation; this dock stays hidden. */ /* Desktop: top nav handles navigation; this dock stays hidden. */
/* Mobile: top nav collapses (.bar nav:display:none below); this fixed dock */ /* Mobile: top nav collapses (.bar nav:display:none below); this fixed dock */
@ -503,17 +644,20 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
<div class="section" id="live-market-section"> <div class="section" id="live-market-section">
<div class="section-header"> <div class="section-header">
<span class="section-title" style="color:#e6edf3;font-size:13px">① Live Market — Chicago right now</span> <span class="section-title" style="color:#e6edf3;font-size:13px">① Live Market — Chicago right now</span>
<span class="section-meta" id="live-market-meta">Public permit data · per-shift schedule on the internal calendar · click any shift to filter</span> <span class="section-meta" id="live-market-meta">City of Chicago Open Data · cross-referenced against the 500K-worker bench · click any shift to slice the dials</span>
</div> </div>
<p style="color:#8b949e;font-size:11px;margin:0 0 12px;line-height:1.5;max-width:780px"> <p style="color:#8b949e;font-size:11px;margin:0 0 12px;line-height:1.5;max-width:780px">
The console on the left is the live punch clock — current time, the active shift, and Live from the <strong>City of Chicago Open Data</strong> permit feed (Building Permits
four dials for what that shift looks like right now: open permits, workers needed, ≥ $250K), cross-referenced against our 500K-worker bench. The console on the left is
bench available, and coverage. The panel beside it adds combined bill demand for those the punch clock — current time, today's active shift, and four dials watching
permits, deadline pressure on the open queue, the top roles in demand, and a shift <strong>open permits</strong>, <strong>workers needed</strong>,
comparison strip. <strong>Click any shift</strong> in the comparison to filter every <strong>bench depth</strong>, and <strong>projected coverage</strong>. The panel on
dial and the bill rate to that shift's slice of the internal calendar — past, active, the right reads the same feed financially: combined bill demand if every permit fills,
or upcoming. Click the same shift again to return to the all-shifts total. This is the deadline pressure across <em>overdue / urgent / soon / scheduled</em>, the four roles
<em>real world</em> the rest of the page is reacting to. being asked for most, and a shift-mix bar. <strong>Click any shift bar</strong> to
re-slice the dials and the dollar counter to that shift's calendar slice; click again
to clear. The same permit feed drives the staffing forecast, the staffer's console,
and worker matches further down — this row is its heartbeat.
</p> </p>
<div id="live-market-hero" style="display:grid;grid-template-columns:240px 1fr;gap:20px;align-items:start"> <div id="live-market-hero" style="display:grid;grid-template-columns:240px 1fr;gap:20px;align-items:start">
<div style="grid-column:1/-1"> <div style="grid-column:1/-1">
@ -2355,8 +2499,12 @@ function loadLiveContracts(){
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{}; var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
var urg=tl.urgency||'scheduled'; var urg=tl.urgency||'scheduled';
var borderColor={overdue:'#f85149',urgent:'#d29922',soon:'#388bfd',scheduled:'#2ea043'}[urg]||'#388bfd'; var borderColor={overdue:'#f85149',urgent:'#d29922',soon:'#388bfd',scheduled:'#2ea043'}[urg]||'#388bfd';
var card=document.createElement('div');card.className='insight info'; var card=document.createElement('div');card.className='insight info contract-card';
card.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:10px;border-left:3px solid '+borderColor; card.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:16px;margin-bottom:10px;border-left:3px solid '+borderColor;
// Foldable details container — fpRow / ecRow / Project Index /
// description / candidate rows all live here. Hidden until the
// card gets .expanded; CSS animates the grid-template-rows.
var detailsInner=document.createElement('div');detailsInner.className='card-details-inner';
// Header — permit // Header — permit
var hdr=document.createElement('div');hdr.style.cssText='display:flex;justify-content:space-between;margin-bottom:8px;gap:12px'; var hdr=document.createElement('div');hdr.style.cssText='display:flex;justify-content:space-between;margin-bottom:8px;gap:12px';
var left=document.createElement('div'); var left=document.createElement('div');
@ -2416,6 +2564,48 @@ function loadLiveContracts(){
} }
if(pillRow.childNodes.length) card.appendChild(pillRow); if(pillRow.childNodes.length) card.appendChild(pillRow);
// Compact summary strip — at-a-glance numbers so scanners get
// the headline without expanding. Mirrors the colors used in
// the full economics grid below: revenue green, margin tiered.
var strip=document.createElement('div');strip.className='card-strip';
function stripCell(label,value,color){
var s=document.createElement('span');
var b=document.createElement('strong');b.style.color=color||'#e6edf3';b.textContent=value;
s.appendChild(b);s.appendChild(document.createTextNode(label));
return s;
}
if(c.economics){
strip.appendChild(stripCell(' rev','$'+Math.round(c.economics.gross_revenue/1000)+'K','#3fb950'));
var stripMarginColor=c.economics.margin_pct>=25?'#3fb950':c.economics.margin_pct>=10?'#d29922':'#f85149';
strip.appendChild(stripCell(' margin',c.economics.margin_pct+'%',stripMarginColor));
}
if(c.fill_probability&&c.fill_probability.curve&&c.fill_probability.curve.length){
var bucket7=c.fill_probability.curve.find(function(pt){return pt.day===7});
var fillBucket=bucket7||c.fill_probability.curve[0];
var spanLabel=bucket7?' fill by 1wk':' fill by day '+fillBucket.day;
strip.appendChild(stripCell(spanLabel,fillBucket.cumulative_pct+'%','#58a6ff'));
}
var topCand=(prop.candidates||[])[0];
if(topCand&&topCand.name){
// Abbreviate "Maria Sanchez" → "Maria S." so the strip stays one line.
var nameParts=topCand.name.trim().split(/\s+/);
var topShort=nameParts.length>1?nameParts[0]+' '+(nameParts[nameParts.length-1][0]||'')+'.':nameParts[0];
strip.appendChild(stripCell(' top match',topShort,'#e6edf3'));
}
if(strip.childNodes.length) card.appendChild(strip);
// Pattern (meta-index) chip — kept visible on the compact card
// because it's a glance-worthy "we have playbooks for this kind
// of contract" signal that doesn't take much vertical space.
if(c.discovered_pattern){
var pat=document.createElement('div');pat.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
var plabel=document.createElement('span');plabel.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
plabel.textContent='MEMORY ('+c.pattern_matched+' playbooks):';
pat.appendChild(plabel);
pat.appendChild(document.createTextNode(' '+c.discovered_pattern));
card.appendChild(pat);
}
// Fill-probability curve — shows "likelihood of filling by day N" // Fill-probability curve — shows "likelihood of filling by day N"
// as a horizontal bar of cumulative percentages. Drill down that // as a horizontal bar of cumulative percentages. Drill down that
// J asked for: "percentage likelihood of filling them on a certain time." // J asked for: "percentage likelihood of filling them on a certain time."
@ -2429,9 +2619,27 @@ function loadLiveContracts(){
var fpBase=document.createElement('span');fpBase.textContent='base '+fp.base_pct+'% · pool × urgency'; var fpBase=document.createElement('span');fpBase.textContent='base '+fp.base_pct+'% · pool × urgency';
fpLabel.appendChild(fpTitle);fpLabel.appendChild(fpBase); fpLabel.appendChild(fpTitle);fpLabel.appendChild(fpBase);
fpRow.appendChild(fpLabel); fpRow.appendChild(fpLabel);
// Horizontal stacked bar — each bucket as a segment // Horizontal stacked bar — each bucket as a segment.
// .fp-bar starts hidden (clip-path); IntersectionObserver adds
// .lit when the bar enters viewport, which fires the paint +
// shimmer animations defined in the <style> block above.
var fpBar=document.createElement('div'); var fpBar=document.createElement('div');
fpBar.className='fp-bar';
fpBar.style.cssText='display:flex;height:8px;border-radius:4px;overflow:hidden;background:#161b22;margin-bottom:6px'; fpBar.style.cssText='display:flex;height:8px;border-radius:4px;overflow:hidden;background:#161b22;margin-bottom:6px';
// Lazy-init a single shared observer — the same instance can
// watch every .fp-bar on the page. unobserve() after firing so
// the bar doesn't re-paint if the user scrolls back past it.
if(!window._lhFpObserver){
window._lhFpObserver=new IntersectionObserver(function(entries){
entries.forEach(function(e){
if(e.isIntersecting){
e.target.classList.add('lit');
window._lhFpObserver.unobserve(e.target);
}
});
},{ threshold: 0.2 });
}
window._lhFpObserver.observe(fpBar);
fp.curve.forEach(function(pt,idx){ fp.curve.forEach(function(pt,idx){
var prev=idx===0?0:fp.curve[idx-1].cumulative_pct; var prev=idx===0?0:fp.curve[idx-1].cumulative_pct;
var delta=pt.cumulative_pct-prev; var delta=pt.cumulative_pct-prev;
@ -2471,7 +2679,7 @@ function loadLiveContracts(){
fpNote.style.cssText='font-size:9px;color:#545d68;margin-top:6px;line-height:1.4'; fpNote.style.cssText='font-size:9px;color:#545d68;margin-top:6px;line-height:1.4';
fpNote.textContent='Cumulative chance the role gets fully staffed by that point, given pool depth, urgency, and past fill patterns.'; fpNote.textContent='Cumulative chance the role gets fully staffed by that point, given pool depth, urgency, and past fill patterns.';
fpRow.appendChild(fpNote); fpRow.appendChild(fpNote);
card.appendChild(fpRow); detailsInner.appendChild(fpRow);
} }
// Economics panel — "as though the contracts were accepted and filled" // Economics panel — "as though the contracts were accepted and filled"
@ -2497,7 +2705,7 @@ function loadLiveContracts(){
var overColor=ec.over_bill_count>0?'#f85149':'#8b949e'; var overColor=ec.over_bill_count>0?'#f85149':'#8b949e';
ecRow.appendChild(ecCell('Over-Bill Pool',ec.over_bill_count+'/'+(prop.candidates||[]).length, ecRow.appendChild(ecCell('Over-Bill Pool',ec.over_bill_count+'/'+(prop.candidates||[]).length,
ec.over_bill_count>0?'$'+ec.over_bill_pool_margin_at_risk.toLocaleString()+' at risk':'none flagged',overColor)); ec.over_bill_count>0?'$'+ec.over_bill_pool_margin_at_risk.toLocaleString()+' at risk':'none flagged',overColor));
card.appendChild(ecRow); detailsInner.appendChild(ecRow);
} }
// Project Index — portfolio of public-data signals for this permit's // Project Index — portfolio of public-data signals for this permit's
@ -2572,22 +2780,13 @@ function loadLiveContracts(){
ebBody.appendChild(errDiv); ebBody.appendChild(errDiv);
}); });
}); });
card.appendChild(eb); detailsInner.appendChild(eb);
} }
// Description // Description
if(p.description){ if(p.description){
var desc=document.createElement('div');desc.style.cssText='color:#94a3b8;font-size:11px;margin-bottom:10px;line-height:1.5'; var desc=document.createElement('div');desc.style.cssText='color:#94a3b8;font-size:11px;margin-bottom:10px;line-height:1.5';
desc.textContent=p.description;card.appendChild(desc); desc.textContent=p.description;detailsInner.appendChild(desc);
}
// Pattern (meta-index) chip
if(c.discovered_pattern){
var pat=document.createElement('div');pat.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5';
var plabel=document.createElement('span');plabel.style.cssText='color:#3fb950;font-weight:600;margin-right:6px';
plabel.textContent='MEMORY ('+c.pattern_matched+' playbooks):';
pat.appendChild(plabel);
pat.appendChild(document.createTextNode(' '+c.discovered_pattern));
card.appendChild(pat);
} }
// Candidates // Candidates
var cands=prop.candidates||[]; var cands=prop.candidates||[];
@ -2615,13 +2814,52 @@ function loadLiveContracts(){
sub2.textContent=subText; sub2.textContent=subText;
info.appendChild(nm);info.appendChild(sub2); info.appendChild(nm);info.appendChild(sub2);
row.appendChild(av);row.appendChild(info); row.appendChild(av);row.appendChild(info);
card.appendChild(row); detailsInner.appendChild(row);
}); });
if(cands.length>3){ if(cands.length>3){
var more=document.createElement('div');more.style.cssText='font-size:10px;color:#58a6ff;padding:4px 10px;margin-top:2px'; var more=document.createElement('div');more.style.cssText='font-size:10px;color:#58a6ff;padding:4px 10px;margin-top:2px';
more.textContent='+ '+(cands.length-3)+' more candidates available'; more.textContent='+ '+(cands.length-3)+' more candidates available';
card.appendChild(more); detailsInner.appendChild(more);
} }
// Wrap the foldable section + add it to the card. The CSS
// .card-details rule animates grid-template-rows: 0fr → 1fr
// when the parent gets .expanded, so this whole block grows
// smoothly to its natural height when the user clicks.
var details=document.createElement('div');details.className='card-details';
details.appendChild(detailsInner);
card.appendChild(details);
// Toggle affordance — small caret + label at the bottom. CSS
// rotates the chevron 180° when expanded; the label flips text
// via the click handler below.
var toggleRow=document.createElement('div');toggleRow.className='card-toggle';
var chevron=document.createElement('span');chevron.className='chevron';chevron.textContent='▾';
var toggleLabel=document.createElement('span');
toggleLabel.textContent='click for full curve, economics, candidates, project index';
toggleRow.appendChild(chevron);toggleRow.appendChild(toggleLabel);
card.appendChild(toggleRow);
// Click handler — expand/collapse + auto-open Project Index when
// expanding. Skips the toggle when the click landed inside the
// already-expanded details (so contractor links / SMS copy /
// <details> toggles inside still work) and when the user is
// selecting text (so a triple-click drag doesn't snap it shut).
card.addEventListener('click',function(e){
if(e.target.closest('.card-details')) return;
if(window.getSelection&&window.getSelection().toString().length>0) return;
var nowExpanded=!card.classList.contains('expanded');
card.classList.toggle('expanded');
toggleLabel.textContent=nowExpanded
? 'click to collapse'
: 'click for full curve, economics, candidates, project index';
// Auto-open the Project Index ONLY on user-driven expand. This
// avoids firing 20+ OSHA scrapes on page load — each open
// triggers /intelligence/permit_entities, which live-scrapes
// OSHA at ~1-2s per contractor.
if(nowExpanded&&typeof eb!=='undefined'&&eb&&!eb.open) eb.open=true;
});
el.appendChild(card); el.appendChild(card);
}); });
}).catch(function(e){ }).catch(function(e){