fix(dashboard): scope thumbs-up to a story and restore refreshes

This commit is contained in:
Fizzlepoof
2026-05-14 19:35:00 +00:00
parent 50ca6361c1
commit 255afad3eb
2 changed files with 92 additions and 27 deletions

View File

@@ -277,7 +277,12 @@ def card(i:dict[str,Any], compact:bool=False)->str:
topic=(i.get('category') or 'other').strip() or 'other'
source=(i.get('source') or '').strip()
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
feedback_html='' if not compact else f"<div class='feedback'><button type='button' class='feedback-btn' data-feedback='up' title='More like this topic'>👍</button><button type='button' class='feedback-btn' data-feedback='down' title='Hide this topic'>👎</button></div>"
feedback_html='' if not compact else (
f"<div class='feedback'>"
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Hide this story and de-prioritize {esc(topic)}'>👎</button>"
f"</div>"
)
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
@@ -335,23 +340,34 @@ def paperless_review_section()->str:
continue
(acknowledged if status=='acknowledged' else active).append(item)
def render_card(item:dict[str,Any], acknowledged_card:bool=False)->str:
triage=item.get('triage',{}) or {}
current=item.get('current',{}) or {}
urgency=triage.get('urgency','none')
summary=strip_markup(triage.get('summary',''))
reason=strip_markup(triage.get('reason',''))
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
meta=''.join(str(x) for x in meta_parts)
status_class=' acknowledged' if acknowledged_card else ''
return f"""<article class='paperless-card {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
<div class='meta'><span class='urgency {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}' data-urgency='{esc(item.get('triage',{}).get('urgency','none'))}'>{esc(item.get('triage',{}).get('urgency','none').upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div>
<h4>{esc(item.get('title') or item.get('triage',{}).get('suggested_title','Untitled'))}</h4>
<p class='summary'>{esc(item.get('triage',{}).get('summary',''))}</p>
<p class='why'>Type: {esc(item.get('triage',{}).get('document_type',''))} | Correspondent: {esc(item.get('current',{}).get('correspondent',''))} | {esc(item.get('triage',{}).get('reason',''))}</p>
<div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div>
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
</article>"""
active_cards=''.join(render_card(item,False) for item in active[:8])
visible_active=active[:3]
overflow_active=active[3:]
active_cards=''.join(render_card(item,False) for item in visible_active)
acked_cards=''.join(render_card(item,True) for item in acknowledged[:8])
count=len(active)
ack_count=len(acknowledged)
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
if overflow_active:
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
if ack_count:
body += f"<details class='paperless-acked'><summary>Acknowledged <span class='badge'>{esc(str(ack_count))}</span></summary><div class='paperless-grid acked-grid'>{acked_cards}</div></details>"
if count+ack_count>8:
body += f"<p class='empty'>Showing {esc(str(min(count,8)))} active and {esc(str(min(ack_count,8)))} acknowledged items from the local queue snapshot.</p>"
if count+ack_count>3:
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{ack_count} acknowledged · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=count>0,extra_class='panel')
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
@@ -374,34 +390,66 @@ def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
rows=[]
for x in items[:6]:
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
subtitle=''.join([p for p in [x.get('subtitle') or '', x.get('assignee') or ''] if p])
subtitle_text=(x.get('subtitle') or '').strip()
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
subtitle_parts=[]
for part in [p.strip() for p in subtitle_text.split('') if p.strip()]:
if part.lower() in chip_names:
continue
subtitle_parts.append(part)
subtitle=''.join(subtitle_parts)
state=esc(x.get('state') or 'upcoming')
due_style=''
if state=='overdue': due_style=' style="color:#ff6b6b;"'
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(x.get("subtitle") or "")}</div>' if x.get('subtitle') else ''}</article>")
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(subtitle)}</div>' if subtitle else ''}</article>")
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
def feedback_bootstrap()->str:
return """<script>
(()=>{
const KEY='doris-dashboard-topic-prefs-v2';
const HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"liked":[],"hidden":[]}')}catch(e){return {liked:[],hidden:[]}}};
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({liked:[...new Set(prefs.liked||[])], hidden:[...new Set(prefs.hidden||[])]}));
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
const prefs=safeRead();
let hiddenCards=safeHiddenCards();
const collapsed=safeCollapse();
const cards=[...document.querySelectorAll('[data-topic]')];
const sections=[...document.querySelectorAll('details[data-section-key]')];
const toast=document.createElement('div');
toast.className='dashboard-toast';
toast.hidden=true;
document.body.appendChild(toast);
let lastAction=null;
let toastTimer=null;
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
const cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
const setToast=(html)=>{
toast.innerHTML=html;
toast.hidden=false;
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
};
const sync=()=>{
cards.forEach(card=>{
const topic=card.dataset.topic||'other';
const hidden=(prefs.hidden||[]).includes(topic);
const liked=(prefs.liked||[]).includes(topic);
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
const downMuted=(prefs.hidden||[]).includes(topic);
card.style.display=hidden?'none':'';
card.classList.toggle('topic-liked', liked);
card.classList.remove('topic-liked');
card.querySelectorAll('[data-feedback]').forEach(btn=>{
const mode=btn.dataset.feedback;
const pressed=(mode==='down' && downMuted);
btn.setAttribute('aria-pressed', pressed ? 'true' : 'false');
btn.classList.toggle('active-up', false);
btn.classList.toggle('active-down', mode==='down' && downMuted);
});
});
const badge=document.querySelector('[data-hidden-topics-count]');
if (badge) badge.textContent=String((prefs.hidden||[]).length);
@@ -420,18 +468,33 @@ def feedback_bootstrap()->str:
const card=btn.closest('[data-topic]');
if(!card) return;
const topic=card.dataset.topic||'other';
const id=card.dataset.cardId||'';
if(btn.dataset.feedback==='down'){
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
hiddenCards=hiddenCards.filter(x=>x!==id);
hiddenCards.push(id);
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
prefs.hidden.push(topic);
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
saveHiddenCards(hiddenCards);
setToast(`Hid this story and muted <strong>${topic}</strong> for later. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
} else {
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
prefs.liked.push(topic);
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
hiddenCards=hiddenCards.filter(x=>x!==id);
hiddenCards.push(id);
saveHiddenCards(hiddenCards);
setToast(`Right — Ill look for more <strong>${topic}</strong> like this, and Ive cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
}
save(prefs); sync();
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
});
document.addEventListener('click',(ev)=>{
const undo=ev.target.closest('[data-undo-topic]');
if(!undo || !lastAction) return;
prefs.hidden=[...(lastAction.prefs.hidden||[])];
hiddenCards=[...(lastAction.hiddenCards||[])];
save(prefs); saveHiddenCards(hiddenCards); sync();
setToast('Undid the last topic/story action.');
});
document.addEventListener('click',(ev)=>{
const btn=ev.target.closest('[data-paperless-action]');
if(!btn) return;
@@ -449,7 +512,7 @@ def feedback_bootstrap()->str:
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
});
const reset=document.querySelector('[data-reset-topic-prefs]');
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; prefs.liked=[]; save(prefs); sync(); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
sync();
})();
</script>"""
@@ -512,16 +575,16 @@ def render()->str:
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
forecast_html=forecast_panel(weather)
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=True)}
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central · auto-refreshes at :05 / :35</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False)}
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=True)}
{forecast_html}
{paperless_review_section()}
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False)}
{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)}
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:6],'No sky signal right now.',compact=True)}
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:5],'No sky signal right now.',compact=True)}
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:4],'No local signal right now.',compact=True)}
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True)}
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""

View File

@@ -2,10 +2,12 @@
/* Compact article layout: keep the number of stories, reduce scroll debt. */
.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}}
.feedback-btn[aria-pressed='true']{font-weight:700}.feedback-btn.active-up{border-color:rgba(94,231,135,.65);background:rgba(94,231,135,.12);color:#c8ffd9}.feedback-btn.active-down{border-color:rgba(255,107,107,.6);background:rgba(255,107,107,.12);color:#ffd9d9}.dashboard-toast{position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 24px));background:rgba(12,17,27,.96);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:16px;padding:12px 14px;color:var(--text);box-shadow:0 14px 34px rgba(0,0,0,.35)}.dashboard-toast[hidden]{display:none}.toast-action{margin-left:10px;background:transparent;border:0;color:var(--accent);font:inherit;font-weight:700;cursor:pointer;padding:0}
.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92}
.donetick-panel .task-grid{gap:8px}.task-card{padding:9px 10px;border-radius:14px}.task-top{gap:6px;margin-bottom:3px}.task-state-dot{width:9px;height:9px;border-radius:999px;background:var(--muted);flex:0 0 auto;box-shadow:0 0 0 2px rgba(255,255,255,.03)}.task-card.overdue .task-state-dot{background:var(--warn)}.task-card.today .task-state-dot{background:var(--local)}.task-card.tomorrow .task-state-dot,.task-card.upcoming .task-state-dot{background:#8ecae6}.task-card.nodue .task-state-dot{background:#6c7a90}.task-due{font-size:.76rem;line-height:1.1}.task-name{font-size:.95rem;line-height:1.15;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.task-subtitle{font-size:.74rem;line-height:1.1;opacity:.9;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.task-chips{gap:4px;margin-bottom:4px}.task-chip{padding:2px 7px;font-size:.68rem}
/* Forecast */
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
/* Paperless review queue */
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:12px 14px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.acknowledged{opacity:.7}.paperless-card h4{margin:.4rem 0;font-size:1rem}.paperless-card .summary{margin:.35rem 0;color:#d7e3f5}.paperless-card .link{display:inline-block;margin-top:.25rem;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:6px 10px;font:inherit;cursor:pointer}.paperless-btn.danger{border-color:rgba(255,107,107,.4);color:#ffd9d9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:650px){.paperless-grid{grid-template-columns:1fr}}
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.acknowledged{opacity:.7}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.danger{border-color:rgba(255,107,107,.4);color:#ffd9d9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}}