Post-PR-#11 polish: demo UI, staffer console, face pool, icons, contractor profile (24 commits) #12
@ -202,6 +202,147 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
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 */
|
||||
/* Desktop: top nav handles navigation; this dock stays hidden. */
|
||||
/* 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-header">
|
||||
<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>
|
||||
<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
|
||||
four dials for what that shift looks like right now: open permits, workers needed,
|
||||
bench available, and coverage. The panel beside it adds combined bill demand for those
|
||||
permits, deadline pressure on the open queue, the top roles in demand, and a shift
|
||||
comparison strip. <strong>Click any shift</strong> in the comparison to filter every
|
||||
dial and the bill rate to that shift's slice of the internal calendar — past, active,
|
||||
or upcoming. Click the same shift again to return to the all-shifts total. This is the
|
||||
<em>real world</em> the rest of the page is reacting to.
|
||||
Live from the <strong>City of Chicago Open Data</strong> permit feed (Building Permits
|
||||
≥ $250K), cross-referenced against our 500K-worker bench. The console on the left is
|
||||
the punch clock — current time, today's active shift, and four dials watching
|
||||
<strong>open permits</strong>, <strong>workers needed</strong>,
|
||||
<strong>bench depth</strong>, and <strong>projected coverage</strong>. The panel on
|
||||
the right reads the same feed financially: combined bill demand if every permit fills,
|
||||
deadline pressure across <em>overdue / urgent / soon / scheduled</em>, the four roles
|
||||
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>
|
||||
<div id="live-market-hero" style="display:grid;grid-template-columns:240px 1fr;gap:20px;align-items:start">
|
||||
<div style="grid-column:1/-1">
|
||||
@ -2355,8 +2499,12 @@ function loadLiveContracts(){
|
||||
var p=c.permit||{}, prop=c.proposed||{}, tl=c.timeline||{};
|
||||
var urg=tl.urgency||'scheduled';
|
||||
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;
|
||||
// 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
|
||||
var hdr=document.createElement('div');hdr.style.cssText='display:flex;justify-content:space-between;margin-bottom:8px;gap:12px';
|
||||
var left=document.createElement('div');
|
||||
@ -2416,6 +2564,48 @@ function loadLiveContracts(){
|
||||
}
|
||||
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"
|
||||
// as a horizontal bar of cumulative percentages. Drill down that
|
||||
// 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';
|
||||
fpLabel.appendChild(fpTitle);fpLabel.appendChild(fpBase);
|
||||
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');
|
||||
fpBar.className='fp-bar';
|
||||
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){
|
||||
var prev=idx===0?0:fp.curve[idx-1].cumulative_pct;
|
||||
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.textContent='Cumulative chance the role gets fully staffed by that point, given pool depth, urgency, and past fill patterns.';
|
||||
fpRow.appendChild(fpNote);
|
||||
card.appendChild(fpRow);
|
||||
detailsInner.appendChild(fpRow);
|
||||
}
|
||||
|
||||
// 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';
|
||||
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));
|
||||
card.appendChild(ecRow);
|
||||
detailsInner.appendChild(ecRow);
|
||||
}
|
||||
|
||||
// Project Index — portfolio of public-data signals for this permit's
|
||||
@ -2572,22 +2780,13 @@ function loadLiveContracts(){
|
||||
ebBody.appendChild(errDiv);
|
||||
});
|
||||
});
|
||||
card.appendChild(eb);
|
||||
detailsInner.appendChild(eb);
|
||||
}
|
||||
|
||||
// Description
|
||||
if(p.description){
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
desc.textContent=p.description;detailsInner.appendChild(desc);
|
||||
}
|
||||
// Candidates
|
||||
var cands=prop.candidates||[];
|
||||
@ -2615,13 +2814,52 @@ function loadLiveContracts(){
|
||||
sub2.textContent=subText;
|
||||
info.appendChild(nm);info.appendChild(sub2);
|
||||
row.appendChild(av);row.appendChild(info);
|
||||
card.appendChild(row);
|
||||
detailsInner.appendChild(row);
|
||||
});
|
||||
if(cands.length>3){
|
||||
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';
|
||||
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);
|
||||
});
|
||||
}).catch(function(e){
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user