chore: sync homelab ops, identity, and monitoring docs

This commit is contained in:
Fizzlepoof
2026-05-21 02:01:09 +00:00
parent 4c41b19e13
commit 2e99873634
44 changed files with 6295 additions and 346 deletions

View File

@@ -11,3 +11,4 @@ data/todos.json
data/weather.json
data/paperless_review.json
data/paperless_review_state.json
data/hermes_usage.json

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
import html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
@@ -15,6 +15,9 @@ PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
LAT=36.53; LON=-87.36
LOCAL_TZ=ZoneInfo('America/Chicago')
HERMES_HOME=Path.home()/'.hermes'
HERMES_STATE_DB=HERMES_HOME/'state.db'
HERMES_CONFIG=HERMES_HOME/'config.yaml'
LABELS={
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
@@ -50,6 +53,37 @@ def kmh_to_mph(k:float|int|None)->str:
if k is None: return ''
return f"{round(k*0.621371)} mph"
def compact_number(value:int|float|None)->str:
if value in (None,''): return ''
try: n=float(value)
except Exception: return str(value)
abs_n=abs(n)
if abs_n >= 1_000_000_000: return f"{n/1_000_000_000:.1f}B"
if abs_n >= 1_000_000: return f"{n/1_000_000:.1f}M"
if abs_n >= 1_000: return f"{n/1_000:.1f}K"
if n.is_integer(): return f"{int(n):,}"
return f"{n:,.1f}"
def total_tokens_value(row:dict[str,Any] | sqlite3.Row)->int:
keys=('input_tokens','output_tokens','cache_read_tokens','cache_write_tokens','reasoning_tokens')
total=0
for key in keys:
try: total += int((row[key] if isinstance(row, sqlite3.Row) else row.get(key,0)) or 0)
except Exception: pass
return total
def read_hermes_model_config()->dict[str,str]:
default='unknown'; provider='unknown'
try:
data=yaml.safe_load(HERMES_CONFIG.read_text(encoding='utf-8')) or {}
except Exception:
return {'default_model':default,'provider':provider}
model_cfg=data.get('model') if isinstance(data,dict) else {}
if isinstance(model_cfg,dict):
default=str(model_cfg.get('default') or default)
provider=str(model_cfg.get('provider') or provider)
return {'default_model':default or 'unknown','provider':provider or 'unknown'}
def moon_phase(dt:datetime)->dict[str,str]:
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
@@ -227,6 +261,51 @@ def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
]
def system_snapshot()->list[dict[str,str]]:
uptime_text='unknown'
try:
uptime_seconds=float(Path('/proc/uptime').read_text().split()[0])
hours=int(uptime_seconds//3600)
minutes=int((uptime_seconds%3600)//60)
uptime_text=(f'{hours}h {minutes}m' if hours else f'{minutes}m')
except Exception:
pass
load_text='unknown'
load_state='ok'
try:
load1,load5,load15=os.getloadavg()
load_text=f'{load1:.2f} / {load5:.2f} / {load15:.2f}'
cpu_count=os.cpu_count() or 1
if load1 > cpu_count*1.25:
load_state='warn'
except Exception:
load_state='warn'
mem_text='unknown'
mem_state='ok'
try:
parts=Path('/proc/meminfo').read_text().splitlines()
raw={line.split(':',1)[0]: int(line.split(':',1)[1].strip().split()[0]) for line in parts if ':' in line}
total=raw.get('MemTotal',0)
avail=raw.get('MemAvailable',0)
used=max(total-avail,0)
pct=(used/total*100) if total else 0
mem_text=f'{pct:.0f}% used ({used/1024/1024:.1f} / {total/1024/1024:.1f} GiB)'
if pct >= 85:
mem_state='warn'
except Exception:
mem_state='warn'
root_usage=shutil.disk_usage('/')
root_pct=root_usage.used/root_usage.total*100 if root_usage.total else 0
root_text=f'{root_pct:.0f}% used ({root_usage.free/1024**3:.1f} GiB free)'
return [
{'label':'Host','value':run(['hostname']) or 'nomad','state':'ok'},
{'label':'Uptime','value':uptime_text,'state':'ok'},
{'label':'Load avg','value':load_text,'state':load_state},
{'label':'Memory','value':mem_text,'state':mem_state},
{'label':'Root disk','value':root_text,'state':'warn' if root_pct >= 85 else 'ok'},
]
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower()
if item.get('category') in prefs.get('boost_categories',[]): score+=2
@@ -313,7 +392,7 @@ def why(i:dict[str,Any])->str:
return f'Interesting signal from {src}.'
def card(i:dict[str,Any], compact:bool=False)->str:
summary=(i.get('summary') or '').strip()[:170 if compact else 300]
summary=(i.get('summary') or '').strip()[:120 if compact else 260]
when=(i.get('published_at','')[:16].replace('T',' '))
cls='compact-card' if compact else 'card'
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
@@ -371,6 +450,21 @@ def load_paperless_review()->list[dict[str,Any]]:
def load_paperless_state()->dict[str,Any]:
return load_json(PAPERLESS_STATE_PATH,{}) or {}
def paperless_focus_summary(items:list[dict[str,Any]]|None=None,state:dict[str,Any]|None=None)->dict[str,Any]:
items=items if items is not None else load_paperless_review()
state=state if state is not None else load_paperless_state()
active=[]; flagged=[]
for item in items:
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
if status in {'dismissed','good_to_go'}:
continue
if status in {'acknowledged','needs_attention'}:
flagged.append(item)
else:
active.append(item)
headline=(flagged[0] if flagged else (active[0] if active else {})).get('title') or 'No documents waiting'
return {'active_count':len(active),'flagged_count':len(flagged),'headline':headline}
def paperless_review_section()->str:
items=load_paperless_review()
state=load_paperless_state()
@@ -408,7 +502,8 @@ def paperless_review_section()->str:
flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
count=len(active)
flag_count=len(needs_attention)
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
empty_active="<p class='empty'>No active documents waiting.</p>"
body=f"<div class='paperless-grid'>{active_cards or empty_active}</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 flag_count:
@@ -427,7 +522,10 @@ def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
headline=x.get('title') if x.get('time') else ''
assignee=x.get('assignee') or ''
detail=x.get('body') or x.get('summary') or x.get('value') or ''
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
assignee_html=f"<span class='muted'>{esc(assignee)}</span>" if assignee else ''
headline_html=f"<div class='panel-item-title'>{esc(headline)}</div>" if headline else ''
detail_html=f"<div class='panel-item-body'>{esc(detail)}</div>" if detail else ''
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{assignee_html}</div>{headline_html}{detail_html}</li>")
body=''.join(rows)
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
@@ -449,7 +547,10 @@ def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
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-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>")
assignee_html=f"<span class='task-assignee'>{esc(x.get('assignee') or '')}</span>" if x.get('assignee') and x.get('assignee')!='Unassigned' else ''
chips_html=f"<div class='task-chips'>{chips}</div>" if chips else ''
subtitle_html=f"<div class='task-subtitle'>{esc(subtitle)}</div>" if subtitle else ''
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>{assignee_html}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{chips_html}{subtitle_html}</article>")
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
def feedback_bootstrap()->str:
@@ -584,10 +685,232 @@ def forecast_panel(weather:dict[str,Any])->str:
return ""
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
def fetch_hermes_usage(days:int=30)->dict[str,Any]:
config=read_hermes_model_config()
stats={
'ok':False,
'period_days':days,
'default_model':config.get('default_model','unknown'),
'provider':config.get('provider','unknown'),
'sessions':0,
'messages':0,
'tool_calls':0,
'user_messages':0,
'assistant_messages':0,
'total_tokens':0,
'input_tokens':0,
'output_tokens':0,
'cache_read_tokens':0,
'cache_write_tokens':0,
'reasoning_tokens':0,
'models':[],
'platforms':[],
'top_tools':[],
'recent_sessions':[],
'generated_at':datetime.now(LOCAL_TZ).isoformat(),
}
if not HERMES_STATE_DB.exists():
stats['error']='Hermes state DB not found'
return stats
cutoff=time.time()-(days*86400)
try:
conn=sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True)
conn.row_factory=sqlite3.Row
cur=conn.cursor()
overview=cur.execute("""
SELECT
count(*) AS sessions,
sum(coalesce(input_tokens,0)) AS input_tokens,
sum(coalesce(output_tokens,0)) AS output_tokens,
sum(coalesce(cache_read_tokens,0)) AS cache_read_tokens,
sum(coalesce(cache_write_tokens,0)) AS cache_write_tokens,
sum(coalesce(reasoning_tokens,0)) AS reasoning_tokens,
sum(coalesce(api_call_count,0)) AS api_calls
FROM sessions
WHERE started_at >= ?
""",(cutoff,)).fetchone()
stats.update({
'sessions':int((overview['sessions'] or 0) if overview else 0),
'input_tokens':int((overview['input_tokens'] or 0) if overview else 0),
'output_tokens':int((overview['output_tokens'] or 0) if overview else 0),
'cache_read_tokens':int((overview['cache_read_tokens'] or 0) if overview else 0),
'cache_write_tokens':int((overview['cache_write_tokens'] or 0) if overview else 0),
'reasoning_tokens':int((overview['reasoning_tokens'] or 0) if overview else 0),
})
stats['total_tokens']=stats['input_tokens']+stats['output_tokens']+stats['cache_read_tokens']+stats['cache_write_tokens']+stats['reasoning_tokens']
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role != 'session_meta' AND timestamp >= ?",(cutoff,)).fetchone()
stats['messages']=int((row['c'] or 0) if row else 0)
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(cutoff,)).fetchone()
stats['tool_calls']=int((row['c'] or 0) if row else 0)
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='user' AND timestamp >= ?",(cutoff,)).fetchone()
stats['user_messages']=int((row['c'] or 0) if row else 0)
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='assistant' AND timestamp >= ?",(cutoff,)).fetchone()
stats['assistant_messages']=int((row['c'] or 0) if row else 0)
stats['models']=[dict(r) for r in cur.execute("""
SELECT model, count(*) AS sessions,
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
FROM sessions
WHERE started_at >= ?
GROUP BY model
ORDER BY total_tokens DESC, sessions DESC
LIMIT 5
""",(cutoff,))]
stats['platforms']=[dict(r) for r in cur.execute("""
SELECT source, count(*) AS sessions,
sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
FROM sessions
WHERE started_at >= ?
GROUP BY source
ORDER BY total_tokens DESC, sessions DESC
LIMIT 5
""",(cutoff,))]
stats['top_tools']=[dict(r) for r in cur.execute("""
SELECT tool_name, count(*) AS calls
FROM messages
WHERE role='tool' AND tool_name IS NOT NULL AND tool_name != '' AND timestamp >= ?
GROUP BY tool_name
ORDER BY calls DESC, tool_name ASC
LIMIT 8
""",(cutoff,))]
stats['recent_sessions']=[dict(r) for r in cur.execute("""
SELECT source, model, started_at,
(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens,
coalesce(api_call_count,0) AS api_calls
FROM sessions
WHERE started_at >= ?
ORDER BY started_at DESC
LIMIT 5
""",(cutoff,))]
for item in stats['recent_sessions']:
started=item.get('started_at')
try: item['started_local']=datetime.fromtimestamp(float(started), LOCAL_TZ).strftime('%a %-I:%M %p')
except Exception: item['started_local']='recent'
stats['ok']=True
conn.close()
return stats
except Exception:
stats['error']='Hermes telemetry is temporarily unavailable.'
return stats
def focus_card(title:str,kicker:str,headline:str,body:str,tone:str='')->str:
return f"<article class='focus-card {esc(tone)}'><span class='focus-kicker'>{esc(kicker)}</span><h3>{esc(title)}</h3><strong>{esc(headline)}</strong><p>{esc(body)}</p></article>"
def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any])->str:
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
warn_items=[item for item in pulse if item.get('state')=='warn']
local_story=local_items[0] if local_items else None
if urgent_todos:
chores_headline=f"{len(urgent_todos)} urgent / due today"
first_urgent=urgent_todos[0]
chores_body=(first_urgent.get('title') or 'You have work waiting') + (f"{first_urgent.get('due_title')}" if first_urgent.get('due_title') else '')
else:
chores_headline='No urgent chores'
chores_body='Nothing due soon enough to nag you here.'
cards=[
focus_card(
'Local watch',
'Useful nearby',
(local_story.get('title') if local_story else 'Local lane quiet'),
(f"{local_story.get('source','Local feed')}{label(local_story.get('category','local'))}" if local_story else 'Nothing local survived filtering hard enough to earn the homepage.'),
'local' if local_story else ''
),
focus_card(
'Chores with heat',
'Donetick',
chores_headline,
chores_body,
'warn' if urgent_todos else 'ok'
),
focus_card(
'Paperless follow-up',
'Queue check',
(f"{paperless.get('active_count',0)} active · {paperless.get('flagged_count',0)} flagged" if paperless.get('active_count',0) or paperless.get('flagged_count',0) else 'No documents waiting'),
str(paperless.get('headline') or 'No documents waiting'),
'warn' if paperless.get('flagged_count',0) else ('ok' if not paperless.get('active_count',0) else '')
),
focus_card(
'Homelab pulse',
'NOMAD checks',
('Stable' if not warn_items else f"{len(warn_items)} warning{'s' if len(warn_items)!=1 else ''}"),
('; '.join(f"{item.get('label')}: {item.get('value')}" for item in warn_items[:2]) if warn_items else 'Dashboard service, cron, digest freshness, and disk all look fine.'),
'warn' if warn_items else 'ok'
),
]
return "<section class='focus-grid'>" + ''.join(cards) + "</section>"
def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'])
warn_count=len([item for item in pulse if item.get('state')=='warn'])
local_status='local lane has signal' if local_items else 'local lane is quiet'
paperless_text=f"{paperless.get('active_count',0)} Paperless item{'s' if paperless.get('active_count',0)!=1 else ''}"
hermes_model=(hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'your main model'
return f"{urgent_todos} urgent chore{'s' if urgent_todos!=1 else ''}, {paperless_text}, {warn_count} homelab warning{'s' if warn_count!=1 else ''}, and Hermes has mostly been running on {hermes_model} while the {local_status}."
def list_rows(rows:list[str])->str:
return ''.join(f"<li>{row}</li>" for row in rows) if rows else "<li class='empty'>Nothing to show.</li>"
def build_system_section(snapshot:list[dict[str,str]])->str:
metrics=''.join(metric_card(item.get('label','Metric'), item.get('value',''), 'live host state', item.get('state','')) for item in snapshot)
subtitle='Live NOMAD host state: uptime, load, memory pressure, and root disk usage.'
return collapsible_section('System snapshot',subtitle,f"<section class='metrics metrics-compact'>{metrics}</section>",'system-snapshot',open_by_default=True)
def build_hermes_section(hermes:dict[str,Any])->str:
if not hermes.get('ok'):
body="<section class='summary-grid'><article class='summary-card'><h3>Hermes telemetry unavailable</h3><p>Local Hermes usage stats could not be read right now.</p></article></section>"
return collapsible_section('Hermes usage',f"Could not read the last {hermes.get('period_days',30)} days of local Hermes state.",body,'hermes-usage',open_by_default=True)
metrics=''.join([
metric_card('Default model',str(hermes.get('default_model','unknown')),str(hermes.get('provider','unknown')),'space'),
metric_card(f"{hermes.get('period_days',30)}-day tokens",compact_number(hermes.get('total_tokens',0)),'all session rows','space'),
metric_card('Sessions / messages',f"{compact_number(hermes.get('sessions',0))} / {compact_number(hermes.get('messages',0))}",'state.db window','space'),
metric_card('Tool calls',compact_number(hermes.get('tool_calls',0)),f"{compact_number(hermes.get('user_messages',0))} user · {compact_number(hermes.get('assistant_messages',0))} assistant",'space'),
])
model_rows=[]
for item in hermes.get('models',[])[:5]:
model_rows.append(f"{esc(item.get('model') or 'unknown')} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
platform_rows=[]
for item in hermes.get('platforms',[])[:5]:
platform_rows.append(f"{esc((item.get('source') or 'unknown').title())} · {compact_number(item.get('sessions',0))} sessions · {compact_number(item.get('total_tokens',0))} tokens")
tool_rows=[]
for item in hermes.get('top_tools',[])[:8]:
tool_rows.append(f"{esc(item.get('tool_name') or 'tool')} · {compact_number(item.get('calls',0))} calls")
recent_rows=[]
for item in hermes.get('recent_sessions',[])[:5]:
source=(item.get('source') or 'session').title()
model=item.get('model') or 'unknown'
recent_rows.append(f"{esc(source)} · {esc(item.get('started_local') or 'recent')} · {esc(model)} · {compact_number(item.get('total_tokens',0))} tokens")
detail_html=f"""
<section class='summary-grid'>
<article class='summary-card'>
<h3>Model mix</h3>
<ul>{list_rows(model_rows)}</ul>
</article>
<article class='summary-card'>
<h3>Platform split</h3>
<ul>{list_rows(platform_rows)}</ul>
</article>
<article class='summary-card'>
<h3>Top tools</h3>
<ul>{list_rows(tool_rows)}</ul>
</article>
<article class='summary-card'>
<h3>Recent sessions</h3>
<ul>{list_rows(recent_rows)}</ul>
</article>
</section>
"""
body=f"<section class='metrics hermes-metrics'>{metrics}</section>{detail_html}"
subtitle=f"Live read from ~/.hermes/state.db over the last {hermes.get('period_days',30)} days."
return collapsible_section('Hermes usage',subtitle,body,'hermes-usage',open_by_default=True)
def render()->str:
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
selected=select_items(candidates,prefs); top_briefing=selected[:4]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
exploration=select_exploration_items(candidates,prefs,{i.get('url','') for i in selected if i.get('url')},4)
for i in selected:
if i.get('url','') in top_urls: continue
@@ -596,7 +919,8 @@ def render()->str:
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
elif cat=='local': groups['local'].append(i)
else: groups['signals'].append(i)
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates)
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); hermes=fetch_hermes_usage(30); system=system_snapshot()
paperless=paperless_focus_summary()
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
if weather['ok']:
cur=weather['current']; daily=weather.get('daily',{})
@@ -613,31 +937,45 @@ def render()->str:
metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')])
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
forecast_html=forecast_panel(weather)
hero_summary=build_hero_summary(groups['local'],todos,pulse,paperless,hermes)
focus_html=build_focus_cards(groups['local'],todos,pulse,paperless)
hermes_html=build_hermes_section(hermes)
system_html=build_system_section(system)
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else ''
primary_html=''.join([
section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',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('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:4],'No sky 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('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks right now.',compact=True),
section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True),
])
secondary_html=''.join([
collapsible_section('Focus now','What actually deserves attention before you go wandering through feeds.',focus_html,'focus-now',open_by_default=True),
system_html,
hermes_html,
collapsible_section('Agenda','Calendar, Donetick, and pinned notes.',f"<section class='today'>{list_panel('Calendar',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=False),
collapsible_section('Environment','Weather, sky, and outside-the-window context.',f"<section class='metrics metrics-compact'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False),
forecast_html,
paperless_review_section(),
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
])
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 · 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'][: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'][:3],'No local signal right now.',compact=True)}
{section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks 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>"""
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>{esc(hero_summary)}</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>Central time · refreshes every 30m</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><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header>
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
<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. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
def main()->int:
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos())
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos()); hermes=fetch_hermes_usage(30)
generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))}
(DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n')
(DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n')
(DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n')
(DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n')
(DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n')
(DATA/'hermes_usage.json').write_text(json.dumps(hermes,indent=2)+'\n')
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
return 0

View File

@@ -1,13 +1,482 @@
:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}}
:root{
color-scheme:dark;
--bg:#08090a;
--bg-accent:#121826;
--panel:#11151d;
--panel-soft:#171d27;
--panel-raise:#1d2430;
--text:#f3f6fb;
--muted:#94a3b8;
--line:rgba(255,255,255,.08);
--line-soft:rgba(255,255,255,.05);
--accent:#7c8cff;
--accent-soft:rgba(124,140,255,.16);
--space:#9f8cff;
--home:#4fd1a5;
--local:#ffd166;
--warn:#ff7b72;
--ok:#5ee787;
--shadow:0 14px 34px rgba(0,0,0,.26);
}
/* 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}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:
radial-gradient(circle at top left,rgba(124,140,255,.16),transparent 28rem),
radial-gradient(circle at top right,rgba(79,209,165,.08),transparent 22rem),
var(--bg);
color:var(--text);
}
/* 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}
a{color:inherit}
main{max-width:1380px;margin:0 auto;padding:18px}
/* 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: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.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),0 10px 24px rgba(0,0,0,.22)}.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.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}.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}}
.hero,
.status div,
.metric,
.panel,
.card,
.compact-card,
.focus-card,
.summary-card,
.paperless-card,
.forecast-card,
.panel-item,
.task-card,
.collapsible{
background:rgba(17,21,29,.92);
border:1px solid var(--line);
box-shadow:var(--shadow);
}
.hero{
display:grid;
grid-template-columns:minmax(0,1.25fr) 430px;
gap:14px;
align-items:stretch;
border-radius:24px;
padding:16px 18px;
margin-bottom:16px;
}
.eyebrow{
margin:0 0 6px;
color:#b7c0ff;
text-transform:uppercase;
letter-spacing:.18em;
font-size:.72rem;
}
h1{
margin:0;
font-size:clamp(2rem,4.6vw,3.5rem);
line-height:.94;
letter-spacing:-.04em;
}
.sub{
margin:10px 0 0;
color:var(--muted);
font-size:.97rem;
line-height:1.45;
max-width:70ch;
}
.hero-actions{
display:flex;
gap:10px;
align-items:center;
flex-wrap:wrap;
margin-top:12px;
}
.reset-btn,
.feedback-btn,
.paperless-btn{
appearance:none;
background:rgba(255,255,255,.03);
border:1px solid var(--line);
border-radius:999px;
color:var(--text);
cursor:pointer;
font:inherit;
}
.reset-btn{padding:7px 11px}
.reset-btn:hover,
.feedback-btn:hover,
.paperless-btn:hover{border-color:rgba(124,140,255,.55)}
.status,
.metrics{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
}
.metrics.metrics-compact{grid-template-columns:repeat(2,minmax(0,1fr))}
.status div,
.metric{
border-radius:18px;
padding:11px 12px;
min-width:0;
}
.status strong,
.metric strong{display:block}
.status strong{font-size:1rem}
.metric{
min-height:78px;
border-top:3px solid var(--accent);
}
.metric strong{
margin:6px 0 4px;
font-size:1.12rem;
line-height:1.08;
}
.metric span,
.metric em,
.status span,
.meta,
.empty,
footer,
.muted{
color:var(--muted);
}
.metric em{font-style:normal;font-size:.78rem}
.metric.weather{border-top-color:var(--local)}
.metric.space{border-top-color:var(--space)}
.metric.ok{border-top-color:var(--ok)}
.metric.warn{border-top-color:var(--warn)}
.dashboard-shell{
display:grid;
grid-template-columns:minmax(0,1.5fr) minmax(320px,.9fr);
gap:14px;
align-items:start;
}
.primary-column,
.secondary-column{
display:flex;
flex-direction:column;
gap:12px;
min-width:0;
}
.collapsible{
border-radius:20px;
overflow:hidden;
}
.collapsible > summary{
list-style:none;
cursor:pointer;
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
padding:12px 14px;
}
.collapsible > summary::-webkit-details-marker{display:none}
.collapsible-heading{min-width:0}
.collapsible-heading h2,
.panel h2,
section h2{
margin:0;
font-size:1.02rem;
line-height:1.15;
letter-spacing:-.02em;
}
.collapsible-heading p,
section header p{
margin:4px 0 0;
color:var(--muted);
font-size:.83rem;
line-height:1.32;
}
.collapse-indicator{
color:var(--muted);
transition:transform .16s ease;
transform-origin:center;
}
.details[open] .collapse-indicator,
.collapsible[open] .collapse-indicator{transform:rotate(180deg)}
.collapsible-body{padding:0 14px 14px}
.focus-grid,
.summary-grid,
.today,
.news-list,
.grid,
.task-grid,
.forecast-grid{
display:grid;
gap:10px;
}
.focus-grid,
.summary-grid,
.forecast-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.today{grid-template-columns:1fr}
.news-list{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.task-grid{grid-template-columns:1fr}
.focus-card,
.summary-card,
.panel,
.compact-card,
.paperless-card,
.forecast-card,
.panel-item,
.task-card,
.card{
border-radius:16px;
}
.focus-card,
.summary-card{padding:12px}
.focus-card{border-top:3px solid var(--accent)}
.focus-card.local{border-top-color:var(--local)}
.focus-card.ok{border-top-color:var(--ok)}
.focus-card.warn{border-top-color:var(--warn)}
.focus-kicker{
display:inline-block;
margin-bottom:8px;
font-size:.72rem;
letter-spacing:.12em;
text-transform:uppercase;
color:#b7c0ff;
}
.focus-card h3,
.summary-card h3{margin:0 0 5px;font-size:.93rem}
.focus-card strong{display:block;font-size:1rem;line-height:1.2;margin-bottom:5px}
.focus-card p,
.summary-card p,
.summary-card li{margin:0;color:#d5deeb;font-size:.88rem;line-height:1.34}
.summary-card ul{margin:0;padding-left:18px}
.summary-card li+li{margin-top:6px}
.hermes-metrics{margin-bottom:10px}
.panel{padding:12px}
.panel ul{list-style:none;padding-left:0;margin:0;display:grid;gap:8px}
.panel-item{
padding:9px 10px;
background:rgba(255,255,255,.03);
border-color:var(--line-soft);
}
.panel-item-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline}
.panel-item-top strong{font-size:.82rem}
.panel-item-title{margin-top:3px;font-weight:600;color:#dce5f6;font-size:.93rem;line-height:1.2}
.panel-item-body{
margin-top:4px;
color:#c7d1e2;
font-size:.84rem;
line-height:1.28;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
overflow:hidden;
}
.panel .empty{padding:6px 2px}
.donetick-panel{padding:12px}
.task-card{
padding:9px 10px;
background:rgba(255,255,255,.035);
border-color:var(--line-soft);
min-width:0;
}
.task-top{display:flex;justify-content:space-between;gap:6px;align-items:center;margin-bottom:3px}
.task-state-dot{width:8px;height:8px;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:.74rem;font-weight:700;line-height:1.1;color:#dde6f6}
.task-assignee{font-size:.68rem;padding:2px 7px;border-radius:999px;background:var(--panel-raise);color:var(--muted)}
.task-name{
font-size:.93rem;
font-weight:700;
line-height:1.14;
color:var(--text);
margin-bottom:4px;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
overflow:hidden;
}
.task-subtitle{
font-size:.73rem;
line-height:1.14;
color:var(--muted);
display:-webkit-box;
-webkit-line-clamp:1;
-webkit-box-orient:vertical;
overflow:hidden;
}
.task-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px}
.task-chip{display:inline-block;padding:2px 7px;border-radius:999px;color:#fff;font-size:.67rem;font-weight:700;line-height:1.05}
.task-card.overdue{border-color:rgba(255,123,114,.45)}
.task-card.today{border-color:rgba(255,209,102,.42)}
.task-card.tomorrow{border-color:rgba(142,202,230,.35)}
.compact-card,
.card{
padding:10px 11px;
border-left:3px solid var(--accent);
}
.compact-card.astronomy,.card.astronomy{border-left-color:var(--space)}
.compact-card.homelab,.compact-card.smart_home,.compact-card.radio,
.card.homelab,.card.smart_home,.card.radio{border-left-color:var(--home)}
.compact-card.local,.card.local{border-left-color:var(--local)}
.compact-card.security,.card.security{border-left-color:var(--warn)}
.compact-card .meta,
.card .meta{
margin-bottom:5px;
display:flex;
flex-wrap:wrap;
gap:6px;
font-size:.72rem;
}
.compact-card .meta span,
.card .meta span{
display:inline-flex;
align-items:center;
padding:2px 7px;
border-radius:999px;
background:rgba(255,255,255,.035);
border:1px solid var(--line-soft);
}
.compact-card h3,
.card h3{
margin:0 0 4px;
font-size:.94rem;
line-height:1.2;
}
.compact-card p,
.card p{
margin:0;
color:#c7d1e2;
font-size:.83rem;
line-height:1.28;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
overflow:hidden;
}
.compact-card:nth-child(n+7){display:none}
.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:7px}
.feedback-btn{padding:2px 8px;background:transparent}
.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}
.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),var(--shadow)}
.forecast-panel{padding:0}
.forecast-card{padding:11px 12px;background:rgba(255,255,255,.035)}
.forecast-day{color:#b7c0ff;font-size:.75rem;text-transform:uppercase;letter-spacing:.08em}
.forecast-card strong{font-size:.95rem}
.forecast-card em{font-style:normal;color:var(--muted);font-size:.82rem}
.badge{
display:inline-block;
margin-left:.35rem;
padding:2px 7px;
border-radius:999px;
background:var(--panel-raise);
color:#b7c0ff;
font-size:.78rem;
}
.paperless-grid{display:grid;grid-template-columns:1fr;gap:8px}
.paperless-card{padding:10px 11px;border-left:3px solid var(--accent)}
.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.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),var(--shadow)}
.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
.paperless-card h4{margin:.3rem 0;font-size:.92rem;line-height:1.18}
.paperless-card .summary{
margin:.2rem 0;
color:#d7e3f5;
font-size:.84rem;
line-height:1.26;
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:.76rem;line-height:1.18}
.paperless-card .link{display:inline-block;color:#b7c0ff;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{padding:5px 8px}
.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}
.paperless-acked,
.paperless-more{margin-top:10px;border-top:1px solid var(--line-soft);padding-top:10px}
.paperless-acked summary,
.paperless-more summary{cursor:pointer;color:var(--muted);font-size:.84rem}
.urgency{font-weight:700}
.urgency.warn{color:var(--warn)}
.urgency.info{color:var(--local)}
.urgency.ok{color:var(--ok)}
section header{display:flex;justify-content:space-between;gap:12px;align-items:end}
section header p{margin:0}
ul{margin:0}
footer{
margin:14px 0 6px;
padding:0 2px;
font-size:.8rem;
line-height:1.4;
}
footer p{margin:.25rem 0}
.dashboard-toast{
position:fixed;
right:16px;
bottom:16px;
z-index:1000;
max-width:min(420px,calc(100vw - 24px));
background:rgba(8,9,10,.97);
border:1px solid var(--line);
border-left:3px solid var(--accent);
border-radius:14px;
padding:12px 14px;
color:var(--text);
box-shadow:0 16px 40px rgba(0,0,0,.36);
}
.dashboard-toast[hidden]{display:none}
.toast-action{margin-left:10px;background:transparent;border:0;color:#b7c0ff;font:inherit;font-weight:700;cursor:pointer;padding:0}
@media (max-width:1180px){
.dashboard-shell{grid-template-columns:1fr}
.secondary-column{order:-1}
.focus-grid,.summary-grid,.forecast-grid,.metrics.metrics-compact{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:920px){
.hero{grid-template-columns:1fr}
.status,.metrics,.metrics.metrics-compact,.focus-grid,.summary-grid,.news-list,.grid,.forecast-grid{grid-template-columns:1fr}
}
@media (max-width:720px){
main{padding:12px}
.hero{padding:14px}
.collapsible > summary{padding:11px 12px}
.collapsible-body{padding:0 12px 12px}
.paperless-head{display:block}
.paperless-actions{justify-content:flex-start;margin-top:8px}
section header{display:block}
}