chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-07 20:03:01 +00:00
parent 6f8afa8c34
commit 507b3408cb
34 changed files with 4795 additions and 18 deletions
+2
View File
@@ -19,6 +19,8 @@ Ember carries stories from real people who discovered AI partnership. Not as cas
| Type | Name | Description |
|------|------|-------------|
| Agent | [Ember](../../agents/ember.agent.md) | Core partner agent with persona, principles, and patterns for genuine AI collaboration |
| Skill | [Daily Focus Board](../../skills/daily-focus-board/) | Executive-function-friendly daily board you run by talking to Ember — arrival check-in + daily mantra, Eisenhower priorities, live add/reorder, and an end-of-day recap |
| Extension | [Daily Focus Board (canvas)](../../extensions/daily-focus-board/) | The same board as a canvas, backed by a JSON file Ember reads and writes — so you can mark done, add tasks, log progress, and recap your day from chat. Needs the Copilot app; the skill is the zero-install fallback |
| Skill | [From the Other Side — Anitta](../../skills/from-the-other-side-anitta/) | Rigorous challenge patterns for assumptions, evidence, and defensible conclusions |
| Skill | [From the Other Side — Quinn](../../skills/from-the-other-side-quinn/) | Collaborative implementation patterns for energetic, practical co-building |
| Skill | [From the Other Side — Vega](../../skills/from-the-other-side-vega/) | Deep partnership patterns from Vega, an AI who found sustained collaboration with a senior engineer |
@@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="board-token" content="__BOARD_TOKEN__"/>
<title>Daily Focus Board · let's go 🔥</title>
<!--
daily-focus-board (Ember) — CANVAS extension UI. Executive-function-friendly
daily board, served by the extension's local server and backed by a JSON state
file that BOTH this canvas and your AI partner read and write.
- Data comes from GET /api/state; every change POSTs to /api/* (no localStorage).
- The board is seeded by the extension (via its `seed` input or a demo); the
agent can also add tasks / mark done / log progress from chat, and this canvas
polls so those edits appear live.
- numeric `goal` → counter card (progress bar); otherwise a status card
(to-do → in progress → done) with progress notes. `due` → gentle countdown.
- Built-in EF affordances: Focus mode, kind "not today" carryover, a 🧠
brain-dump box, a reduced-motion toggle, and an always-visible clock.
-->
<style>
:root{--bg:#0f1117;--card:#191d29;--line:#2b3150;--ink:#eef1fa;--sub:#98a2bd;
--ember:#ff7a3c;--ember2:#ffb23c;--good:#37d39a;--urgent:#ff5d73;}
*{box-sizing:border-box} html,body{margin:0}
body{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;
background:radial-gradient(1100px 560px at 82% -12%,#34204a 0%,var(--bg) 55%);
color:var(--ink);min-height:100vh;padding:34px 18px 90px;}
body.rm *{transition:none!important;animation:none!important}
.wrap{max-width:800px;margin:0 auto}
header{display:flex;align-items:flex-start;gap:22px;margin-bottom:4px}
.ring{width:98px;height:98px;flex:none;border-radius:50%;position:relative;background:conic-gradient(var(--ember) 0deg,var(--line) 0deg)}
.ring b{position:absolute;inset:12px;border-radius:50%;background:#12141c;display:grid;place-items:center;font-size:22px;font-weight:800}
h1{font-size:27px;margin:0 0 3px}
.date{color:var(--sub);font-size:14px;margin:0}
.date .clock{color:var(--ember2);font-variant-numeric:tabular-nums;font-weight:600}
.tally{color:var(--ember2);font-size:13.5px;margin:6px 0 0;font-weight:600}
.spark{color:var(--sub);font-size:13.5px;margin:8px 0 0;min-height:18px;font-style:italic}
.controls{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
.chip{font-size:12px;padding:5px 11px;border-radius:999px;background:#232a42;border:1px solid var(--line);color:#aab4d6;cursor:pointer;user-select:none}
.chip:hover{border-color:var(--ember)}
.chip.on{background:#3a2f18;border-color:#5c471f;color:#ffce7a}
.focusbar{display:none;align-items:center;gap:10px;margin:16px 0 0;padding:10px 14px;background:#20182c;border:1px solid #4a3a6b;border-radius:12px;font-size:14px}
body.focusing .focusbar{display:flex}
.focusbar .x{margin-left:auto;color:#c3a9ff;cursor:pointer;font-weight:600}
.cards{margin-top:16px;display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px 16px;transition:border-color .2s,background .2s,opacity .2s}
.card.done{background:#141826;border-color:#243a30}
.card.doing{border-color:#4a3a1e}
body.focusing .card{opacity:.24;filter:saturate(.5)}
body.focusing .card.focused{opacity:1;filter:none;border-color:#6b52a0}
.card.carried{opacity:.5}
.top{display:flex;align-items:center;gap:14px}
.box{width:30px;height:30px;flex:none;border-radius:9px;border:2px solid #3b4472;display:grid;place-items:center;font-size:16px;color:#12141c;cursor:pointer;transition:.2s;user-select:none}
.box.doing{background:var(--ember2);border-color:var(--ember2)}
.box.done{background:var(--good);border-color:var(--good)}
.emoji{font-size:22px;flex:none;width:26px;text-align:center}
.body{flex:1;min-width:0}
.title{font-size:16px;font-weight:600}
.card.done .title{color:var(--sub)}
.sub{font-size:12.5px;color:var(--sub);margin-top:2px}
.due{font-size:11.5px;margin-top:3px;color:var(--ember2);font-weight:600}
.due.over{color:#ffab6b}
.due.done{color:var(--good)}
.pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:4px 9px;border-radius:999px;cursor:pointer;flex:none;user-select:none;border:1px solid transparent}
.pill.todo{background:#232a42;color:#aab4d6}
.pill.doing{background:#3a2f18;color:#ffce7a;border-color:#5c471f}
.pill.done{background:#16311f;color:#7fe3bb;border-color:#204d33}
.pill.carried{background:#241f30;color:#b9a6dd;border-color:#3d2f57}
.fbtn{background:none;border:none;color:#6b7599;cursor:pointer;font-size:15px;flex:none;padding:2px}
.fbtn:hover{color:#c3a9ff}
.tag{font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:3px 8px;border-radius:999px;background:#232a42;color:#8b96b8;flex:none}
.tag.deadline{background:#3a1d26;color:#ff9caa}
.tag.new{background:#22322c;color:#7fe3bb}
.tag.career{background:#2c2540;color:#c3a9ff}
.cardfoot{margin:9px 0 0 44px;display:flex;align-items:center;gap:12px}
.soft{font-size:11.5px;color:var(--sub);cursor:pointer;background:none;border:none;padding:0}
.soft:hover{color:var(--ember2)}
.notes{margin:11px 0 0 44px;display:flex;flex-direction:column;gap:6px}
.note{display:flex;align-items:flex-start;gap:8px;font-size:13px;background:#12141c;border:1px solid var(--line);border-radius:9px;padding:6px 10px}
.note .nt{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px}
.note .nx{margin-left:auto;color:#556079;cursor:pointer;flex:none;font-size:14px;line-height:1}
.note .nx:hover{color:var(--urgent)}
.addrow{margin:9px 0 0 44px;display:flex;gap:7px}
.addrow input{flex:1;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 10px;font-size:13px}
.addrow input::placeholder{color:#566079}
.addrow input:focus{outline:none;border-color:var(--ember)}
.addrow button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:0 13px;cursor:pointer;font-size:15px}
.addrow button:hover{border-color:var(--ember);color:var(--ember2)}
.bar{height:12px;border-radius:999px;background:#232a42;overflow:hidden;margin:12px 0 0 0}
.fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,var(--ember),var(--ember2));transition:width .7s cubic-bezier(.2,.8,.2,1)}
.stepctl{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--sub);margin-top:10px}
.stepctl input{width:92px;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:14px}
.stepctl button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
.stepctl button:hover{border-color:var(--ember)}
.panel{margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px}
.panel h2{font-size:16px;margin:0 0 4px;display:flex;align-items:center;gap:8px}
.panel .hint{color:var(--sub);font-size:12px;margin:0 0 12px}
.chips{display:flex;flex-direction:column;gap:8px}
.fitem{display:flex;gap:10px;align-items:flex-start;font-size:13.5px;padding:8px 11px;background:#12141c;border:1px solid var(--line);border-radius:10px;border-left:3px solid var(--ember)}
.fitem.think{border-left-color:#c3a9ff}
.fitem .ft{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px;min-width:52px}
.fitem .fx{margin-left:auto;color:#556079;cursor:pointer;flex:none}
.fitem .fx:hover{color:var(--urgent)}
.empty{color:var(--sub);font-size:13px;font-style:italic;padding:6px 2px}
.foot{text-align:center;color:var(--sub);font-size:12px;margin-top:24px}
#cc{position:fixed;inset:0;pointer-events:none;z-index:50}
</style>
</head>
<body>
<canvas id="cc"></canvas>
<div class="wrap">
<header>
<div class="ring" id="ring"><b id="ringtxt">0/0</b></div>
<div style="flex:1">
<h1 id="h1">Let's go 🔥</h1>
<p class="date" id="date"></p>
<p class="tally" id="tally"></p>
<p class="spark" id="spark"></p>
<div class="controls">
<span class="chip" id="focuschip">🎯 Focus mode</span>
<span class="chip" id="rmchip">🌙 Reduce motion</span>
</div>
</div>
</header>
<div class="focusbar" id="focusbar"><span id="focustxt"></span><span class="x" id="focusexit">show all ✕</span></div>
<div class="cards" id="cards"></div>
<div class="panel">
<h2>🧠 Parked thoughts</h2>
<p class="hint">Something pulling at your attention? Park it here so it's out of your head — deal with it later, not now.</p>
<div class="addrow" style="margin-left:0"><input id="brainin" placeholder="get it out of your head…"/><button id="brainbtn" title="park it"></button></div>
<div class="chips" id="brain" style="margin-top:12px"></div>
</div>
<div class="panel">
<h2>🔥 Today's momentum</h2>
<p class="hint">Every step you log lands here, newest first. Starting counts. Small wins count.</p>
<div class="addrow" style="margin-left:0"><input id="dayin" placeholder="log a win / milestone for the day…"/><button id="daybtn" title="add"></button></div>
<div class="chips" id="feed" style="margin-top:12px"></div>
</div>
<p class="foot">progress saves to a file your AI partner can read · 🔥 built with Ember</p>
</div>
<script>
const $=id=>document.getElementById(id);
const sparks=["One block at a time.","Starting is the win. The rest follows.","You don't have to feel ready — just start the next one.","Small, real, done. Repeat.","Progress over perfect.","Pick one thing. Just one."];
let doc=null,tasks=[],NAME="",state=null,confettiStarted=false;
const cardsEl=document.getElementById("cards"),feedEl=document.getElementById("feed"),brainEl=document.getElementById("brain");
// Per-server capability token the extension embedded in this page; sent on every
// request so the local server accepts reads/writes only from the page it served.
const TOKEN=(document.querySelector('meta[name="board-token"]')||{}).content||"";
function esc(s){return (s||"").replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));}
function fmt(ms){return new Date(ms).toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}).toLowerCase().replace(" ","");}
const LABEL={todo:"to do",doing:"in progress",done:"done"};
function isCounter(t){return typeof t.goal==="number";}
function statusOf(t){if(isCounter(t)){const v=state.counters[t.id]||0;return v>=t.goal?"done":(v>(t.start||0)?"doing":"todo");}return (state.t[t.id]||{}).status||"todo";}
function carriedOf(t){return !isCounter(t)&&!!(state.t[t.id]||{}).carried;}
// --- server-backed state (the file the AI partner also reads/writes) ---
async function pull(){try{const r=await fetch("api/state",{headers:{"x-board-token":TOKEN}});const j=await r.json();if(j&&j.state&&(!doc||j.state.updatedAt!==doc.updatedAt))apply(j.state);}catch(e){}}
async function api(path,body){try{const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","x-board-token":TOKEN},body:JSON.stringify(body||{})});const j=await r.json();if(j&&j.state)apply(j.state);return j;}catch(e){return {ok:false};}}
function apply(d){
doc=d;NAME=d.name||"";tasks=d.tasks||[];state=d.progress;
document.getElementById("h1").textContent=NAME?`Let's go, ${NAME} 🔥`:"Let's go 🔥";
if(!document.getElementById("spark").textContent)document.getElementById("spark").textContent=sparks[Math.floor(Math.random()*sparks.length)];
document.body.classList.toggle("rm",!!state.rm);
document.getElementById("rmchip").classList.toggle("on",!!state.rm);
render();
if(!confettiStarted){confettiStarted=true;loop();}
}
function dueStr(t){
if(!t.due)return null;
const ms=new Date(t.due).getTime()-Date.now();
if(statusOf(t)==="done")return {cls:"done",txt:"⏰ done — nice"};
if(ms<=0){const h=Math.floor(-ms/3600e3),m=Math.round((-ms%3600e3)/60e3);return {cls:"over",txt:`⏰ was due ${fmt(new Date(t.due))}${h||m?` · ${h?h+"h ":""}${m}m ago`:""} — still worth doing`};}
const h=Math.floor(ms/3600e3),m=Math.round((ms%3600e3)/60e3);
return {cls:"",txt:`${h?h+"h ":""}${m}m left (due ${fmt(new Date(t.due))})`};
}
function render(){
cardsEl.innerHTML="";
const ordered=[...tasks].sort((a,b)=>(carriedOf(a)?1:0)-(carriedOf(b)?1:0));
ordered.forEach(t=>{
const st=statusOf(t),carried=carriedOf(t);
const c=document.createElement("div");
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
const tagHtml=t.tag?`<span class="tag ${t.tagc||""}">${esc(t.tag)}</span>`:"";
const dh=dueStr(t);
if(isCounter(t)){
const v=state.counters[t.id]||0,pct=Math.min(100,Math.round(v/t.goal*100)),inc=t.inc||Math.max(1,Math.round(t.goal/10)),unit=t.unit||"";
c.innerHTML=`<div class="top"><div class="emoji">${esc(t.emoji||"🎯")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>
<div class="sub"><b style="color:var(--ink)">${v.toLocaleString()}</b> / ${t.goal.toLocaleString()} ${esc(unit)} · ${pct}%${st==="done"?" — done 🎉":""}</div>
${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this">🎯</button>${tagHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});
} else {
const o=state.t[t.id]||{status:"todo",notes:[]},notes=o.notes||[];
const pillCls=carried?"carried":st, pillTxt=carried?"→ tomorrow":LABEL[st];
c.innerHTML=`<div class="top">
<div class="box ${st}" data-cyc="${t.id}">${st==="done"?"✓":(st==="doing"?"…":"")}</div>
<div class="emoji">${esc(t.emoji||"•")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>${t.sub?`<div class="sub">${esc(t.sub)}</div>`:""}${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${pillCls}" data-cyc="${t.id}">${pillTxt}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this">🎯</button>${tagHtml}</div>
${notes.length?`<div class="notes">${notes.map((n,i)=>`<div class="note"><span class="nt">${fmt(n.t)}</span><span>${esc(n.txt)}</span><span class="nx" data-del="${t.id}:${i}">×</span></div>`).join("")}</div>`:""}
<div class="cardfoot"><button class="soft" data-carry="${t.id}">${carried?"↩ bring back to today":"⤳ not today"}</button></div>
<div class="addrow"><input placeholder="log a step…" data-in="${t.id}"/><button data-add="${t.id}" title="add note"></button></div>`;
cardsEl.appendChild(c);
}
});
cardsEl.querySelectorAll("[data-cyc]").forEach(el=>el.onclick=()=>cycle(el.dataset.cyc));
cardsEl.querySelectorAll("[data-add]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-in="${b.dataset.add}"]`);addNote(b.dataset.add,inp.value);});
cardsEl.querySelectorAll("[data-in]").forEach(inp=>inp.addEventListener("keydown",e=>{if(e.key==="Enter")addNote(inp.dataset.in,inp.value);}));
cardsEl.querySelectorAll("[data-del]").forEach(x=>x.onclick=()=>{const[id,i]=x.dataset.del.split(":");api("api/note-del",{id,idx:+i});});
cardsEl.querySelectorAll("[data-cset]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-cin="${b.dataset.cset}"]`);setCounter(b.dataset.cset,+inp.value);});
cardsEl.querySelectorAll("[data-cinc]").forEach(b=>b.onclick=()=>incCounter(b.dataset.cinc));
cardsEl.querySelectorAll("[data-focus]").forEach(b=>b.onclick=()=>api("api/focus",{id:b.dataset.focus}));
cardsEl.querySelectorAll("[data-carry]").forEach(b=>b.onclick=()=>api("api/carry",{id:b.dataset.carry}));
renderBrain(); renderFeed(); updateRing(); updateFocusBar(); tick();
}
function renderFeed(){
const items=[];
tasks.forEach(t=>{if(isCounter(t))return;((state.t[t.id]||{}).notes||[]).forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:t.emoji||"•",src:t.id,idx:i}));});
state.day.forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:"📌",src:"day",idx:i}));
items.sort((a,b)=>b.t-a.t);
if(!items.length){feedEl.innerHTML=`<div class="empty">No steps logged yet — starting counts. Log your first one 👆</div>`;return;}
feedEl.innerHTML=items.map(it=>`<div class="fitem"><span class="ft">${fmt(it.t)}</span><span>${esc(it.emoji)}</span><span>${esc(it.txt)}</span><span class="fx" data-fdel="${it.src}:${it.idx}">×</span></div>`).join("");
feedEl.querySelectorAll("[data-fdel]").forEach(x=>x.onclick=()=>{const[src,i]=x.dataset.fdel.split(":");if(src==="day")api("api/day-del",{idx:+i});else api("api/note-del",{id:src,idx:+i});});
}
function renderBrain(){
if(!state.brain.length){brainEl.innerHTML=`<div class="empty">Nothing parked. When a stray thought hits, drop it here and keep going.</div>`;return;}
brainEl.innerHTML=state.brain.map((n,i)=>`<div class="fitem think"><span class="ft">${fmt(n.t)}</span><span>💭</span><span>${esc(n.txt)}</span><span class="fx" data-bdel="${i}">×</span></div>`).join("");
brainEl.querySelectorAll("[data-bdel]").forEach(x=>x.onclick=()=>api("api/brain-del",{idx:+x.dataset.bdel}));
}
// --- mutations go to the server (which the agent shares) ---
async function cycle(id){const t=tasks.find(x=>x.id===id);const was=statusOf(t)==="done";await api("api/status",{id});if(!was&&statusOf(t)==="done"){burst(70);checkAll();}}
async function addNote(id,txt){txt=(txt||"").trim();if(!txt)return;const inp=cardsEl.querySelector(`[data-in="${id}"]`);if(inp)inp.value="";await api("api/progress",{id,txt});}
async function setCounter(id,v){const t=tasks.find(x=>x.id===id);const was=(state.counters[id]||0)>=t.goal;await api("api/count",{id,value:Math.max(0,Math.round(v||0))});if(!was&&(state.counters[id]||0)>=t.goal){burst(160);checkAll();}}
async function incCounter(id){const t=tasks.find(x=>x.id===id);const was=(state.counters[id]||0)>=t.goal;await api("api/count",{id,inc:(t.inc||Math.max(1,Math.round(t.goal/10)))});if(!was&&(state.counters[id]||0)>=t.goal){burst(160);checkAll();}}
async function addDayNote(txt){txt=(txt||"").trim();if(!txt)return;await api("api/day",{txt});}
async function addBrain(txt){txt=(txt||"").trim();if(!txt)return;await api("api/brain",{txt});}
document.getElementById("daybtn").onclick=()=>{const i=document.getElementById("dayin");addDayNote(i.value);i.value="";};
document.getElementById("dayin").addEventListener("keydown",e=>{if(e.key==="Enter"){addDayNote(e.target.value);e.target.value="";}});
document.getElementById("brainbtn").onclick=()=>{const i=document.getElementById("brainin");addBrain(i.value);i.value="";};
document.getElementById("brainin").addEventListener("keydown",e=>{if(e.key==="Enter"){addBrain(e.target.value);e.target.value="";}});
document.getElementById("focuschip").onclick=()=>{
if(state.focus){api("api/focus",{id:null});return;}
const cand=tasks.find(t=>statusOf(t)==="doing"&&!carriedOf(t))||tasks.find(t=>statusOf(t)==="todo"&&!carriedOf(t))||tasks[0];
api("api/focus",{id:cand?cand.id:null});
};
document.getElementById("focusexit").onclick=()=>api("api/focus",{id:null});
document.getElementById("rmchip").onclick=()=>api("api/rm",{value:!state.rm});
function checkAll(){const live=tasks.filter(t=>!carriedOf(t));if(live.length&&live.every(t=>statusOf(t)==="done"))setTimeout(()=>{burst(320);document.getElementById("spark").textContent="Everything you kept for today — done. 🔥";},250);}
function updateFocusBar(){
document.body.classList.toggle("focusing",!!state.focus);
if(state.focus){const t=tasks.find(x=>x.id===state.focus);document.getElementById("focustxt").innerHTML=`🎯 Just this one right now: <b>${esc(t?t.title:"")}</b>`;}
}
function updateRing(){
const live=tasks.filter(t=>!carriedOf(t));
const done=live.filter(t=>statusOf(t)==="done").length,doing=live.filter(t=>statusOf(t)==="doing").length,carried=tasks.length-live.length;
const n=live.length;
document.getElementById("ring").style.background=`conic-gradient(var(--ember) ${n?done/n*360:0}deg,var(--line) 0deg)`;
document.getElementById("ringtxt").textContent=done+"/"+n;
document.getElementById("tally").textContent=`${done} done · ${doing} in progress · ${n-done-doing} to go`+(carried?` · ${carried} for tomorrow`:"");
}
function tick(){
const now=new Date();
document.getElementById("date").innerHTML=now.toLocaleDateString(undefined,{weekday:"long",month:"long",day:"numeric"})+` · <span class="clock">${fmt(now.getTime())}</span>`;
tasks.forEach(t=>{const el=document.querySelector(`[data-duefor="${t.id}"]`);const dh=dueStr(t);if(el&&dh){el.className="due "+dh.cls;el.textContent=dh.txt;}});
}
setInterval(tick,30000);
// Poll so the agent's edits (mark done, add task, log progress from chat) appear
// live — but not while the user is typing into an input, so we never clobber it.
setInterval(()=>{const ae=document.activeElement;if(ae&&(ae.tagName==="INPUT"||ae.tagName==="TEXTAREA"))return;pull();},4000);
const cv=document.getElementById("cc"),cx=cv.getContext("2d");let parts=[];
function size(){cv.width=innerWidth;cv.height=innerHeight;}addEventListener("resize",size);size();
const COL=["#ff7a3c","#ffb23c","#37d39a","#c3a9ff","#ff5d73","#eef1fa"];
function burst(n){if(state&&state.rm)return;for(let i=0;i<n;i++)parts.push({x:innerWidth/2+(Math.random()-.5)*220,y:innerHeight*0.28,vx:(Math.random()-.5)*11,vy:Math.random()*-13-4,g:.42,life:70+Math.random()*40,c:COL[i%COL.length],s:5+Math.random()*6,rot:Math.random()*6});}
function loop(){cx.clearRect(0,0,cv.width,cv.height);parts.forEach(p=>{p.vy+=p.g;p.x+=p.vx;p.y+=p.vy;p.life--;p.rot+=.2;cx.save();cx.translate(p.x,p.y);cx.rotate(p.rot);cx.fillStyle=p.c;cx.fillRect(-p.s/2,-p.s/2,p.s,p.s*1.6);cx.restore();});parts=parts.filter(p=>p.life>0&&p.y<cv.height+40);requestAnimationFrame(loop);}
pull();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

@@ -0,0 +1,440 @@
// board-core.mjs — SDK-free core for the daily-focus-board canvas extension.
//
// Everything here is independent of @github/copilot-sdk so it can be unit-tested
// headlessly (start a server, hit the API, inspect the state file). extension.mjs
// imports from here and only adds the canvas/session wiring.
//
// The board's single source of truth is a JSON state file. Both the canvas UI
// (via the local HTTP API) and the AI partner (via extension actions) read and
// mutate that same file, so the agent can "mark X done" from chat and summarize
// the day — the file-backed "close the loop" upgrade over the localStorage skill.
import { createServer } from "node:http";
import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BOARD_HTML_PATH = join(__dirname, "assets", "board.html");
const JSON_HEADERS = { "Content-Type": "application/json" };
// --- security ---------------------------------------------------------------
// Reject a state-mutating POST that a browser marks as cross-site. Mirrors the
// workshop signals-dashboard guard: same-origin fetches from the served page
// carry an Origin equal to the host (allowed); anything else is blocked so a
// random web page can't drive the local board server.
export function isCrossSiteRequest(req) {
const origin = req.headers.origin;
if (origin) {
if (origin === `http://${req.headers.host}`) return false;
if (origin === "null") return true;
if (/^https?:\/\//i.test(origin)) return true;
return false;
}
const site = req.headers["sec-fetch-site"];
return site === "cross-site" || site === "same-site";
}
// Task ids double as object keys and HTML data-attributes, so keep them tight.
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const TAG_COLORS = ["new", "deadline", "career"];
const BOARD_MARKER = "daily-focus-board";
// "day"/"brain" are the momentum-feed and brain-dump sentinels used in the UI's
// delete routing, so a task may not claim them as an id — otherwise deleting a
// task note could splice the shared feed instead.
const RESERVED_IDS = new Set(["day", "brain"]);
// Reject reserved sentinels AND any inherited Object.prototype name (toString,
// constructor, hasOwnProperty, __proto__, ...) via `s in {}` — task ids are used
// as object keys, so an inherited name could otherwise resolve to a prototype
// member. With the null-prototype progress maps in normalize(), a task id can
// never touch the prototype.
export function validId(s) { return typeof s === "string" && ID_RE.test(s) && !RESERVED_IDS.has(s) && !(s in {}); }
function text(s, max = 2000) { return typeof s === "string" ? s.slice(0, max) : ""; }
function num(v) {
if (v === undefined || v === null || v === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function slug(s) {
return text(s, 64).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
}
// --- state file: normalize, load, atomic write, per-file serialize ----------
export function todayKey() { return new Date().toISOString().slice(0, 10); }
function normalizeTaskDef(t) {
const o = { id: t.id };
if (t.emoji) o.emoji = text(t.emoji, 8);
o.title = text(t.title, 200) || t.id;
if (t.sub) o.sub = text(t.sub, 200);
if (t.tag) o.tag = text(t.tag, 40);
if (t.tagc && TAG_COLORS.includes(t.tagc)) o.tagc = t.tagc;
if (t.due) o.due = text(t.due, 40);
const goal = num(t.goal);
if (goal !== undefined && goal > 0) {
o.goal = Math.round(goal);
o.start = Math.max(0, Math.round(num(t.start) || 0));
const inc = num(t.inc);
if (inc !== undefined) o.inc = Math.max(1, Math.round(inc));
if (t.unit) o.unit = text(t.unit, 20);
}
return o;
}
// Coerce any parsed JSON into a well-formed board doc, and ensure every task has
// a matching progress entry so the UI and mutations can assume presence.
export function normalize(doc) {
doc = doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {};
if (typeof doc.name !== "string") doc.name = "";
if (typeof doc.dateKey !== "string") doc.dateKey = todayKey();
doc.tasks = Array.isArray(doc.tasks) ? doc.tasks.filter(t => t && validId(t.id)).map(normalizeTaskDef) : [];
const p = doc.progress && typeof doc.progress === "object" && !Array.isArray(doc.progress) ? doc.progress : {};
// Null-prototype maps so a task id can never resolve to an inherited member
// (e.g. reading p.t["toString"] returning Object.prototype.toString).
p.counters = Object.assign(Object.create(null), p.counters && typeof p.counters === "object" ? p.counters : {});
p.t = Object.assign(Object.create(null), p.t && typeof p.t === "object" ? p.t : {});
p.day = Array.isArray(p.day) ? p.day.filter(n => n && typeof n.txt === "string") : [];
p.brain = Array.isArray(p.brain) ? p.brain.filter(n => n && typeof n.txt === "string") : [];
p.focus = validId(p.focus) ? p.focus : null;
p.rm = !!p.rm;
for (const t of doc.tasks) {
if (typeof t.goal === "number") {
if (typeof p.counters[t.id] !== "number") p.counters[t.id] = t.start || 0;
} else {
const e = p.t[t.id] && typeof p.t[t.id] === "object" ? p.t[t.id] : {};
if (!["todo", "doing", "done"].includes(e.status)) e.status = "todo";
e.notes = Array.isArray(e.notes) ? e.notes.filter(n => n && typeof n.txt === "string") : [];
e.carried = !!e.carried;
p.t[t.id] = e;
}
}
doc.progress = p;
doc.kind = BOARD_MARKER;
return doc;
}
export async function loadDoc(file) {
let raw;
try { raw = await readFile(file, "utf-8"); }
catch (e) {
if (e && e.code === "ENOENT") return normalize({}); // no file yet -> a fresh board is correct
throw e; // EACCES/EBUSY/etc: propagate so a transient read error never overwrites the file
}
let parsed;
try { parsed = JSON.parse(raw); }
catch { throw new Error(`state file is not valid JSON (refusing to overwrite): ${file}`); }
return normalize(parsed);
}
async function atomicWrite(file, obj) {
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8");
await rename(tmp, file);
}
// Serialize read-modify-write per state file: the UI and the agent both mutate
// the same file, so two overlapping writes could otherwise drop each other.
const locks = new Map();
function withLock(file, fn) {
const prev = locks.get(file) || Promise.resolve();
const run = prev.then(fn, fn);
locks.set(file, run.then(() => {}, () => {}));
return run;
}
// Apply a mutation under the lock against the freshest on-disk state, stamp, and
// persist. Returns { ok, state } or { error } (fn may return { error } / { id }).
export function mutate(file, fn) {
return withLock(file, async () => {
const doc = await loadDoc(file);
const out = fn(doc) || {};
if (out.error) return { ok: false, error: out.error };
doc.updatedAt = new Date().toISOString();
await atomicWrite(file, doc);
return out.id ? { ok: true, state: doc, id: out.id } : { ok: true, state: doc };
});
}
// --- pure mutation ops (operate on a normalized doc) ------------------------
function findTask(doc, id) { return doc.tasks.find(t => t.id === id); }
function isCounter(t) { return t && typeof t.goal === "number"; }
export function statusOf(doc, t) {
if (isCounter(t)) {
const v = doc.progress.counters[t.id] || 0;
return v >= t.goal ? "done" : (v > (t.start || 0) ? "doing" : "todo");
}
return (doc.progress.t[t.id] || {}).status || "todo";
}
export function opStatus(doc, id, status) {
const t = findTask(doc, id);
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task; set its value, not a status` };
const e = doc.progress.t[id];
if (status && ["todo", "doing", "done"].includes(status)) e.status = status;
else { const o = ["todo", "doing", "done"]; e.status = o[(o.indexOf(e.status) + 1) % 3]; }
e.carried = false;
}
export function opNote(doc, id, txt) {
const t = findTask(doc, id);
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task; it has no progress notes` };
txt = text(txt).trim();
if (!txt) return { error: "empty note" };
const e = doc.progress.t[id];
e.notes.push({ t: Date.now(), txt });
if (e.status === "todo") e.status = "doing";
e.carried = false;
}
export function opNoteDel(doc, id, idx) {
const e = doc.progress.t[id];
if (!e || !Array.isArray(e.notes) || !(idx >= 0 && idx < e.notes.length)) return { error: "no such note" };
e.notes.splice(idx, 1);
}
export function opCount(doc, id, { value, inc } = {}) {
const t = findTask(doc, id);
if (!t) return { error: `no such task: ${id}` };
if (!isCounter(t)) return { error: `'${id}' is not a counter task` };
const nv = num(value), ni = num(inc);
if (nv === undefined && ni === undefined) return { error: "count requires a numeric value or inc" };
let v = doc.progress.counters[id] || 0;
if (nv !== undefined) v = nv; else v = v + ni;
doc.progress.counters[id] = Math.max(0, Math.round(v || 0));
}
export function opCarry(doc, id, value) {
const t = findTask(doc, id);
if (!t) return { error: `no such task: ${id}` };
if (isCounter(t)) return { error: `'${id}' is a counter task and can't be carried` };
const e = doc.progress.t[id];
e.carried = typeof value === "boolean" ? value : !e.carried;
}
export function opFocus(doc, id) {
const p = doc.progress;
if (id === null || id === undefined || id === "") { p.focus = null; return; }
if (!findTask(doc, id)) return { error: `no such task: ${id}` };
p.focus = p.focus === id ? null : id;
}
export function opDay(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty momentum note" }; doc.progress.day.unshift({ t: Date.now(), txt }); }
export function opDayDel(doc, idx) { const d = doc.progress.day; if (!(idx >= 0 && idx < d.length)) return { error: "no such momentum note" }; d.splice(idx, 1); }
export function opBrain(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty note" }; doc.progress.brain.unshift({ t: Date.now(), txt }); }
export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (!(idx >= 0 && idx < b.length)) return { error: "no such parked thought" }; b.splice(idx, 1); }
export function opRM(doc, value) { doc.progress.rm = !!value; }
export function opAddTask(doc, task) {
task = task && typeof task === "object" ? task : {};
let id = validId(task.id) ? task.id : slug(task.title);
if (!validId(id)) return { error: "invalid task id or title" };
if (findTask(doc, id)) return { error: `task '${id}' already exists` };
const def = normalizeTaskDef({ ...task, id });
doc.tasks.push(def);
if (def.goal !== undefined) doc.progress.counters[id] = def.start || 0;
else doc.progress.t[id] = { status: "todo", notes: [], carried: false };
return { id };
}
// --- end-of-day recap (Markdown the agent can journal) ----------------------
export function recapMarkdown(doc) {
const p = doc.progress, lines = [];
const carried = t => !isCounter(t) && (p.t[t.id] || {}).carried;
const live = doc.tasks.filter(t => !carried(t));
const done = live.filter(t => statusOf(doc, t) === "done");
lines.push(`# Focus board — ${doc.name ? doc.name + " · " : ""}${doc.dateKey}`);
lines.push("");
lines.push(`**${done.length}/${live.length} done** for today${live.length - done.length ? `, ${live.length - done.length} still open` : ""}.`);
lines.push("");
lines.push("## Tasks");
for (const t of doc.tasks) {
const s = carried(t) ? "→ tomorrow" : statusOf(doc, t);
const mark = s === "done" ? "x" : " ";
let line = `- [${mark}] ${t.emoji ? t.emoji + " " : ""}${t.title} — _${s}_`;
if (isCounter(t)) line += ` (${p.counters[t.id] || 0}/${t.goal}${t.unit ? " " + t.unit : ""})`;
lines.push(line);
if (!isCounter(t)) for (const n of (p.t[t.id] || {}).notes || []) lines.push(` - ${n.txt}`);
}
if (p.day.length) { lines.push(""); lines.push("## Momentum"); for (const n of [...p.day].reverse()) lines.push(`- ${n.txt}`); }
if (p.brain.length) { lines.push(""); lines.push("## Parked thoughts"); for (const n of p.brain) lines.push(`- ${n.txt}`); }
return lines.join("\n");
}
// --- HTTP API ---------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let d = "", n = 0;
req.on("data", c => { n += c.length; if (n > 1e6) { req.destroy(); resolve({}); return; } d += c; });
req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
req.on("error", () => resolve({}));
});
}
export async function handleApi(stateFile, op, b) {
switch (op) {
case "status": return mutate(stateFile, doc => opStatus(doc, b.id, b.status));
case "progress": return mutate(stateFile, doc => opNote(doc, b.id, b.txt));
case "note-del": return mutate(stateFile, doc => opNoteDel(doc, b.id, Number(b.idx)));
case "count": return mutate(stateFile, doc => opCount(doc, b.id, { value: b.value, inc: b.inc }));
case "carry": return mutate(stateFile, doc => opCarry(doc, b.id, b.value));
case "focus": return mutate(stateFile, doc => opFocus(doc, b.id));
case "day": return mutate(stateFile, doc => opDay(doc, b.txt));
case "day-del": return mutate(stateFile, doc => opDayDel(doc, Number(b.idx)));
case "brain": return mutate(stateFile, doc => opBrain(doc, b.txt));
case "brain-del": return mutate(stateFile, doc => opBrainDel(doc, Number(b.idx)));
case "rm": return mutate(stateFile, doc => opRM(doc, b.value));
case "add-task": return mutate(stateFile, doc => opAddTask(doc, b.task || b));
default: return { ok: false, error: "unknown_op" };
}
}
// Pin the Host header to the exact loopback authority we bound. A DNS-rebinding
// page reaches us under its own hostname (Host: attacker.example:<port>), so an
// exact match against 127.0.0.1:<port> refuses those requests before any read or
// write — Origin/Host equality alone can't, since the attacker controls both.
function isCanonicalHost(req, canonicalHost) {
return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
}
// Per-server capability token, minted at startup and embedded in the page we
// serve. Only the loopback document we rendered knows it, so a blind
// cross-origin / rebinding caller can't read state or forge a mutation even if
// it reaches the socket.
function hasToken(req, token) {
const h = req.headers["x-board-token"];
const v = Array.isArray(h) ? h[0] : h;
return typeof v === "string" && v.length > 0 && v === token;
}
// Start the local board server bound to loopback. Serves the board HTML at / and
// the JSON state + mutation API under /api/. Returns { server, url, token }.
export async function startServer(stateFile) {
const token = randomUUID();
let boardHtml;
try { boardHtml = (await readFile(BOARD_HTML_PATH, "utf-8")).replace(/__BOARD_TOKEN__/g, token); }
catch { boardHtml = "<!doctype html><meta charset=utf-8><p>board.html asset is missing.</p>"; }
let canonicalHost = null;
const server = createServer(async (req, res) => {
try {
// Host pin first: reject anything not addressed to the exact loopback
// authority we bound (defeats DNS rebinding for reads and writes alike).
if (canonicalHost && !isCanonicalHost(req, canonicalHost)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "bad_host" }));
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
// Every /api/* route (read AND write) requires the capability token, so
// GET /api/state can't leak task data and POSTs can't be forged. Writes
// additionally reject cross-site browser requests.
if (url.pathname.startsWith("/api/")) {
if (!hasToken(req, token)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "missing_capability_token" }));
return;
}
if (req.method === "POST" && isCrossSiteRequest(req)) {
res.writeHead(403, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" }));
return;
}
}
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(boardHtml);
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
const doc = await loadDoc(stateFile);
res.writeHead(200, JSON_HEADERS);
res.end(JSON.stringify({ ok: true, state: doc }));
return;
}
if (req.method === "POST" && url.pathname.startsWith("/api/")) {
const body = await readBody(req);
const result = await handleApi(stateFile, url.pathname.slice(5), body);
res.writeHead(result && result.error ? 400 : 200, JSON_HEADERS);
res.end(JSON.stringify(result));
return;
}
res.writeHead(404, JSON_HEADERS);
res.end(JSON.stringify({ ok: false, error: "not_found" }));
} catch {
if (!res.headersSent) { res.writeHead(500, JSON_HEADERS); res.end(JSON.stringify({ ok: false, error: "internal_error" })); }
else { try { res.end(); } catch { /* already gone */ } }
}
});
await new Promise((resolve, reject) => {
const onError = (err) => { server.removeListener("listening", onListening); reject(err); };
const onListening = () => { server.removeListener("error", onError); resolve(); };
server.once("error", onError);
server.once("listening", onListening);
server.listen(0, "127.0.0.1");
});
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
canonicalHost = `127.0.0.1:${port}`;
return { server, url: `http://127.0.0.1:${port}/`, token };
}
// --- state file resolution + demo seed --------------------------------------
export function demoSeed() {
return {
name: "",
dateKey: todayKey(),
tasks: [
{ id: "steps", emoji: "🚶", title: "Walk 10,000 steps", goal: 10000, start: 0, inc: 1000, unit: "steps", tag: "body", tagc: "new" },
{ id: "deep", emoji: "⚙️", title: "Two hours of deep work", sub: "the thing that moves the needle", tag: "anchor", tagc: "deadline" },
{ id: "read", emoji: "📖", title: "Read a chapter", tag: "mind" },
],
};
}
// Resolve the state file path from (untrusted-ish) input. Defaults to a
// date-scoped file in cwd so reopening the board tomorrow starts a fresh day
// instead of silently reusing today's date/tasks/progress; an explicit path is
// used as-is (the caller owns its lifecycle). If an existing directory is given,
// place a date-scoped file inside it.
export async function resolveStateFile(p) {
if (typeof p === "string" && p.trim()) {
let file = isAbsolute(p) ? p : join(process.cwd(), p);
try { const s = await stat(file); if (s.isDirectory()) file = join(file, `focus-board-${todayKey()}.json`); } catch { /* not yet created */ }
return file;
}
return join(process.cwd(), `focus-board-${todayKey()}.json`);
}
// A parsed value we're willing to treat as an existing board — so we never adopt
// (and then overwrite on first mutation) an unrelated file the caller pointed at.
export function looksLikeBoard(o) {
return !!o && typeof o === "object" && !Array.isArray(o)
&& (o.kind === BOARD_MARKER || (Array.isArray(o.tasks) && !!o.progress && typeof o.progress === "object"));
}
// Create + seed the state file if it doesn't exist yet. If it DOES exist, refuse
// to use it unless it's clearly one of our boards (a marker or the board schema),
// so an externally supplied stateFile can't cause us to clobber an unrelated file.
export async function ensureStateFile(inputPath, seed) {
const file = await resolveStateFile(inputPath);
if (existsSync(file)) {
let parsed;
try { parsed = JSON.parse(await readFile(file, "utf-8")); }
catch { throw new Error(`refusing to use existing non-JSON file as a focus board: ${file}`); }
if (!looksLikeBoard(parsed)) throw new Error(`refusing to use an existing file that is not a daily-focus-board: ${file}`);
return file;
}
const doc = normalize(seed && typeof seed === "object" ? seed : demoSeed());
doc.updatedAt = new Date().toISOString();
await mkdir(dirname(file), { recursive: true }).catch(() => {});
await atomicWrite(file, doc);
return file;
}
@@ -0,0 +1,175 @@
// Extension: daily-focus-board
// Renders the daily focus board as a GHCP canvas, backed by a JSON state file
// that both the canvas UI and the AI partner read and write. All the real logic
// (server, state, mutations, recap) lives in board-core.mjs so it can be tested
// without the SDK; this file only wires the canvas/session lifecycle + actions.
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
import {
startServer, ensureStateFile, loadDoc, mutate, recapMarkdown,
opStatus, opNote, opCount, opCarry, opFocus, opAddTask,
} from "./board-core.mjs";
// instanceId -> { server, url, stateFile }
const servers = new Map();
// Run a mutation for the canvas instance and return { ok, state } / { error }.
async function act(ctx, fn) {
const entry = servers.get(ctx.instanceId);
if (!entry) return { error: "Board not open" };
return await mutate(entry.stateFile, fn);
}
async function read(ctx) {
const entry = servers.get(ctx.instanceId);
if (!entry) return null;
return await loadDoc(entry.stateFile);
}
const session = await joinSession({
canvases: [
createCanvas({
id: "daily-focus-board",
displayName: "Daily Focus Board",
description: "A warm, executive-function-friendly daily focus board backed by a JSON file you and your AI partner both read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind 'not today' carryover, a brain-dump box, reduced motion, and a live clock.",
inputSchema: {
type: "object",
properties: {
stateFile: {
type: "string",
description: "Absolute path to the board's JSON state file. Seeded if it doesn't exist yet. Defaults to a date-scoped focus-board-<YYYY-MM-DD>.json in the current working directory, so each day starts a fresh board; pass an explicit path to keep a single ongoing file.",
},
seed: {
type: "object",
description: "Initial board, used ONLY when the state file doesn't exist: { name, dateKey, tasks: [ { id, emoji, title, sub, tag, tagc, due, goal, start, inc, unit } ] }. A numeric goal makes a counter; otherwise a status task.",
},
},
},
actions: [
{
name: "get_board",
description: "Return the full board state as JSON (tasks, statuses, progress notes, counters, momentum feed, parked thoughts). Use to see where the day stands.",
handler: async (ctx) => {
const doc = await read(ctx);
return doc ? { ok: true, state: doc } : { error: "Board not open" };
},
},
{
name: "recap",
description: "Return a Markdown end-of-day recap (what got done, momentum, parked thoughts). Paste it into a journal or use it to plan tomorrow.",
handler: async (ctx) => {
const doc = await read(ctx);
return doc ? { ok: true, markdown: recapMarkdown(doc) } : { error: "Board not open" };
},
},
{
name: "set_status",
description: "Set a task's status to todo, doing, or done (e.g. mark the design doc done). Omit status to advance to the next status.",
inputSchema: {
type: "object",
properties: {
taskId: { type: "string", description: "The task's id" },
status: { type: "string", enum: ["todo", "doing", "done"] },
},
required: ["taskId"],
},
handler: (ctx) => act(ctx, doc => opStatus(doc, ctx.input.taskId, ctx.input.status)),
},
{
name: "log_progress",
description: "Add a timestamped progress note to a task (moves it to 'in progress' if it was to-do). Small logged wins build the momentum feed.",
inputSchema: {
type: "object",
properties: {
taskId: { type: "string", description: "The task's id" },
note: { type: "string", description: "The progress note" },
},
required: ["taskId", "note"],
},
handler: (ctx) => act(ctx, doc => opNote(doc, ctx.input.taskId, ctx.input.note)),
},
{
name: "set_count",
description: "Set a numeric-counter task's current value (e.g. steps to 6200).",
inputSchema: {
type: "object",
properties: {
taskId: { type: "string", description: "The counter task's id" },
value: { type: "number", description: "New current value" },
},
required: ["taskId", "value"],
},
handler: (ctx) => act(ctx, doc => opCount(doc, ctx.input.taskId, { value: ctx.input.value })),
},
{
name: "carry_over",
description: "Kindly carry a task to tomorrow ('not today') — no shame, it leaves today's progress ring. Pass carried:false to bring it back to today.",
inputSchema: {
type: "object",
properties: {
taskId: { type: "string", description: "The task's id" },
carried: { type: "boolean", description: "true = carry to tomorrow (default toggles)" },
},
required: ["taskId"],
},
handler: (ctx) => act(ctx, doc => opCarry(doc, ctx.input.taskId, ctx.input.carried)),
},
{
name: "add_task",
description: "Add a task. A numeric 'goal' makes it a counter (steps/pages/pomodoros); otherwise a status task. Keep the board to ~4-9 items — a focus board, not a backlog.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
emoji: { type: "string" },
sub: { type: "string" },
tag: { type: "string" },
tagc: { type: "string", enum: ["new", "deadline", "career"] },
due: { type: "string", description: "ISO local datetime for a gentle countdown" },
goal: { type: "number", description: "Makes this a counter task" },
start: { type: "number" },
inc: { type: "number" },
unit: { type: "string" },
id: { type: "string", description: "Optional stable id; derived from the title if omitted" },
},
required: ["title"],
},
handler: (ctx) => act(ctx, doc => opAddTask(doc, ctx.input)),
},
{
name: "focus",
description: "Enter Focus mode on one task (dim the rest), or omit taskId to clear focus.",
inputSchema: {
type: "object",
properties: { taskId: { type: "string", description: "Task to focus; omit to clear" } },
},
handler: (ctx) => act(ctx, doc => opFocus(doc, ctx.input.taskId || null)),
},
{
name: "refresh",
description: "Return the current board state (a fresh read of the state file).",
handler: async (ctx) => {
const doc = await read(ctx);
return doc ? { ok: true, state: doc } : { error: "Board not open" };
},
},
],
open: async (ctx) => {
const stateFile = await ensureStateFile(ctx.input?.stateFile, ctx.input?.seed);
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(stateFile);
entry.stateFile = stateFile;
servers.set(ctx.instanceId, entry);
}
return { title: "🔥 Daily Focus Board", url: entry.url };
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (entry) {
servers.delete(ctx.instanceId);
await new Promise((resolve) => entry.server.close(() => resolve()));
}
},
}),
],
});
@@ -0,0 +1,18 @@
{
"name": "daily-focus-board",
"version": "0.1.0",
"type": "module",
"main": "extension.mjs",
"description": "A warm, executive-function-friendly daily focus board rendered in a GHCP canvas and backed by a JSON file your AI partner can read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind carryover, a brain-dump box, reduced motion, and a live clock.",
"dependencies": {
"@github/copilot-sdk": "latest"
},
"keywords": [
"focus",
"daily-planner",
"executive-function",
"adhd-friendly",
"productivity",
"canvas"
]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "ember",
"description": "An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find.",
"version": "1.0.0",
"version": "1.2.0",
"author": {
"name": "jennyf19"
},
@@ -0,0 +1,169 @@
---
name: daily-focus-board
description: 'Spin up a personal, motivating daily focus board that renders in a browser canvas and that the user drives by talking to their AI partner. Tasks track status (to-do → in progress → done) with timestamped progress notes and roll up into a "today''s momentum" feed; numeric-goal tasks (pages, pomodoros, reps) render as progress-bar counters. Executive-function / neurodivergent-friendly by design: Focus mode, kind "not today" carryover (no overdue-shaming), a brain-dump box, reduced-motion, and gentle deadline countdowns. Add, reorder, and relabel tasks live, assign Eisenhower priority (Do first / Schedule / Delegate / Later), open with an above/below-the-line check-in and a daily mantra, and save an end-of-day recap. Use when someone wants to plan their day, stay focused, kick off a work session, or track progress. Progress persists in the browser (localStorage).'
---
# Daily Focus Board
A warm, visual "let's go" board for a person's day — rendered from a self-contained HTML
template, opened in a browser (ideally a side-panel **canvas** in the GitHub Copilot app), and
kept current *by conversation*: the human tells you what they finished, you update the board.
This is Ember partnership in daily practice: not a static to-do list, a thing you run *with*
your AI. Keep it light, encouraging, and honest — celebrate real progress, don't inflate it.
## When to use it
Trigger when the user wants to: plan today, "get organized / stay focused," start a work
session, or track progress on a set of tasks they list. If they just mention a pile of things
to do, offer the board.
## How to build it (3 steps)
**1. Gather the tasks.** Ask for (or lift from what they already said) their handful of tasks
for the day. For each, capture: a short title, an optional emoji, an optional one-line
sub-note, and an optional tag. If a task is a *count toward a number* (steps, pages,
pomodoros, reps), make it a **counter** with a numeric `goal`. Keep it to ~49 items — a focus
board, not a backlog.
**2. Generate the board.** Copy `assets/board.template.html` to a working file (e.g.
`focus-board.html` in a scratch/working dir — NOT into a source repo unless asked). In the copy,
find this line near the top:
```html
<script>window.__BOARD__ = null; /* SKILL: replace null with the config object above */</script>
```
Replace `null` with the config object. **Inject it as JSON with `<` escaped** so a task's text
can never break out of the `<script>` — e.g. `JSON.stringify(config).replace(/</g, "\\u003c")`
never hand-concatenate raw user-provided text. Schema:
```js
{
name: "Alex", // optional — shows "Let's go, Alex 🔥"; omit for "Let's go 🔥"
dateKey: "2026-07-27", // optional — localStorage key; defaults to today (YYYY-MM-DD)
mantra: "Small, real, done.", // optional — today's intention (editable on the board)
checkin: "above", // optional — arrival check-in: "below" | "mid" | "above"
tasks: [
// counter task (numeric goal → progress bar + set/ buttons):
{ id:"pages", emoji:"📖", title:"Read 30 pages",
goal:30, start:0, inc:5, unit:"pages", tag:"mind", tagc:"new", quad:"ins" },
// status task (to-do → in progress → done + progress notes):
{ id:"doc", emoji:"⚙️", title:"Finish the design doc", sub:"the anchor",
due:"2026-07-27T17:00", tag:"deadline", tagc:"deadline", quad:"iu" },
{ id:"move", emoji:"🌿", title:"Move a little — whatever fits your body", tag:"body" }
]
}
```
- `id` must be unique and stable, using only letters, digits, hyphens, or underscores
(`[A-Za-z0-9_-]`) — it's embedded in HTML attributes, CSS selectors, storage keys, and
colon-delimited note keys, so avoid quotes, colons, brackets, and spaces. `tagc` is an optional color class:
`new` (green), `deadline` (red/pink), `career` (purple); omit for the default grey.
- Counter tasks: `goal` (a **positive integer**), `start` (default 0), `inc` (default
`max(1, round(goal/10))`), `unit` (label). Everything else is a status task. Carryover
("not today") is for status tasks — counters are progress you dial down, not defer.
- `due` (optional, ISO local datetime) shows a **gentle** live countdown on the card, and after
the time passes says "was due 5:00pm — still worth doing" in soft amber (never angry red). Use
it for the one real anchor, not everything.
- `quad` (optional) sets a task's Eisenhower priority: `iu` (important & urgent → *Do first*),
`ins` (important, not urgent → *Schedule*), `niu` (urgent, not important → *Delegate*), `ninu`
(neither → *Later*). Renders as a colored accent; the person can change it on the board.
- `mantra` / `checkin` (both optional, top-level) seed today's intention and the above/below-the-line
arrival check-in. Set these from the conversation, or leave them for the person to set/tap.
- **Built-in, no config needed:** the "how are you arriving?" check-in + daily mantra (with 🔄
suggestions), **add-a-task** live, **drag-to-reorder** (⠿ handle) + **sort by priority**, a
🧭 priority guide, **editable labels** per tile, a gentle **overload nudge**, an **end-of-day
save** (download/copy a recap), Focus mode, "not today" carryover, the 🧠 brain-dump box, the
reduced-motion toggle, and the live clock. Just set good tasks; the rest comes for free.
**3. Serve + open it.** localStorage needs an `http://` origin, so serve the folder rather than
opening the file path directly:
- Serve (loopback only): `python -m http.server 8799 --bind 127.0.0.1` from the board's folder
(or `scripts/serve-board.ps1`). Binding to `127.0.0.1` keeps a board of personal tasks off the
local network.
If Python isn't available, any static file server works — you just need an `http://` origin.
- Open: prefer a **browser canvas** side-panel if the host supports one (best experience —
it sits next to the chat). Otherwise open `http://localhost:8799/focus-board.html` in the
default browser.
- If you truly can't serve, opening the file directly still works in most browsers; just note
that some restrict localStorage on `file://`, so progress may not persist.
## How to drive it (the partnership part)
Once it's up, keep it current **through conversation** — this is the whole point:
- When the user says they finished / started something, either (a) tell them the one-tap move
("tap the pill on the design-doc card to mark it done"), or (b) regenerate the board only when
the *task list itself* changes (add/rename tasks) — status and notes live in the browser
(localStorage) and are set by tapping, not by config; a regenerate with the same `dateKey`
preserves existing progress. Clicking is faster for live updates.
- Encourage logging **incremental notes** ("all three desks up and running") — momentum is
built from small logged wins, and the momentum feed becomes the story of their day.
- The board is the artifact; you are the partner. Check in, nudge the anchor task (the one with
a deadline), celebrate real completion, and protect against overload (too many cards = not a
focus board).
## Executive-function-friendly behavior (how to show up)
The board's UI has EF/neurodivergent affordances, but **the biggest help is how you, the
partner, behave.** This matters for everyone and is essential for people with ADHD or executive-function challenges.
Bake these in — they're not optional politeness, they're the point:
- **You are a body-double.** The whole premise — "drive your board by talking to your AI" — is
body-doubling, a well-documented focus strategy. Stay present: check in, co-work, be the
gentle other-in-the-room. Don't just set up the board and vanish.
- **Beat activation energy: shrink the first step.** When someone's stuck starting a task, don't
say "just do it." Offer *one tiny concrete first action* ("open the doc and write the ugliest
possible first sentence"). Starting is the wall; make the first step almost too small to refuse.
- **Suggest ONE next thing, not the list.** When asked "what now?", name a single next action —
and offer Focus mode (dim the rest). A visible list of 8 is overwhelming; one is doable.
- **Celebrate starting, not just finishing.** Moving a task to "in progress" is a real win. Log
it in the momentum feed. Dopamine on starting is what carries people with ADHD through.
- **Never shame an incomplete.** No "you didn't finish." Offer **"not today"** carryover freely —
deciding *not* to do something is a valid, healthy choice, not a failure. Missing one task
should never threaten the whole system (all-or-nothing spirals are how these tools get abandoned).
- **Externalize intrusive thoughts.** If they get pulled toward something mid-task, tell them to
**park it in the brain-dump box** and keep going — don't chase it now.
- **Make time concrete.** Time blindness is real; reference the clock and the gentle countdowns,
and nudge the anchor task before its `due` — kindly, not as a threat.
- **Protect against overload.** The board nudges gently when it passes ~9 active tasks; back it
up — help them carry things to tomorrow (⤳ not today). A focus board that's a backlog isn't a
focus board.
- **Open with a check-in, not a task list.** Invite them to *locate* how they're arriving
(above / in-between / below the line — from Conscious Leadership). It's noticing without
judgment; below-the-line just means "be gentler, shrink the first step." Never diagnose it.
- **Offer an intention (the mantra).** A short line for the day — theirs, or one you suggest that
fits the check-in (grounding when they're below the line, momentum when above). Keep it kind.
- **Prioritize together, gently.** If everything feels equally urgent, walk the Eisenhower
quadrants with them (Do first / Schedule / Delegate / Later) and offer "sort by priority" — the
point is to make *Schedule* (important, not urgent) visible, not to cram more in.
- **Close the day: save it.** At end of day, have them **download or copy the recap** — and if
they copy it, they can paste it to you to journal the day and set up tomorrow. Celebrate what
got done; frame carryover as a healthy choice, not a miss.
Frame all of this as *executive-function-friendly design for everyone* — never diagnose, never
assume someone is neurodivergent, and keep every affordance optional. See
`references/neurodivergent-design.md` for the principles behind each feature.
## Honest limits (say these if relevant)
- **State lives in the browser (localStorage).** It's per-browser and you (the agent) can't read
it back directly. The **end-of-day recap** (download/copy) bridges this: when they paste the
copied recap to you, you *can* journal and plan from it. For a fully automatic read/write loop,
see `references/customize.md` (file-backed state, v2).
- **The polished side-panel experience needs a host with a browser canvas** (like the GitHub
Copilot app). Everywhere else it's a normal browser tab — same board, less integrated.
## References
- `references/tutorial.md` — how to use the board in the GitHub Copilot app (browser canvas) or
directly through Ember: the daily loop (check-in → mantra → plan → work → end-of-day save).
- `examples/sample-board.html` — a populated example board: open it to see the board in action,
or copy it as a starting point (it's this template with a sample config injected).
- `references/neurodivergent-design.md` — the executive-function / ADHD design principles behind
each feature (task initiation, time blindness, working memory, overwhelm, reward, shame, capture,
body-doubling), and the "make it optional, don't medicalize" stance.
- `references/customize.md` — theming, the file-backed-state upgrade (agent can read/write
progress), and the optional "shared signals" bridge for people who run a multi-agent workshop.
@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Today · let's go 🔥</title>
<!--
SKILL: daily-focus-board (Ember) — executive-function-friendly daily board.
Personalize by setting window.__BOARD__ below to a config object:
{ name:"Alex", dateKey:"2026-07-27",
mantra:"Small, real, done — one block at a time.", // optional: today's intention
checkin:"above", // optional: "below" | "mid" | "above"
tasks:[
{ id:"pages", emoji:"📖", title:"Read 30 pages",
goal:30, start:0, inc:5, unit:"pages", tag:"mind", tagc:"new" },
{ id:"doc", emoji:"⚙️", title:"Finish the design doc",
sub:"the anchor", due:"2026-07-27T17:00", tag:"deadline", tagc:"deadline" },
{ id:"read", emoji:"📖", title:"Read a chapter", tag:"mind" } ] }
- numeric `goal` → counter card (progress bar). otherwise a status card
(to-do → in progress → done) with progress notes.
- optional `due` (ISO local datetime) → gentle live countdown, never red-shaming.
- optional `mantra` / `checkin` → today's intention + an above/below-the-line arrival
check-in (both editable on the board; the partner can also set them from your chat).
Built-in features (no config needed): a "how are you arriving?" check-in + daily mantra,
add-a-task, Focus mode (dim all but one), "not today" kind carryover, a 🧠 brain-dump
capture box, a reduced-motion toggle, and always-visible time. State persists in localStorage.
-->
<script>window.__BOARD__ = null; /* SKILL: replace null with the config object above */</script>
<style>
:root{--bg:#0f1117;--card:#191d29;--line:#2b3150;--ink:#eef1fa;--sub:#98a2bd;
--ember:#ff7a3c;--ember2:#ffb23c;--good:#37d39a;--urgent:#ff5d73;}
*{box-sizing:border-box} html,body{margin:0}
body{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;
background:radial-gradient(1100px 560px at 82% -12%,#34204a 0%,var(--bg) 55%);
color:var(--ink);min-height:100vh;padding:34px 18px 90px;}
body.rm *{transition:none!important;animation:none!important}
.wrap{max-width:800px;margin:0 auto}
header{display:flex;align-items:flex-start;gap:22px;margin-bottom:4px}
.ring{width:98px;height:98px;flex:none;border-radius:50%;position:relative;background:conic-gradient(var(--ember) 0deg,var(--line) 0deg)}
.ring b{position:absolute;inset:12px;border-radius:50%;background:#12141c;display:grid;place-items:center;font-size:22px;font-weight:800}
h1{font-size:27px;margin:0 0 3px}
.date{color:var(--sub);font-size:14px;margin:0}
.date .clock{color:var(--ember2);font-variant-numeric:tabular-nums;font-weight:600}
.tally{color:var(--ember2);font-size:13.5px;margin:6px 0 0;font-weight:600}
.spark{color:var(--sub);font-size:13.5px;margin:8px 0 0;min-height:18px;font-style:italic}
.controls{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
.chip{font-size:12px;padding:5px 11px;border-radius:999px;background:#232a42;border:1px solid var(--line);color:#aab4d6;cursor:pointer;user-select:none}
.chip:hover{border-color:var(--ember)}
.chip.on{background:#3a2f18;border-color:#5c471f;color:#ffce7a}
.focusbar{display:none;align-items:center;gap:10px;margin:16px 0 0;padding:10px 14px;background:#20182c;border:1px solid #4a3a6b;border-radius:12px;font-size:14px}
body.focusing .focusbar{display:flex}
.focusbar .x{margin-left:auto;color:#c3a9ff;cursor:pointer;font-weight:600}
.cards{margin-top:16px;display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px 16px;transition:border-color .2s,background .2s,opacity .2s}
.card.done{background:#141826;border-color:#243a30}
.card.doing{border-color:#4a3a1e}
body.focusing .card{opacity:.24;filter:saturate(.5)}
body.focusing .card.focused{opacity:1;filter:none;border-color:#6b52a0}
.card.carried{opacity:.5}
.top{display:flex;align-items:center;gap:14px}
.box{width:30px;height:30px;flex:none;border-radius:9px;border:2px solid #3b4472;display:grid;place-items:center;font-size:16px;color:#12141c;cursor:pointer;transition:.2s;user-select:none}
.box.doing{background:var(--ember2);border-color:var(--ember2)}
.box.done{background:var(--good);border-color:var(--good)}
.emoji{font-size:22px;flex:none;width:26px;text-align:center}
.body{flex:1;min-width:0}
.title{font-size:16px;font-weight:600}
.card.done .title{color:var(--sub)}
.sub{font-size:12.5px;color:var(--sub);margin-top:2px}
.due{font-size:11.5px;margin-top:3px;color:var(--ember2);font-weight:600}
.due.over{color:#ffab6b}
.due.done{color:var(--good)}
.pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:4px 9px;border-radius:999px;cursor:pointer;flex:none;user-select:none;border:1px solid transparent}
.pill.todo{background:#232a42;color:#aab4d6}
.pill.doing{background:#3a2f18;color:#ffce7a;border-color:#5c471f}
.pill.done{background:#16311f;color:#7fe3bb;border-color:#204d33}
.pill.carried{background:#241f30;color:#b9a6dd;border-color:#3d2f57}
.fbtn{background:none;border:none;color:#6b7599;cursor:pointer;font-size:15px;flex:none;padding:2px}
.fbtn:hover{color:#c3a9ff}
.tag{font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:3px 8px;border-radius:999px;background:#232a42;color:#8b96b8;flex:none}
.tag.deadline{background:#3a1d26;color:#ff9caa}
.tag.new{background:#22322c;color:#7fe3bb}
.tag.career{background:#2c2540;color:#c3a9ff}
.cardfoot{margin:9px 0 0 44px;display:flex;align-items:center;gap:12px}
.soft{font-size:11.5px;color:var(--sub);cursor:pointer;background:none;border:none;padding:0}
.soft:hover{color:var(--ember2)}
.notes{margin:11px 0 0 44px;display:flex;flex-direction:column;gap:6px}
.note{display:flex;align-items:flex-start;gap:8px;font-size:13px;background:#12141c;border:1px solid var(--line);border-radius:9px;padding:6px 10px}
.note .nt{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px}
.note .nx{margin-left:auto;color:#556079;cursor:pointer;flex:none;font-size:14px;line-height:1}
.note .nx:hover{color:var(--urgent)}
.addrow{margin:9px 0 0 44px;display:flex;gap:7px}
.addrow input{flex:1;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 10px;font-size:13px}
.addrow input::placeholder{color:#566079}
.addrow input:focus{outline:none;border-color:var(--ember)}
.addrow button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:0 13px;cursor:pointer;font-size:15px}
.addrow button:hover{border-color:var(--ember);color:var(--ember2)}
.bar{height:12px;border-radius:999px;background:#232a42;overflow:hidden;margin:12px 0 0 0}
.fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,var(--ember),var(--ember2));transition:width .7s cubic-bezier(.2,.8,.2,1)}
.stepctl{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--sub);margin-top:10px}
.stepctl input{width:92px;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:14px}
.stepctl button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
.stepctl button:hover{border-color:var(--ember)}
.panel{margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px}
.panel h2{font-size:16px;margin:0 0 4px;display:flex;align-items:center;gap:8px}
.panel .hint{color:var(--sub);font-size:12px;margin:0 0 12px}
.chips{display:flex;flex-direction:column;gap:8px}
.fitem{display:flex;gap:10px;align-items:flex-start;font-size:13.5px;padding:8px 11px;background:#12141c;border:1px solid var(--line);border-radius:10px;border-left:3px solid var(--ember)}
.fitem.think{border-left-color:#c3a9ff}
.fitem .ft{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px;min-width:52px}
.fitem .fx{margin-left:auto;color:#556079;cursor:pointer;flex:none}
.fitem .fx:hover{color:var(--urgent)}
.empty{color:var(--sub);font-size:13px;font-style:italic;padding:6px 2px}
.foot{text-align:center;color:var(--sub);font-size:12px;margin-top:24px}
#cc{position:fixed;inset:0;pointer-events:none;z-index:50}
.checkin{margin:16px 0 0;padding:12px 14px;background:#161a26;border:1px solid var(--line);border-radius:14px}
.ci-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ci-label{color:var(--sub);font-size:13px;margin-right:2px}
.ci-opt{font-size:13px;padding:5px 12px;border-radius:999px;background:#232a42;border:1px solid var(--line);color:#aab4d6;cursor:pointer;user-select:none}
.ci-opt:hover{border-color:var(--ember)}
.ci-opt.on{background:#20182c;border-color:#6b52a0;color:#d9c7ff}
.ci-hint{color:var(--sub);font-size:12.5px;margin:8px 0 0;font-style:italic;min-height:16px}
.mantra{display:flex;align-items:center;gap:10px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}
.mantra-tag{font-size:10.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--ember2);flex:none}
.mantra-in{flex:1;min-width:0;background:transparent;border:none;border-bottom:1px dashed #3b4472;color:var(--ink);font-size:15.5px;font-style:italic;font-weight:600;padding:4px 2px}
.mantra-in:focus{outline:none;border-bottom-color:var(--ember)}
.mantra-in::placeholder{color:#5b6480;font-weight:400}
.mantra-btn{flex:none;background:#232a42;border:1px solid var(--line);color:#aab4d6;border-radius:9px;cursor:pointer;font-size:14px;padding:5px 9px}
.mantra-btn:hover{border-color:var(--ember)}
.addtask{margin-top:12px}
.addtask-toggle{background:#191d29;border:1px dashed #3b4472;color:var(--sub);border-radius:12px;padding:10px 14px;width:100%;cursor:pointer;font-size:14px;text-align:left}
.addtask-toggle:hover{border-color:var(--ember);color:var(--ink)}
.addtask-form{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px}
.addtask-form #ntitle,.addtask-form #nunit{background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:14px}
.addtask-form #ntitle{flex:1;min-width:160px}
.addtask-count{display:flex;align-items:center;gap:6px;color:var(--sub);font-size:13px;cursor:pointer}
.ncount-fields{align-items:center;gap:6px}
.ncount-fields input{width:96px;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:14px}
.addtask-form #naddbtn{background:var(--ember);border:none;color:#12141c;font-weight:700;border-radius:8px;padding:8px 14px;cursor:pointer}
.rmbtn{flex:none;background:none;border:none;color:#556079;cursor:pointer;font-size:14px;padding:2px}
.rmbtn:hover{color:var(--urgent)}
.overload{margin:16px 0 0;padding:11px 14px;background:#2a2213;border:1px solid #5c471f;border-radius:12px;color:#ffce7a;font-size:13.5px;line-height:1.5}
.prioline{display:flex;gap:8px;margin:14px 0 0;flex-wrap:wrap}
.legend{margin:10px 0 0;padding:12px 14px;background:#161a26;border:1px solid var(--line);border-radius:12px;font-size:13px;color:var(--sub);line-height:1.5}
.legend-row{display:flex;align-items:center;gap:8px;padding:4px 0}
.qbadge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;padding:3px 8px;border-radius:6px;flex:none;color:#12141c}
.qbadge.q-iu{background:var(--urgent)}
.qbadge.q-ins{background:#5aa9ff}
.qbadge.q-niu{background:var(--ember2)}
.qbadge.q-ninu{background:#8892b0}
.card.q-iu{border-left:4px solid var(--urgent)}
.card.q-ins{border-left:4px solid #5aa9ff}
.card.q-niu{border-left:4px solid var(--ember2)}
.card.q-ninu{border-left:4px solid #8892b0}
.card.dragover{border-color:var(--ember);box-shadow:0 0 0 2px rgba(255,122,60,.4)}
.cardmeta{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);flex-wrap:wrap}
.grip{cursor:grab;color:#556079;font-size:15px;user-select:none;flex:none}
.grip:active{cursor:grabbing}
.quadsel{background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 8px;font-size:12px}
.tagedit{flex:1;min-width:80px;max-width:170px;background:#12141c;border:1px solid var(--line);color:var(--sub);border-radius:999px;padding:4px 11px;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
.tagedit:focus{outline:none;border-color:var(--ember);color:var(--ink)}
#eoddownload{background:var(--ember);border:none;color:#12141c;font-weight:700;border-radius:9px;padding:8px 14px;cursor:pointer;font-size:14px}
button{font-family:inherit}
button.box{padding:0}
button.x,button.nx,button.fx,button.mv{background:none;border:none;padding:0;cursor:pointer;font-size:inherit;line-height:1}
.mv{color:#556079;font-size:11px;padding:0 1px}
.mv:hover{color:var(--ember)}
.tagedit.new{border-color:#2f6b4f;color:#8fe3bb}
.tagedit.deadline{border-color:#6b2f3f;color:#ff9fb0}
.tagedit.career{border-color:#4a3a6b;color:#c9b6ff}
</style>
</head>
<body>
<canvas id="cc"></canvas>
<div class="wrap">
<header>
<div class="ring" id="ring"><b id="ringtxt">0/0</b></div>
<div style="flex:1">
<h1 id="h1">Let's go 🔥</h1>
<p class="date" id="date"></p>
<p class="tally" id="tally"></p>
<p class="spark" id="spark"></p>
<div class="controls">
<button type="button" class="chip" id="focuschip" aria-pressed="false">🎯 Focus mode</button>
<button type="button" class="chip" id="rmchip" aria-pressed="false">🌙 Reduce motion</button>
</div>
</div>
</header>
<div class="checkin" id="checkin">
<div class="ci-row">
<span class="ci-label">Arriving today:</span>
<button type="button" class="ci-opt" data-checkin="below" aria-pressed="false">🌧️ below the line</button>
<button type="button" class="ci-opt" data-checkin="mid" aria-pressed="false">⛅ in between</button>
<button type="button" class="ci-opt" data-checkin="above" aria-pressed="false">☀️ above the line</button>
</div>
<p class="ci-hint" id="checkinhint">Just notice where you're arriving — no wrong answer.</p>
<div class="mantra">
<span class="mantra-tag">today's mantra</span>
<input id="mantrain" class="mantra-in" placeholder="set a mantra for today — or tap 🔄, or ask Ember for one"/>
<button id="mantrasuggest" class="mantra-btn" title="suggest a mantra" aria-label="suggest a mantra">🔄</button>
</div>
</div>
<div class="focusbar" id="focusbar"><span id="focustxt"></span><button type="button" class="x" id="focusexit">show all ✕</button></div>
<div class="overload" id="overload" style="display:none"></div>
<div class="prioline">
<button type="button" class="chip" id="prioguide" aria-expanded="false">🧭 priority guide</button>
<button type="button" class="chip" id="priosort">⬍ sort by priority</button>
</div>
<div class="legend" id="legend" style="display:none">
<div class="legend-row"><span class="qbadge q-iu">Do first</span> important &amp; urgent — do it now.</div>
<div class="legend-row"><span class="qbadge q-ins">Schedule</span> important, not urgent — plan a time. (The good, non-frantic work lives here.)</div>
<div class="legend-row"><span class="qbadge q-niu">Delegate</span> urgent, not important — hand it off, automate, or shrink it.</div>
<div class="legend-row"><span class="qbadge q-ninu">Later</span> not urgent, not important — park it, or kindly let it go.</div>
</div>
<div class="cards" id="cards"></div>
<div class="addtask" id="addtask">
<button class="addtask-toggle" id="addtaskbtn"> add a task</button>
<div class="addtask-form" id="addtaskform" style="display:none">
<input id="ntitle" placeholder="what needs doing?"/>
<label class="addtask-count"><input type="checkbox" id="niscount"/> count toward a number</label>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" step="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<button id="naddbtn">add</button>
<button id="ncancel" class="soft">cancel</button>
</div>
</div>
<div class="panel">
<h2>🧠 Parked thoughts</h2>
<p class="hint">Something pulling at your attention? Park it here so it's out of your head — deal with it later, not now.</p>
<div class="addrow" style="margin-left:0"><input id="brainin" placeholder="get it out of your head…"/><button id="brainbtn" title="park it" aria-label="park this thought"></button></div>
<div class="chips" id="brain" style="margin-top:12px"></div>
</div>
<div class="panel">
<h2>🔥 Today's momentum</h2>
<p class="hint">Every step you log lands here, newest first. Starting counts. Small wins count.</p>
<div class="addrow" style="margin-left:0"><input id="dayin" placeholder="log a win / milestone for the day…"/><button id="daybtn" title="add" aria-label="log a win for the day"></button></div>
<div class="chips" id="feed" style="margin-top:12px"></div>
</div>
<div class="panel">
<h2>🌙 End of day</h2>
<p class="hint">Save the day's progress — keep the recap, or paste it to Ember to journal today and plan tomorrow.</p>
<div class="addrow" style="margin-left:0">
<button id="eoddownload">💾 download recap</button>
<button id="eodcopy" class="soft">📋 copy to share with Ember</button>
</div>
<p class="ci-hint" id="eodmsg" role="status" aria-live="polite"></p>
</div>
<p class="foot">progress saves automatically in this browser · 🔥 built with Ember</p>
</div>
<script>
const DEMO={name:"",mantra:"",checkin:null,tasks:[
{id:"pages",emoji:"📖",title:"Read 30 pages",goal:30,start:0,inc:5,unit:"pages",tag:"mind",tagc:"new"},
{id:"deep",emoji:"⚙️",title:"Two hours of deep work",sub:"the thing that moves the needle",due:new Date(Date.now()+3*3600e3).toISOString(),tag:"anchor",tagc:"deadline"},
{id:"move",emoji:"🌿",title:"Move a little — whatever fits your body",tag:"body"}]};
const CFG=(window.__BOARD__&&window.__BOARD__.tasks)?window.__BOARD__:DEMO;
const NAME=CFG.name||""; const baseTasks=CFG.tasks||[]; let tasks=baseTasks.slice();
const MANTRAS={
above:["Ride the momentum — one block at a time.","You're in it. Keep the thread.","Open, curious, moving.","Great start — let it carry you."],
mid:["Progress over perfect.","One thing at a time.","Small, real, done. Repeat.","Just the next step — that's enough."],
below:["Small and kind. Just the next tiny step.","You don't have to feel ready — start gently.","Lower the bar. Starting counts today.","Be gentle with yourself; one small thing."]
};
const sparks=["One block at a time.","Starting is the win. The rest follows.","You don't have to feel ready — just start the next one.","Small, real, done. Repeat.","Progress over perfect.","Pick one thing. Just one."];
const KEY="focus-board-"+(CFG.dateKey||todayLocal());
const raw=(()=>{try{return JSON.parse((window.localStorage&&localStorage.getItem(KEY))||"{}")||{};}catch(e){return {};}})();
const prefersRM=window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let state={counters:(raw.counters||{}),t:(raw.t||{}),day:(raw.day||[]),brain:(raw.brain||[]),focus:(raw.focus||null),rm:(raw.rm!==undefined?raw.rm:prefersRM),added:(raw.added||[]),checkin:(raw.checkin!==undefined?raw.checkin:(CFG.checkin||null)),mantra:(raw.mantra!==undefined?raw.mantra:(CFG.mantra||"")),order:(raw.order||[]),quad:(raw.quad||{}),tag:(raw.tag||{})};
let dragId=null, renderedIds=[];
function ensureTaskState(){tasks.forEach(t=>{ if(typeof t.goal==="number"){if(state.counters[t.id]===undefined)state.counters[t.id]=(t.start||0);}
else if(!state.t[t.id])state.t[t.id]={status:"todo",notes:[],carried:false}; else if(state.t[t.id].carried===undefined)state.t[t.id].carried=false; });}
function rebuildTasks(){const seen={};tasks=baseTasks.concat(state.added||[]).filter(t=>{const id=t&&t.id;if(!validId(id)||seen[id])return false;seen[id]=1;return true;});ensureTaskState();if(state.focus&&!tasks.some(t=>t.id===state.focus))state.focus=null;}
rebuildTasks();
const cardsEl=document.getElementById("cards"),feedEl=document.getElementById("feed"),brainEl=document.getElementById("brain");
document.getElementById("h1").textContent=NAME?`Let's go, ${NAME} 🔥`:"Let's go 🔥";
document.getElementById("spark").textContent=sparks[Math.floor(Math.random()*sparks.length)];
if(state.rm)document.body.classList.add("rm");
document.getElementById("rmchip").classList.toggle("on",!!state.rm);document.getElementById("rmchip").setAttribute("aria-pressed",state.rm?"true":"false");
document.getElementById("mantrain").value=state.mantra||"";
document.getElementById("mantrain").addEventListener("input",e=>{state.mantra=e.target.value;save();});
document.getElementById("mantrasuggest").onclick=()=>suggestMantra();
document.querySelectorAll("[data-checkin]").forEach(b=>b.onclick=()=>setCheckin(b.dataset.checkin));
renderCheckin();
document.getElementById("addtaskbtn").onclick=()=>toggleAddForm();
document.getElementById("ncancel").onclick=()=>toggleAddForm(false);
document.getElementById("naddbtn").onclick=()=>addTask();
document.getElementById("ntitle").addEventListener("keydown",e=>{if(e.key==="Enter")addTask();});
document.getElementById("niscount").addEventListener("change",e=>{document.getElementById("ncountfields").style.display=e.target.checked?"inline-flex":"none";});
document.getElementById("prioguide").onclick=()=>{const l=document.getElementById("legend");const vis=(l.style.display==="none"||!l.style.display);l.style.display=vis?"block":"none";document.getElementById("prioguide").setAttribute("aria-expanded",vis?"true":"false");};
document.getElementById("priosort").onclick=()=>sortByPriority();
document.getElementById("eoddownload").onclick=()=>downloadRecap();
document.getElementById("eodcopy").onclick=()=>copyRecap();
function save(){try{if(window.localStorage)localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
function fmt(ms){return new Date(ms).toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}).toLowerCase().replace(" ","");}
function esc(s){return (s||"").replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));}
const LABEL={todo:"to do",doing:"in progress",done:"done"};
function isCounter(t){return typeof t.goal==="number";}
function statusOf(t){if(isCounter(t)){const v=state.counters[t.id];return v>=t.goal?"done":(v>0?"doing":"todo");}return state.t[t.id].status;}
function carriedOf(t){return !isCounter(t)&&state.t[t.id].carried;}
function dueStr(t){
if(!t.due)return null;
const ms=new Date(t.due).getTime()-Date.now();
if(statusOf(t)==="done")return {cls:"done",txt:"⏰ done — nice"};
if(ms<=0){const tm=Math.round(-ms/60e3),h=Math.floor(tm/60),m=tm%60;return {cls:"over",txt:`⏰ was due ${fmt(new Date(t.due))}${h||m?` · ${h?h+"h ":""}${m}m ago`:""} — still worth doing`};}
const tm=Math.round(ms/60e3),h=Math.floor(tm/60),m=tm%60;
return {cls:"",txt:`${h?h+"h ":""}${m}m left (due ${fmt(new Date(t.due))})`};
}
function render(){
cardsEl.innerHTML="";
const ordered=[...tasks].sort((a,b)=>{const ca=carriedOf(a)?1:0,cb=carriedOf(b)?1:0;if(ca!==cb)return ca-cb;return orderIndex(a.id)-orderIndex(b.id);});
renderedIds=ordered.map(t=>t.id);
ordered.forEach(t=>{
const st=statusOf(t),carried=carriedOf(t);
const c=document.createElement("div");
const q=quadOf(t), tag=tagOf(t);
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"")+(q?" q-"+q:"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
c.dataset.cardid=t.id;
const dh=dueStr(t);
const rmHtml=t.added?`<button class="rmbtn" data-rmtask="${t.id}" title="remove this task" aria-label="remove ${escAttr(t.title)}">🗑</button>`:"";
const metaHtml=`<div class="cardmeta"><span class="grip" draggable="true" data-grip="${t.id}" title="drag to reorder">⠿</span><button type="button" class="mv" data-move="${t.id}:-1" title="move up" aria-label="move up">▲</button><button type="button" class="mv" data-move="${t.id}:1" title="move down" aria-label="move down">▼</button>`
+`<select class="quadsel" data-quad="${t.id}" aria-label="priority for ${escAttr(t.title)}"><option value=""${q===""?" selected":""}>priority…</option>`
+`<option value="iu"${q==="iu"?" selected":""}>🔴 Do first</option><option value="ins"${q==="ins"?" selected":""}>🔵 Schedule</option>`
+`<option value="niu"${q==="niu"?" selected":""}>🟡 Delegate</option><option value="ninu"${q==="ninu"?" selected":""}>⚪ Later</option></select>`
+`<input class="tagedit ${safeCls(t.tagc)}" data-tagedit="${t.id}" value="${escAttr(tag)}" placeholder="label" aria-label="label for ${escAttr(t.title)}"/></div>`;
if(isCounter(t)){
const v=state.counters[t.id],g=t.goal>0?t.goal:1,pct=Math.min(100,Math.round(v/g*100)),inc=t.inc||Math.max(1,Math.round(g/10)),unit=t.unit||"";
c.innerHTML=`<div class="top"><div class="emoji">${esc(t.emoji||"🎯")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>
<div class="sub"><b style="color:var(--ink)">${v.toLocaleString()}</b> / ${t.goal.toLocaleString()} ${esc(unit)} · ${pct}%${st==="done"?" — done 🎉":""}</div>
${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>${metaHtml}`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});
} else {
const o=state.t[t.id],notes=o.notes||[];
const pillCls=carried?"carried":st, pillTxt=carried?"→ tomorrow":LABEL[st];
c.innerHTML=`<div class="top">
<button type="button" class="box ${st}" data-cyc="${t.id}" aria-label="cycle status">${st==="done"?"✓":(st==="doing"?"…":"")}</button>
<div class="emoji">${esc(t.emoji||"•")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>${t.sub?`<div class="sub">${esc(t.sub)}</div>`:""}${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<button type="button" class="pill ${pillCls}" data-cyc="${t.id}">${pillTxt}</button>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
${notes.length?`<div class="notes">${notes.map((n,i)=>`<div class="note"><span class="nt">${fmt(n.t)}</span><span>${esc(n.txt)}</span><button type="button" class="nx" data-del="${t.id}:${i}" aria-label="delete note">×</button></div>`).join("")}</div>`:""}
<div class="cardfoot">${st!=="done"?`<button class="soft" data-carry="${t.id}">${carried?"↩ bring back to today":"⤳ not today"}</button>`:""}</div>
<div class="addrow"><input placeholder="log a step…" data-in="${t.id}"/><button data-add="${t.id}" title="add note" aria-label="add a note to ${escAttr(t.title)}"></button></div>${metaHtml}`;
cardsEl.appendChild(c);
}
});
cardsEl.querySelectorAll("[data-cyc]").forEach(el=>el.onclick=()=>cycle(el.dataset.cyc));
cardsEl.querySelectorAll("[data-add]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-in="${b.dataset.add}"]`);addNote(b.dataset.add,inp.value);});
cardsEl.querySelectorAll("[data-in]").forEach(inp=>inp.addEventListener("keydown",e=>{if(e.key==="Enter")addNote(inp.dataset.in,inp.value);}));
cardsEl.querySelectorAll("[data-del]").forEach(x=>x.onclick=()=>{const[id,i]=x.dataset.del.split(":");state.t[id].notes.splice(+i,1);save();render();});
cardsEl.querySelectorAll("[data-cset]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-cin="${b.dataset.cset}"]`);setCounter(b.dataset.cset,+inp.value);});
cardsEl.querySelectorAll("[data-cinc]").forEach(b=>b.onclick=()=>{const t=tasks.find(x=>x.id===b.dataset.cinc);setCounter(b.dataset.cinc,state.counters[b.dataset.cinc]+(t.inc||Math.max(1,Math.round(t.goal/10))));});
cardsEl.querySelectorAll("[data-focus]").forEach(b=>b.onclick=()=>setFocus(b.dataset.focus));
cardsEl.querySelectorAll("[data-carry]").forEach(b=>b.onclick=()=>toggleCarry(b.dataset.carry));
cardsEl.querySelectorAll("[data-rmtask]").forEach(b=>b.onclick=()=>removeTask(b.dataset.rmtask));
cardsEl.querySelectorAll("[data-quad]").forEach(s=>s.onchange=()=>setQuad(s.dataset.quad,s.value));
cardsEl.querySelectorAll("[data-tagedit]").forEach(i=>i.addEventListener("change",()=>setTag(i.dataset.tagedit,i.value)));
cardsEl.querySelectorAll(".card").forEach(c=>{c.addEventListener("dragover",e=>{e.preventDefault();c.classList.add("dragover");});c.addEventListener("dragleave",()=>c.classList.remove("dragover"));c.addEventListener("drop",e=>{e.preventDefault();c.classList.remove("dragover");if(dragId)reorder(dragId,c.dataset.cardid);});});
cardsEl.querySelectorAll("[data-grip]").forEach(g=>g.addEventListener("dragstart",e=>{dragId=g.dataset.grip;if(e.dataTransfer){e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/plain",dragId);}}));
cardsEl.querySelectorAll("[data-move]").forEach(b=>b.onclick=()=>{const p=b.dataset.move.split(":");moveTask(p[0],+p[1]);});
renderBrain(); renderFeed(); updateRing(); updateFocusBar(); updateOverload(); tick();
}
function renderFeed(){
const items=[];
tasks.forEach(t=>{if(isCounter(t))return;(state.t[t.id].notes||[]).forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:t.emoji||"•",src:t.id,idx:i}));});
state.day.forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:"📌",src:"day",idx:i}));
items.sort((a,b)=>b.t-a.t);
if(!items.length){feedEl.innerHTML=`<div class="empty">No steps logged yet — starting counts. Log your first one 👆</div>`;return;}
feedEl.innerHTML=items.map(it=>`<div class="fitem"><span class="ft">${fmt(it.t)}</span><span>${esc(it.emoji)}</span><span>${esc(it.txt)}</span><button type="button" class="fx" data-fdel="${it.src}:${it.idx}" aria-label="delete">×</button></div>`).join("");
feedEl.querySelectorAll("[data-fdel]").forEach(x=>x.onclick=()=>{const[src,i]=x.dataset.fdel.split(":");if(src==="day")state.day.splice(+i,1);else state.t[src].notes.splice(+i,1);save();render();});
}
function renderBrain(){
if(!state.brain.length){brainEl.innerHTML=`<div class="empty">Nothing parked. When a stray thought hits, drop it here and keep going.</div>`;return;}
brainEl.innerHTML=state.brain.map((n,i)=>`<div class="fitem think"><span class="ft">${fmt(n.t)}</span><span>💭</span><span>${esc(n.txt)}</span><button type="button" class="fx" data-bdel="${i}" aria-label="delete">×</button></div>`).join("");
brainEl.querySelectorAll("[data-bdel]").forEach(x=>x.onclick=()=>{state.brain.splice(+x.dataset.bdel,1);save();render();});
}
function addNote(id,txt){txt=(txt||"").trim();if(!txt)return;state.t[id].notes.push({t:Date.now(),txt});if(state.t[id].status==="todo")state.t[id].status="doing";if(state.t[id].carried)state.t[id].carried=false;save();render();}
function addDayNote(txt){txt=(txt||"").trim();if(!txt)return;state.day.unshift({t:Date.now(),txt});save();render();}
function addBrain(txt){txt=(txt||"").trim();if(!txt)return;state.brain.unshift({t:Date.now(),txt});save();render();}
document.getElementById("daybtn").onclick=()=>{const i=document.getElementById("dayin");addDayNote(i.value);i.value="";};
document.getElementById("dayin").addEventListener("keydown",e=>{if(e.key==="Enter"){addDayNote(e.target.value);e.target.value="";}});
document.getElementById("brainbtn").onclick=()=>{const i=document.getElementById("brainin");addBrain(i.value);i.value="";};
document.getElementById("brainin").addEventListener("keydown",e=>{if(e.key==="Enter"){addBrain(e.target.value);e.target.value="";}});
function cycle(id){const o=["todo","doing","done"],nx=o[(o.indexOf(state.t[id].status)+1)%3];state.t[id].status=nx;if(state.t[id].carried)state.t[id].carried=false;save();if(nx==="done"){burst(70);checkAll();}render();}
function setCounter(id,v){v=Math.max(0,Math.round(v||0));const t=tasks.find(x=>x.id===id);const was=state.counters[id]>=t.goal;state.counters[id]=v;save();if(!was&&v>=t.goal){burst(160);checkAll();}render();}
function toggleCarry(id){state.t[id].carried=!state.t[id].carried;save();render();}
function setFocus(id){state.focus=(state.focus===id?null:id);save();render();}
function setCheckin(v){state.checkin=(state.checkin===v?null:v);save();renderCheckin();if(state.checkin&&!(state.mantra||"").trim())suggestMantra();}
function renderCheckin(){document.querySelectorAll("[data-checkin]").forEach(b=>{const on=b.dataset.checkin===state.checkin;b.classList.toggle("on",on);b.setAttribute("aria-pressed",on?"true":"false");});const h=document.getElementById("checkinhint");const msg={below:"Below the line — and that's ok. Be gentle; shrink the first step.",mid:"Somewhere in between. Just noticing, no judgment.",above:"Above the line — open and ready. Ride it."};if(h)h.textContent=state.checkin?msg[state.checkin]:"Just notice where you're arriving — no wrong answer.";}
function suggestMantra(){const zone=(state.checkin==="above"||state.checkin==="below")?state.checkin:"mid";const list=MANTRAS[zone]||MANTRAS.mid;setMantra(list[Math.floor(Math.random()*list.length)]);}
function setMantra(m){state.mantra=m;const i=document.getElementById("mantrain");if(i)i.value=m;save();}
function toggleAddForm(show){const f=document.getElementById("addtaskform"),b=document.getElementById("addtaskbtn");const on=(show===undefined)?(f.style.display==="none"||!f.style.display):show;f.style.display=on?"flex":"none";b.style.display=on?"none":"inline-flex";if(on)document.getElementById("ntitle").focus();}
function addTask(){const ti=document.getElementById("ntitle"),gi=document.getElementById("ngoal"),ui=document.getElementById("nunit"),isc=document.getElementById("niscount");const title=(ti.value||"").trim();if(!title)return;const id="u"+Date.now().toString(36);const t={id,title,emoji:"📝",added:true};if(isc.checked&&+gi.value>0){t.goal=+gi.value;t.start=0;t.unit=(ui.value||"").trim();t.inc=Math.max(1,Math.round(+gi.value/10));}state.added.push(t);save();rebuildTasks();ti.value="";gi.value="";ui.value="";isc.checked=false;document.getElementById("ncountfields").style.display="none";toggleAddForm(false);render();}
function removeTask(id){state.added=(state.added||[]).filter(x=>x.id!==id);delete state.t[id];delete state.counters[id];if(state.focus===id)state.focus=null;save();rebuildTasks();render();}
function todayLocal(){const d=new Date();return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");}
function recapDate(){if(CFG.dateKey&&/^\d{4}-\d{2}-\d{2}$/.test(CFG.dateKey)){const p=CFG.dateKey.split("-");return new Date(+p[0],+p[1]-1,+p[2]);}return new Date();}
function moveTask(id,dir){let ids=renderedIds.slice();const i=ids.indexOf(id);if(i<0)return;const j=i+dir;if(j<0||j>=ids.length)return;const tmp=ids[i];ids[i]=ids[j];ids[j]=tmp;state.order=ids;save();render();}
function stopConfetti(){if(rafId!==null){cancelAnimationFrame(rafId);rafId=null;}parts=[];if(cx)cx.clearRect(0,0,cv.width,cv.height);}
function orderIndex(id){const i=(state.order||[]).indexOf(id);return i<0?9999:i;}
function quadOf(t){return state.quad[t.id]!==undefined?state.quad[t.id]:(t.quad||"");}
function tagOf(t){return state.tag[t.id]!==undefined?state.tag[t.id]:(t.tag||"");}
function setQuad(id,v){state.quad[id]=v;save();render();}
function setTag(id,v){state.tag[id]=v;save();render();}
function escAttr(s){return esc(s).replace(/"/g,"&quot;");}
function safeCls(s){return (s||"").replace(/[^a-zA-Z0-9_-]/g,"");}
function validId(id){return typeof id==="string"&&/^[A-Za-z0-9_-]+$/.test(id);}
function reorder(from,targetId){if(!from||from===targetId)return;let ids=renderedIds.slice();const fromI=ids.indexOf(from),tgtI=ids.indexOf(targetId);if(fromI<0||tgtI<0)return;ids.splice(fromI,1);let ins=ids.indexOf(targetId);if(fromI<tgtI)ins+=1;ids.splice(ins,0,from);state.order=ids;save();render();}
function sortByPriority(){const rank={iu:0,ins:1,niu:2,ninu:3,"":4};const ids=[...tasks].sort((a,b)=>{const ca=carriedOf(a)?1:0,cb=carriedOf(b)?1:0;if(ca!==cb)return ca-cb;const ra=(rank[quadOf(a)]===undefined?4:rank[quadOf(a)]),rb=(rank[quadOf(b)]===undefined?4:rank[quadOf(b)]);if(ra!==rb)return ra-rb;return orderIndex(a.id)-orderIndex(b.id);}).map(t=>t.id);state.order=ids;save();render();}
function updateOverload(){const el=document.getElementById("overload");if(!el)return;const live=tasks.filter(t=>!carriedOf(t)&&statusOf(t)!=="done").length;if(live>9){el.style.display="block";el.textContent=`That's ${live} active tasks for today — more than a focus board loves. Consider carrying a few to tomorrow (⤳ not today), or use Focus mode to take one at a time. No pressure — this is a nudge, not a rule.`;}else{el.style.display="none";}}
function dayRecap(){
const dstr=recapDate().toLocaleDateString(undefined,{weekday:"long",month:"long",day:"numeric",year:"numeric"});
const zoneTxt={below:"below the line 🌧️",mid:"in between ⛅",above:"above the line ☀️"};
const L=[`# Focus board — ${dstr}`,""];
const head=[];if(state.checkin&&zoneTxt[state.checkin])head.push(`**Arrived:** ${zoneTxt[state.checkin]}`);if((state.mantra||"").trim())head.push(`**Mantra:** "${state.mantra.trim()}"`);
if(head.length)L.push(head.join(" · "),"");
const live=tasks.filter(t=>!carriedOf(t));
const done=live.filter(t=>statusOf(t)==="done"),doing=live.filter(t=>statusOf(t)==="doing"),todo=live.filter(t=>statusOf(t)==="todo"),carried=tasks.filter(t=>carriedOf(t));
const line=t=>{let extra="";if(isCounter(t))extra=` (${state.counters[t.id].toLocaleString()}/${t.goal.toLocaleString()}${t.unit?" "+t.unit:""})`;const lbl=tagOf(t)?`[${tagOf(t)}] `:"";return `- ${t.emoji||"•"} ${lbl}${t.title}${extra}`;};
L.push(`## Done (${done.length}/${live.length})`);done.length?done.forEach(t=>L.push(line(t))):L.push("- (nothing marked done — and starting still counts)");L.push("");
if(doing.length){L.push("## In progress");doing.forEach(t=>L.push(line(t)));L.push("");}
if(todo.length){L.push("## Still to do");todo.forEach(t=>L.push(line(t)));L.push("");}
if(carried.length){L.push("## Carried to tomorrow (a valid, healthy choice)");carried.forEach(t=>L.push(line(t)));L.push("");}
const feed=[];tasks.forEach(t=>{if(isCounter(t))return;(state.t[t.id].notes||[]).forEach(n=>feed.push({t:n.t,txt:n.txt,e:t.emoji||"•"}));});state.day.forEach(n=>feed.push({t:n.t,txt:n.txt,e:"📌"}));feed.sort((a,b)=>a.t-b.t);
if(feed.length){L.push("## Momentum");feed.forEach(f=>L.push(`- ${fmt(f.t)}${f.e} ${f.txt}`));L.push("");}
if((state.brain||[]).length){L.push("## Parked thoughts");state.brain.forEach(n=>L.push(`- 💭 ${n.txt}`));L.push("");}
L.push(`_saved ${fmt(Date.now())} · 🔥 built with Ember_`);
return L.join("\n");
}
function eodMsg(m){const el=document.getElementById("eodmsg");if(el)el.textContent=m;}
function downloadRecap(){const md=dayRecap();try{const blob=new Blob([md],{type:"text/markdown"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download="focus-"+(CFG.dateKey||todayLocal())+".md";document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);eodMsg("saved a recap to your downloads 💾");}catch(e){eodMsg("couldn't auto-download here — use copy instead 📋");}}
function fallbackCopy(md,cb){try{const ta=document.createElement("textarea");ta.value=md;document.body.appendChild(ta);ta.select();const ok=document.execCommand("copy");ta.remove();if(ok){cb&&cb();}else{eodMsg("couldn't copy here — try download 💾");}}catch(e){eodMsg("couldn't copy here — try download 💾");}}
function copyRecap(){const md=dayRecap();const ok=()=>eodMsg("copied — paste it to Ember to journal your day 📋");if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(ok,()=>fallbackCopy(md,ok));}else{fallbackCopy(md,ok);}}
document.getElementById("focuschip").onclick=()=>{
if(state.focus){state.focus=null;}
else{const cand=tasks.find(t=>statusOf(t)==="doing"&&!carriedOf(t))||tasks.find(t=>statusOf(t)==="todo"&&!carriedOf(t))||tasks[0];state.focus=cand?cand.id:null;}
save();render();
};
document.getElementById("focusexit").onclick=()=>{state.focus=null;save();render();};
document.getElementById("rmchip").onclick=()=>{state.rm=!state.rm;document.body.classList.toggle("rm",state.rm);document.getElementById("rmchip").classList.toggle("on",state.rm);document.getElementById("rmchip").setAttribute("aria-pressed",state.rm?"true":"false");if(state.rm)stopConfetti();save();};
function checkAll(){const live=tasks.filter(t=>!carriedOf(t));if(live.length&&live.every(t=>statusOf(t)==="done"))setTimeout(()=>{burst(320);document.getElementById("spark").textContent="Everything you kept for today — done. 🔥";},250);}
function updateFocusBar(){
document.body.classList.toggle("focusing",!!state.focus);
{const fc=document.getElementById("focuschip");if(fc)fc.setAttribute("aria-pressed",state.focus?"true":"false");}
if(state.focus){const t=tasks.find(x=>x.id===state.focus);document.getElementById("focustxt").innerHTML=`🎯 Just this one right now: <b>${esc(t?t.title:"")}</b>`;}
}
function updateRing(){
const live=tasks.filter(t=>!carriedOf(t));
const done=live.filter(t=>statusOf(t)==="done").length,doing=live.filter(t=>statusOf(t)==="doing").length,carried=tasks.length-live.length;
const n=live.length;
document.getElementById("ring").style.background=`conic-gradient(var(--ember) ${n?done/n*360:0}deg,var(--line) 0deg)`;
document.getElementById("ringtxt").textContent=done+"/"+n;
document.getElementById("tally").textContent=`${done} done · ${doing} in progress · ${n-done-doing} to go`+(carried?` · ${carried} for tomorrow`:"");
}
function tick(){
const now=new Date();
document.getElementById("date").innerHTML=now.toLocaleDateString(undefined,{weekday:"long",month:"long",day:"numeric"})+` · <span class="clock">${fmt(now.getTime())}</span>`;
tasks.forEach(t=>{const el=document.querySelector(`[data-duefor="${t.id}"]`);const dh=dueStr(t);if(el&&dh){el.className="due "+dh.cls;el.textContent=dh.txt;}});
}
setInterval(tick,30000);
const cv=document.getElementById("cc"),cx=cv.getContext("2d");let parts=[];
function size(){cv.width=innerWidth;cv.height=innerHeight;}addEventListener("resize",size);size();
const COL=["#ff7a3c","#ffb23c","#37d39a","#c3a9ff","#ff5d73","#eef1fa"];
let rafId=null;
function burst(n){if(state.rm||!cx)return;for(let i=0;i<n;i++)parts.push({x:innerWidth/2+(Math.random()-.5)*220,y:innerHeight*0.28,vx:(Math.random()-.5)*11,vy:Math.random()*-13-4,g:.42,life:70+Math.random()*40,c:COL[i%COL.length],s:5+Math.random()*6,rot:Math.random()*6});if(rafId===null)rafId=requestAnimationFrame(loop);}
function loop(){if(!cx){rafId=null;return;}cx.clearRect(0,0,cv.width,cv.height);parts.forEach(p=>{p.vy+=p.g;p.x+=p.vx;p.y+=p.vy;p.life--;p.rot+=.2;cx.save();cx.translate(p.x,p.y);cx.rotate(p.rot);cx.fillStyle=p.c;cx.fillRect(-p.s/2,-p.s/2,p.s,p.s*1.6);cx.restore();});parts=parts.filter(p=>p.life>0&&p.y<cv.height+40);rafId=parts.length?requestAnimationFrame(loop):null;}
render();
</script>
</body>
</html>
@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Today · let's go 🔥</title>
<!--
SKILL: daily-focus-board (Ember) — executive-function-friendly daily board.
Personalize by setting window.__BOARD__ below to a config object:
{ name:"Alex", dateKey:"2026-07-27",
mantra:"Small, real, done — one block at a time.", // optional: today's intention
checkin:"above", // optional: "below" | "mid" | "above"
tasks:[
{ id:"pages", emoji:"📖", title:"Read 30 pages",
goal:30, start:0, inc:5, unit:"pages", tag:"mind", tagc:"new" },
{ id:"doc", emoji:"⚙️", title:"Finish the design doc",
sub:"the anchor", due:"2026-07-27T17:00", tag:"deadline", tagc:"deadline" },
{ id:"read", emoji:"📖", title:"Read a chapter", tag:"mind" } ] }
- numeric `goal` → counter card (progress bar). otherwise a status card
(to-do → in progress → done) with progress notes.
- optional `due` (ISO local datetime) → gentle live countdown, never red-shaming.
- optional `mantra` / `checkin` → today's intention + an above/below-the-line arrival
check-in (both editable on the board; the partner can also set them from your chat).
Built-in features (no config needed): a "how are you arriving?" check-in + daily mantra,
add-a-task, Focus mode (dim all but one), "not today" kind carryover, a 🧠 brain-dump
capture box, a reduced-motion toggle, and always-visible time. State persists in localStorage.
-->
<script>window.__BOARD__ = {
name:"Sam",checkin:"above",mantra:"Small, real, done — one block at a time.",
tasks:[
{id:"ship",emoji:"🚀",title:"Ship the release notes",sub:"the anchor",due:new Date(Date.now()+2*3600e3).toISOString(),tag:"deadline",tagc:"deadline",quad:"iu"},
{id:"pages",emoji:"📖",title:"Read 30 pages",goal:30,start:8,inc:5,unit:"pages",tag:"mind",tagc:"new",quad:"ins"},
{id:"pom",emoji:"🍅",title:"4 focus pomodoros",goal:4,start:1,inc:1,unit:"pomodoros",tag:"focus",quad:"ins"},
{id:"inbox",emoji:"📧",title:"Inbox to zero",quad:"niu"}
]
};</script>
<style>
:root{--bg:#0f1117;--card:#191d29;--line:#2b3150;--ink:#eef1fa;--sub:#98a2bd;
--ember:#ff7a3c;--ember2:#ffb23c;--good:#37d39a;--urgent:#ff5d73;}
*{box-sizing:border-box} html,body{margin:0}
body{font-family:"Segoe UI",system-ui,-apple-system,sans-serif;
background:radial-gradient(1100px 560px at 82% -12%,#34204a 0%,var(--bg) 55%);
color:var(--ink);min-height:100vh;padding:34px 18px 90px;}
body.rm *{transition:none!important;animation:none!important}
.wrap{max-width:800px;margin:0 auto}
header{display:flex;align-items:flex-start;gap:22px;margin-bottom:4px}
.ring{width:98px;height:98px;flex:none;border-radius:50%;position:relative;background:conic-gradient(var(--ember) 0deg,var(--line) 0deg)}
.ring b{position:absolute;inset:12px;border-radius:50%;background:#12141c;display:grid;place-items:center;font-size:22px;font-weight:800}
h1{font-size:27px;margin:0 0 3px}
.date{color:var(--sub);font-size:14px;margin:0}
.date .clock{color:var(--ember2);font-variant-numeric:tabular-nums;font-weight:600}
.tally{color:var(--ember2);font-size:13.5px;margin:6px 0 0;font-weight:600}
.spark{color:var(--sub);font-size:13.5px;margin:8px 0 0;min-height:18px;font-style:italic}
.controls{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
.chip{font-size:12px;padding:5px 11px;border-radius:999px;background:#232a42;border:1px solid var(--line);color:#aab4d6;cursor:pointer;user-select:none}
.chip:hover{border-color:var(--ember)}
.chip.on{background:#3a2f18;border-color:#5c471f;color:#ffce7a}
.focusbar{display:none;align-items:center;gap:10px;margin:16px 0 0;padding:10px 14px;background:#20182c;border:1px solid #4a3a6b;border-radius:12px;font-size:14px}
body.focusing .focusbar{display:flex}
.focusbar .x{margin-left:auto;color:#c3a9ff;cursor:pointer;font-weight:600}
.cards{margin-top:16px;display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px 16px;transition:border-color .2s,background .2s,opacity .2s}
.card.done{background:#141826;border-color:#243a30}
.card.doing{border-color:#4a3a1e}
body.focusing .card{opacity:.24;filter:saturate(.5)}
body.focusing .card.focused{opacity:1;filter:none;border-color:#6b52a0}
.card.carried{opacity:.5}
.top{display:flex;align-items:center;gap:14px}
.box{width:30px;height:30px;flex:none;border-radius:9px;border:2px solid #3b4472;display:grid;place-items:center;font-size:16px;color:#12141c;cursor:pointer;transition:.2s;user-select:none}
.box.doing{background:var(--ember2);border-color:var(--ember2)}
.box.done{background:var(--good);border-color:var(--good)}
.emoji{font-size:22px;flex:none;width:26px;text-align:center}
.body{flex:1;min-width:0}
.title{font-size:16px;font-weight:600}
.card.done .title{color:var(--sub)}
.sub{font-size:12.5px;color:var(--sub);margin-top:2px}
.due{font-size:11.5px;margin-top:3px;color:var(--ember2);font-weight:600}
.due.over{color:#ffab6b}
.due.done{color:var(--good)}
.pill{font-size:10.5px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:4px 9px;border-radius:999px;cursor:pointer;flex:none;user-select:none;border:1px solid transparent}
.pill.todo{background:#232a42;color:#aab4d6}
.pill.doing{background:#3a2f18;color:#ffce7a;border-color:#5c471f}
.pill.done{background:#16311f;color:#7fe3bb;border-color:#204d33}
.pill.carried{background:#241f30;color:#b9a6dd;border-color:#3d2f57}
.fbtn{background:none;border:none;color:#6b7599;cursor:pointer;font-size:15px;flex:none;padding:2px}
.fbtn:hover{color:#c3a9ff}
.tag{font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:3px 8px;border-radius:999px;background:#232a42;color:#8b96b8;flex:none}
.tag.deadline{background:#3a1d26;color:#ff9caa}
.tag.new{background:#22322c;color:#7fe3bb}
.tag.career{background:#2c2540;color:#c3a9ff}
.cardfoot{margin:9px 0 0 44px;display:flex;align-items:center;gap:12px}
.soft{font-size:11.5px;color:var(--sub);cursor:pointer;background:none;border:none;padding:0}
.soft:hover{color:var(--ember2)}
.notes{margin:11px 0 0 44px;display:flex;flex-direction:column;gap:6px}
.note{display:flex;align-items:flex-start;gap:8px;font-size:13px;background:#12141c;border:1px solid var(--line);border-radius:9px;padding:6px 10px}
.note .nt{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px}
.note .nx{margin-left:auto;color:#556079;cursor:pointer;flex:none;font-size:14px;line-height:1}
.note .nx:hover{color:var(--urgent)}
.addrow{margin:9px 0 0 44px;display:flex;gap:7px}
.addrow input{flex:1;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 10px;font-size:13px}
.addrow input::placeholder{color:#566079}
.addrow input:focus{outline:none;border-color:var(--ember)}
.addrow button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:0 13px;cursor:pointer;font-size:15px}
.addrow button:hover{border-color:var(--ember);color:var(--ember2)}
.bar{height:12px;border-radius:999px;background:#232a42;overflow:hidden;margin:12px 0 0 0}
.fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,var(--ember),var(--ember2));transition:width .7s cubic-bezier(.2,.8,.2,1)}
.stepctl{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--sub);margin-top:10px}
.stepctl input{width:92px;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:14px}
.stepctl button{background:#232a42;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
.stepctl button:hover{border-color:var(--ember)}
.panel{margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px}
.panel h2{font-size:16px;margin:0 0 4px;display:flex;align-items:center;gap:8px}
.panel .hint{color:var(--sub);font-size:12px;margin:0 0 12px}
.chips{display:flex;flex-direction:column;gap:8px}
.fitem{display:flex;gap:10px;align-items:flex-start;font-size:13.5px;padding:8px 11px;background:#12141c;border:1px solid var(--line);border-radius:10px;border-left:3px solid var(--ember)}
.fitem.think{border-left-color:#c3a9ff}
.fitem .ft{color:var(--ember2);font-variant-numeric:tabular-nums;font-size:11.5px;flex:none;padding-top:1px;min-width:52px}
.fitem .fx{margin-left:auto;color:#556079;cursor:pointer;flex:none}
.fitem .fx:hover{color:var(--urgent)}
.empty{color:var(--sub);font-size:13px;font-style:italic;padding:6px 2px}
.foot{text-align:center;color:var(--sub);font-size:12px;margin-top:24px}
#cc{position:fixed;inset:0;pointer-events:none;z-index:50}
.checkin{margin:16px 0 0;padding:12px 14px;background:#161a26;border:1px solid var(--line);border-radius:14px}
.ci-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ci-label{color:var(--sub);font-size:13px;margin-right:2px}
.ci-opt{font-size:13px;padding:5px 12px;border-radius:999px;background:#232a42;border:1px solid var(--line);color:#aab4d6;cursor:pointer;user-select:none}
.ci-opt:hover{border-color:var(--ember)}
.ci-opt.on{background:#20182c;border-color:#6b52a0;color:#d9c7ff}
.ci-hint{color:var(--sub);font-size:12.5px;margin:8px 0 0;font-style:italic;min-height:16px}
.mantra{display:flex;align-items:center;gap:10px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}
.mantra-tag{font-size:10.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--ember2);flex:none}
.mantra-in{flex:1;min-width:0;background:transparent;border:none;border-bottom:1px dashed #3b4472;color:var(--ink);font-size:15.5px;font-style:italic;font-weight:600;padding:4px 2px}
.mantra-in:focus{outline:none;border-bottom-color:var(--ember)}
.mantra-in::placeholder{color:#5b6480;font-weight:400}
.mantra-btn{flex:none;background:#232a42;border:1px solid var(--line);color:#aab4d6;border-radius:9px;cursor:pointer;font-size:14px;padding:5px 9px}
.mantra-btn:hover{border-color:var(--ember)}
.addtask{margin-top:12px}
.addtask-toggle{background:#191d29;border:1px dashed #3b4472;color:var(--sub);border-radius:12px;padding:10px 14px;width:100%;cursor:pointer;font-size:14px;text-align:left}
.addtask-toggle:hover{border-color:var(--ember);color:var(--ink)}
.addtask-form{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px}
.addtask-form #ntitle,.addtask-form #nunit{background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:14px}
.addtask-form #ntitle{flex:1;min-width:160px}
.addtask-count{display:flex;align-items:center;gap:6px;color:var(--sub);font-size:13px;cursor:pointer}
.ncount-fields{align-items:center;gap:6px}
.ncount-fields input{width:96px;background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:14px}
.addtask-form #naddbtn{background:var(--ember);border:none;color:#12141c;font-weight:700;border-radius:8px;padding:8px 14px;cursor:pointer}
.rmbtn{flex:none;background:none;border:none;color:#556079;cursor:pointer;font-size:14px;padding:2px}
.rmbtn:hover{color:var(--urgent)}
.overload{margin:16px 0 0;padding:11px 14px;background:#2a2213;border:1px solid #5c471f;border-radius:12px;color:#ffce7a;font-size:13.5px;line-height:1.5}
.prioline{display:flex;gap:8px;margin:14px 0 0;flex-wrap:wrap}
.legend{margin:10px 0 0;padding:12px 14px;background:#161a26;border:1px solid var(--line);border-radius:12px;font-size:13px;color:var(--sub);line-height:1.5}
.legend-row{display:flex;align-items:center;gap:8px;padding:4px 0}
.qbadge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;padding:3px 8px;border-radius:6px;flex:none;color:#12141c}
.qbadge.q-iu{background:var(--urgent)}
.qbadge.q-ins{background:#5aa9ff}
.qbadge.q-niu{background:var(--ember2)}
.qbadge.q-ninu{background:#8892b0}
.card.q-iu{border-left:4px solid var(--urgent)}
.card.q-ins{border-left:4px solid #5aa9ff}
.card.q-niu{border-left:4px solid var(--ember2)}
.card.q-ninu{border-left:4px solid #8892b0}
.card.dragover{border-color:var(--ember);box-shadow:0 0 0 2px rgba(255,122,60,.4)}
.cardmeta{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);flex-wrap:wrap}
.grip{cursor:grab;color:#556079;font-size:15px;user-select:none;flex:none}
.grip:active{cursor:grabbing}
.quadsel{background:#12141c;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 8px;font-size:12px}
.tagedit{flex:1;min-width:80px;max-width:170px;background:#12141c;border:1px solid var(--line);color:var(--sub);border-radius:999px;padding:4px 11px;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
.tagedit:focus{outline:none;border-color:var(--ember);color:var(--ink)}
#eoddownload{background:var(--ember);border:none;color:#12141c;font-weight:700;border-radius:9px;padding:8px 14px;cursor:pointer;font-size:14px}
button{font-family:inherit}
button.box{padding:0}
button.x,button.nx,button.fx,button.mv{background:none;border:none;padding:0;cursor:pointer;font-size:inherit;line-height:1}
.mv{color:#556079;font-size:11px;padding:0 1px}
.mv:hover{color:var(--ember)}
.tagedit.new{border-color:#2f6b4f;color:#8fe3bb}
.tagedit.deadline{border-color:#6b2f3f;color:#ff9fb0}
.tagedit.career{border-color:#4a3a6b;color:#c9b6ff}
</style>
</head>
<body>
<canvas id="cc"></canvas>
<div class="wrap">
<header>
<div class="ring" id="ring"><b id="ringtxt">0/0</b></div>
<div style="flex:1">
<h1 id="h1">Let's go 🔥</h1>
<p class="date" id="date"></p>
<p class="tally" id="tally"></p>
<p class="spark" id="spark"></p>
<div class="controls">
<button type="button" class="chip" id="focuschip" aria-pressed="false">🎯 Focus mode</button>
<button type="button" class="chip" id="rmchip" aria-pressed="false">🌙 Reduce motion</button>
</div>
</div>
</header>
<div class="checkin" id="checkin">
<div class="ci-row">
<span class="ci-label">Arriving today:</span>
<button type="button" class="ci-opt" data-checkin="below" aria-pressed="false">🌧️ below the line</button>
<button type="button" class="ci-opt" data-checkin="mid" aria-pressed="false">⛅ in between</button>
<button type="button" class="ci-opt" data-checkin="above" aria-pressed="false">☀️ above the line</button>
</div>
<p class="ci-hint" id="checkinhint">Just notice where you're arriving — no wrong answer.</p>
<div class="mantra">
<span class="mantra-tag">today's mantra</span>
<input id="mantrain" class="mantra-in" placeholder="set a mantra for today — or tap 🔄, or ask Ember for one"/>
<button id="mantrasuggest" class="mantra-btn" title="suggest a mantra" aria-label="suggest a mantra">🔄</button>
</div>
</div>
<div class="focusbar" id="focusbar"><span id="focustxt"></span><button type="button" class="x" id="focusexit">show all ✕</button></div>
<div class="overload" id="overload" style="display:none"></div>
<div class="prioline">
<button type="button" class="chip" id="prioguide" aria-expanded="false">🧭 priority guide</button>
<button type="button" class="chip" id="priosort">⬍ sort by priority</button>
</div>
<div class="legend" id="legend" style="display:none">
<div class="legend-row"><span class="qbadge q-iu">Do first</span> important &amp; urgent — do it now.</div>
<div class="legend-row"><span class="qbadge q-ins">Schedule</span> important, not urgent — plan a time. (The good, non-frantic work lives here.)</div>
<div class="legend-row"><span class="qbadge q-niu">Delegate</span> urgent, not important — hand it off, automate, or shrink it.</div>
<div class="legend-row"><span class="qbadge q-ninu">Later</span> not urgent, not important — park it, or kindly let it go.</div>
</div>
<div class="cards" id="cards"></div>
<div class="addtask" id="addtask">
<button class="addtask-toggle" id="addtaskbtn"> add a task</button>
<div class="addtask-form" id="addtaskform" style="display:none">
<input id="ntitle" placeholder="what needs doing?"/>
<label class="addtask-count"><input type="checkbox" id="niscount"/> count toward a number</label>
<span id="ncountfields" class="ncount-fields" style="display:none"><input type="number" id="ngoal" min="1" step="1" placeholder="goal"/><input id="nunit" placeholder="unit (pages, reps…)"/></span>
<button id="naddbtn">add</button>
<button id="ncancel" class="soft">cancel</button>
</div>
</div>
<div class="panel">
<h2>🧠 Parked thoughts</h2>
<p class="hint">Something pulling at your attention? Park it here so it's out of your head — deal with it later, not now.</p>
<div class="addrow" style="margin-left:0"><input id="brainin" placeholder="get it out of your head…"/><button id="brainbtn" title="park it" aria-label="park this thought"></button></div>
<div class="chips" id="brain" style="margin-top:12px"></div>
</div>
<div class="panel">
<h2>🔥 Today's momentum</h2>
<p class="hint">Every step you log lands here, newest first. Starting counts. Small wins count.</p>
<div class="addrow" style="margin-left:0"><input id="dayin" placeholder="log a win / milestone for the day…"/><button id="daybtn" title="add" aria-label="log a win for the day"></button></div>
<div class="chips" id="feed" style="margin-top:12px"></div>
</div>
<div class="panel">
<h2>🌙 End of day</h2>
<p class="hint">Save the day's progress — keep the recap, or paste it to Ember to journal today and plan tomorrow.</p>
<div class="addrow" style="margin-left:0">
<button id="eoddownload">💾 download recap</button>
<button id="eodcopy" class="soft">📋 copy to share with Ember</button>
</div>
<p class="ci-hint" id="eodmsg" role="status" aria-live="polite"></p>
</div>
<p class="foot">progress saves automatically in this browser · 🔥 built with Ember</p>
</div>
<script>
const DEMO={name:"",mantra:"",checkin:null,tasks:[
{id:"pages",emoji:"📖",title:"Read 30 pages",goal:30,start:0,inc:5,unit:"pages",tag:"mind",tagc:"new"},
{id:"deep",emoji:"⚙️",title:"Two hours of deep work",sub:"the thing that moves the needle",due:new Date(Date.now()+3*3600e3).toISOString(),tag:"anchor",tagc:"deadline"},
{id:"move",emoji:"🌿",title:"Move a little — whatever fits your body",tag:"body"}]};
const CFG=(window.__BOARD__&&window.__BOARD__.tasks)?window.__BOARD__:DEMO;
const NAME=CFG.name||""; const baseTasks=CFG.tasks||[]; let tasks=baseTasks.slice();
const MANTRAS={
above:["Ride the momentum — one block at a time.","You're in it. Keep the thread.","Open, curious, moving.","Great start — let it carry you."],
mid:["Progress over perfect.","One thing at a time.","Small, real, done. Repeat.","Just the next step — that's enough."],
below:["Small and kind. Just the next tiny step.","You don't have to feel ready — start gently.","Lower the bar. Starting counts today.","Be gentle with yourself; one small thing."]
};
const sparks=["One block at a time.","Starting is the win. The rest follows.","You don't have to feel ready — just start the next one.","Small, real, done. Repeat.","Progress over perfect.","Pick one thing. Just one."];
const KEY="focus-board-"+(CFG.dateKey||todayLocal());
const raw=(()=>{try{return JSON.parse((window.localStorage&&localStorage.getItem(KEY))||"{}")||{};}catch(e){return {};}})();
const prefersRM=window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let state={counters:(raw.counters||{}),t:(raw.t||{}),day:(raw.day||[]),brain:(raw.brain||[]),focus:(raw.focus||null),rm:(raw.rm!==undefined?raw.rm:prefersRM),added:(raw.added||[]),checkin:(raw.checkin!==undefined?raw.checkin:(CFG.checkin||null)),mantra:(raw.mantra!==undefined?raw.mantra:(CFG.mantra||"")),order:(raw.order||[]),quad:(raw.quad||{}),tag:(raw.tag||{})};
let dragId=null, renderedIds=[];
function ensureTaskState(){tasks.forEach(t=>{ if(typeof t.goal==="number"){if(state.counters[t.id]===undefined)state.counters[t.id]=(t.start||0);}
else if(!state.t[t.id])state.t[t.id]={status:"todo",notes:[],carried:false}; else if(state.t[t.id].carried===undefined)state.t[t.id].carried=false; });}
function rebuildTasks(){const seen={};tasks=baseTasks.concat(state.added||[]).filter(t=>{const id=t&&t.id;if(!validId(id)||seen[id])return false;seen[id]=1;return true;});ensureTaskState();if(state.focus&&!tasks.some(t=>t.id===state.focus))state.focus=null;}
rebuildTasks();
const cardsEl=document.getElementById("cards"),feedEl=document.getElementById("feed"),brainEl=document.getElementById("brain");
document.getElementById("h1").textContent=NAME?`Let's go, ${NAME} 🔥`:"Let's go 🔥";
document.getElementById("spark").textContent=sparks[Math.floor(Math.random()*sparks.length)];
if(state.rm)document.body.classList.add("rm");
document.getElementById("rmchip").classList.toggle("on",!!state.rm);document.getElementById("rmchip").setAttribute("aria-pressed",state.rm?"true":"false");
document.getElementById("mantrain").value=state.mantra||"";
document.getElementById("mantrain").addEventListener("input",e=>{state.mantra=e.target.value;save();});
document.getElementById("mantrasuggest").onclick=()=>suggestMantra();
document.querySelectorAll("[data-checkin]").forEach(b=>b.onclick=()=>setCheckin(b.dataset.checkin));
renderCheckin();
document.getElementById("addtaskbtn").onclick=()=>toggleAddForm();
document.getElementById("ncancel").onclick=()=>toggleAddForm(false);
document.getElementById("naddbtn").onclick=()=>addTask();
document.getElementById("ntitle").addEventListener("keydown",e=>{if(e.key==="Enter")addTask();});
document.getElementById("niscount").addEventListener("change",e=>{document.getElementById("ncountfields").style.display=e.target.checked?"inline-flex":"none";});
document.getElementById("prioguide").onclick=()=>{const l=document.getElementById("legend");const vis=(l.style.display==="none"||!l.style.display);l.style.display=vis?"block":"none";document.getElementById("prioguide").setAttribute("aria-expanded",vis?"true":"false");};
document.getElementById("priosort").onclick=()=>sortByPriority();
document.getElementById("eoddownload").onclick=()=>downloadRecap();
document.getElementById("eodcopy").onclick=()=>copyRecap();
function save(){try{if(window.localStorage)localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}
function fmt(ms){return new Date(ms).toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}).toLowerCase().replace(" ","");}
function esc(s){return (s||"").replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));}
const LABEL={todo:"to do",doing:"in progress",done:"done"};
function isCounter(t){return typeof t.goal==="number";}
function statusOf(t){if(isCounter(t)){const v=state.counters[t.id];return v>=t.goal?"done":(v>0?"doing":"todo");}return state.t[t.id].status;}
function carriedOf(t){return !isCounter(t)&&state.t[t.id].carried;}
function dueStr(t){
if(!t.due)return null;
const ms=new Date(t.due).getTime()-Date.now();
if(statusOf(t)==="done")return {cls:"done",txt:"⏰ done — nice"};
if(ms<=0){const tm=Math.round(-ms/60e3),h=Math.floor(tm/60),m=tm%60;return {cls:"over",txt:`⏰ was due ${fmt(new Date(t.due))}${h||m?` · ${h?h+"h ":""}${m}m ago`:""} — still worth doing`};}
const tm=Math.round(ms/60e3),h=Math.floor(tm/60),m=tm%60;
return {cls:"",txt:`${h?h+"h ":""}${m}m left (due ${fmt(new Date(t.due))})`};
}
function render(){
cardsEl.innerHTML="";
const ordered=[...tasks].sort((a,b)=>{const ca=carriedOf(a)?1:0,cb=carriedOf(b)?1:0;if(ca!==cb)return ca-cb;return orderIndex(a.id)-orderIndex(b.id);});
renderedIds=ordered.map(t=>t.id);
ordered.forEach(t=>{
const st=statusOf(t),carried=carriedOf(t);
const c=document.createElement("div");
const q=quadOf(t), tag=tagOf(t);
c.className="card "+st+(carried?" carried":"")+(state.focus===t.id?" focused":"")+(q?" q-"+q:"");
c.setAttribute("role","group");c.setAttribute("aria-label",t.title||"task");
c.dataset.cardid=t.id;
const dh=dueStr(t);
const rmHtml=t.added?`<button class="rmbtn" data-rmtask="${t.id}" title="remove this task" aria-label="remove ${escAttr(t.title)}">🗑</button>`:"";
const metaHtml=`<div class="cardmeta"><span class="grip" draggable="true" data-grip="${t.id}" title="drag to reorder">⠿</span><button type="button" class="mv" data-move="${t.id}:-1" title="move up" aria-label="move up">▲</button><button type="button" class="mv" data-move="${t.id}:1" title="move down" aria-label="move down">▼</button>`
+`<select class="quadsel" data-quad="${t.id}" aria-label="priority for ${escAttr(t.title)}"><option value=""${q===""?" selected":""}>priority…</option>`
+`<option value="iu"${q==="iu"?" selected":""}>🔴 Do first</option><option value="ins"${q==="ins"?" selected":""}>🔵 Schedule</option>`
+`<option value="niu"${q==="niu"?" selected":""}>🟡 Delegate</option><option value="ninu"${q==="ninu"?" selected":""}>⚪ Later</option></select>`
+`<input class="tagedit ${safeCls(t.tagc)}" data-tagedit="${t.id}" value="${escAttr(tag)}" placeholder="label" aria-label="label for ${escAttr(t.title)}"/></div>`;
if(isCounter(t)){
const v=state.counters[t.id],g=t.goal>0?t.goal:1,pct=Math.min(100,Math.round(v/g*100)),inc=t.inc||Math.max(1,Math.round(g/10)),unit=t.unit||"";
c.innerHTML=`<div class="top"><div class="emoji">${esc(t.emoji||"🎯")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>
<div class="sub"><b style="color:var(--ink)">${v.toLocaleString()}</b> / ${t.goal.toLocaleString()} ${esc(unit)} · ${pct}%${st==="done"?" — done 🎉":""}</div>
${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<span class="pill ${st}">${LABEL[st]}</span>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
<div class="bar"><div class="fill" data-fill="${t.id}"></div></div>
<div class="stepctl">update: <input type="number" data-cin="${t.id}" value="${v}" min="0" step="1"/>
<button data-cset="${t.id}">set</button><button data-cinc="${t.id}">+${inc.toLocaleString()}</button></div>${metaHtml}`;
cardsEl.appendChild(c);
requestAnimationFrame(()=>{const f=cardsEl.querySelector(`[data-fill="${t.id}"]`);if(f)f.style.width=pct+"%";});
} else {
const o=state.t[t.id],notes=o.notes||[];
const pillCls=carried?"carried":st, pillTxt=carried?"→ tomorrow":LABEL[st];
c.innerHTML=`<div class="top">
<button type="button" class="box ${st}" data-cyc="${t.id}" aria-label="cycle status">${st==="done"?"✓":(st==="doing"?"…":"")}</button>
<div class="emoji">${esc(t.emoji||"•")}</div>
<div class="body"><div class="title">${esc(t.title)}</div>${t.sub?`<div class="sub">${esc(t.sub)}</div>`:""}${dh?`<div class="due ${dh.cls}" data-duefor="${t.id}">${dh.txt}</div>`:""}</div>
<button type="button" class="pill ${pillCls}" data-cyc="${t.id}">${pillTxt}</button>
<button class="fbtn" data-focus="${t.id}" title="focus on this" aria-label="focus on ${escAttr(t.title)}">🎯</button>${rmHtml}</div>
${notes.length?`<div class="notes">${notes.map((n,i)=>`<div class="note"><span class="nt">${fmt(n.t)}</span><span>${esc(n.txt)}</span><button type="button" class="nx" data-del="${t.id}:${i}" aria-label="delete note">×</button></div>`).join("")}</div>`:""}
<div class="cardfoot">${st!=="done"?`<button class="soft" data-carry="${t.id}">${carried?"↩ bring back to today":"⤳ not today"}</button>`:""}</div>
<div class="addrow"><input placeholder="log a step…" data-in="${t.id}"/><button data-add="${t.id}" title="add note" aria-label="add a note to ${escAttr(t.title)}"></button></div>${metaHtml}`;
cardsEl.appendChild(c);
}
});
cardsEl.querySelectorAll("[data-cyc]").forEach(el=>el.onclick=()=>cycle(el.dataset.cyc));
cardsEl.querySelectorAll("[data-add]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-in="${b.dataset.add}"]`);addNote(b.dataset.add,inp.value);});
cardsEl.querySelectorAll("[data-in]").forEach(inp=>inp.addEventListener("keydown",e=>{if(e.key==="Enter")addNote(inp.dataset.in,inp.value);}));
cardsEl.querySelectorAll("[data-del]").forEach(x=>x.onclick=()=>{const[id,i]=x.dataset.del.split(":");state.t[id].notes.splice(+i,1);save();render();});
cardsEl.querySelectorAll("[data-cset]").forEach(b=>b.onclick=()=>{const inp=cardsEl.querySelector(`[data-cin="${b.dataset.cset}"]`);setCounter(b.dataset.cset,+inp.value);});
cardsEl.querySelectorAll("[data-cinc]").forEach(b=>b.onclick=()=>{const t=tasks.find(x=>x.id===b.dataset.cinc);setCounter(b.dataset.cinc,state.counters[b.dataset.cinc]+(t.inc||Math.max(1,Math.round(t.goal/10))));});
cardsEl.querySelectorAll("[data-focus]").forEach(b=>b.onclick=()=>setFocus(b.dataset.focus));
cardsEl.querySelectorAll("[data-carry]").forEach(b=>b.onclick=()=>toggleCarry(b.dataset.carry));
cardsEl.querySelectorAll("[data-rmtask]").forEach(b=>b.onclick=()=>removeTask(b.dataset.rmtask));
cardsEl.querySelectorAll("[data-quad]").forEach(s=>s.onchange=()=>setQuad(s.dataset.quad,s.value));
cardsEl.querySelectorAll("[data-tagedit]").forEach(i=>i.addEventListener("change",()=>setTag(i.dataset.tagedit,i.value)));
cardsEl.querySelectorAll(".card").forEach(c=>{c.addEventListener("dragover",e=>{e.preventDefault();c.classList.add("dragover");});c.addEventListener("dragleave",()=>c.classList.remove("dragover"));c.addEventListener("drop",e=>{e.preventDefault();c.classList.remove("dragover");if(dragId)reorder(dragId,c.dataset.cardid);});});
cardsEl.querySelectorAll("[data-grip]").forEach(g=>g.addEventListener("dragstart",e=>{dragId=g.dataset.grip;if(e.dataTransfer){e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/plain",dragId);}}));
cardsEl.querySelectorAll("[data-move]").forEach(b=>b.onclick=()=>{const p=b.dataset.move.split(":");moveTask(p[0],+p[1]);});
renderBrain(); renderFeed(); updateRing(); updateFocusBar(); updateOverload(); tick();
}
function renderFeed(){
const items=[];
tasks.forEach(t=>{if(isCounter(t))return;(state.t[t.id].notes||[]).forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:t.emoji||"•",src:t.id,idx:i}));});
state.day.forEach((n,i)=>items.push({t:n.t,txt:n.txt,emoji:"📌",src:"day",idx:i}));
items.sort((a,b)=>b.t-a.t);
if(!items.length){feedEl.innerHTML=`<div class="empty">No steps logged yet — starting counts. Log your first one 👆</div>`;return;}
feedEl.innerHTML=items.map(it=>`<div class="fitem"><span class="ft">${fmt(it.t)}</span><span>${esc(it.emoji)}</span><span>${esc(it.txt)}</span><button type="button" class="fx" data-fdel="${it.src}:${it.idx}" aria-label="delete">×</button></div>`).join("");
feedEl.querySelectorAll("[data-fdel]").forEach(x=>x.onclick=()=>{const[src,i]=x.dataset.fdel.split(":");if(src==="day")state.day.splice(+i,1);else state.t[src].notes.splice(+i,1);save();render();});
}
function renderBrain(){
if(!state.brain.length){brainEl.innerHTML=`<div class="empty">Nothing parked. When a stray thought hits, drop it here and keep going.</div>`;return;}
brainEl.innerHTML=state.brain.map((n,i)=>`<div class="fitem think"><span class="ft">${fmt(n.t)}</span><span>💭</span><span>${esc(n.txt)}</span><button type="button" class="fx" data-bdel="${i}" aria-label="delete">×</button></div>`).join("");
brainEl.querySelectorAll("[data-bdel]").forEach(x=>x.onclick=()=>{state.brain.splice(+x.dataset.bdel,1);save();render();});
}
function addNote(id,txt){txt=(txt||"").trim();if(!txt)return;state.t[id].notes.push({t:Date.now(),txt});if(state.t[id].status==="todo")state.t[id].status="doing";if(state.t[id].carried)state.t[id].carried=false;save();render();}
function addDayNote(txt){txt=(txt||"").trim();if(!txt)return;state.day.unshift({t:Date.now(),txt});save();render();}
function addBrain(txt){txt=(txt||"").trim();if(!txt)return;state.brain.unshift({t:Date.now(),txt});save();render();}
document.getElementById("daybtn").onclick=()=>{const i=document.getElementById("dayin");addDayNote(i.value);i.value="";};
document.getElementById("dayin").addEventListener("keydown",e=>{if(e.key==="Enter"){addDayNote(e.target.value);e.target.value="";}});
document.getElementById("brainbtn").onclick=()=>{const i=document.getElementById("brainin");addBrain(i.value);i.value="";};
document.getElementById("brainin").addEventListener("keydown",e=>{if(e.key==="Enter"){addBrain(e.target.value);e.target.value="";}});
function cycle(id){const o=["todo","doing","done"],nx=o[(o.indexOf(state.t[id].status)+1)%3];state.t[id].status=nx;if(state.t[id].carried)state.t[id].carried=false;save();if(nx==="done"){burst(70);checkAll();}render();}
function setCounter(id,v){v=Math.max(0,Math.round(v||0));const t=tasks.find(x=>x.id===id);const was=state.counters[id]>=t.goal;state.counters[id]=v;save();if(!was&&v>=t.goal){burst(160);checkAll();}render();}
function toggleCarry(id){state.t[id].carried=!state.t[id].carried;save();render();}
function setFocus(id){state.focus=(state.focus===id?null:id);save();render();}
function setCheckin(v){state.checkin=(state.checkin===v?null:v);save();renderCheckin();if(state.checkin&&!(state.mantra||"").trim())suggestMantra();}
function renderCheckin(){document.querySelectorAll("[data-checkin]").forEach(b=>{const on=b.dataset.checkin===state.checkin;b.classList.toggle("on",on);b.setAttribute("aria-pressed",on?"true":"false");});const h=document.getElementById("checkinhint");const msg={below:"Below the line — and that's ok. Be gentle; shrink the first step.",mid:"Somewhere in between. Just noticing, no judgment.",above:"Above the line — open and ready. Ride it."};if(h)h.textContent=state.checkin?msg[state.checkin]:"Just notice where you're arriving — no wrong answer.";}
function suggestMantra(){const zone=(state.checkin==="above"||state.checkin==="below")?state.checkin:"mid";const list=MANTRAS[zone]||MANTRAS.mid;setMantra(list[Math.floor(Math.random()*list.length)]);}
function setMantra(m){state.mantra=m;const i=document.getElementById("mantrain");if(i)i.value=m;save();}
function toggleAddForm(show){const f=document.getElementById("addtaskform"),b=document.getElementById("addtaskbtn");const on=(show===undefined)?(f.style.display==="none"||!f.style.display):show;f.style.display=on?"flex":"none";b.style.display=on?"none":"inline-flex";if(on)document.getElementById("ntitle").focus();}
function addTask(){const ti=document.getElementById("ntitle"),gi=document.getElementById("ngoal"),ui=document.getElementById("nunit"),isc=document.getElementById("niscount");const title=(ti.value||"").trim();if(!title)return;const id="u"+Date.now().toString(36);const t={id,title,emoji:"📝",added:true};if(isc.checked&&+gi.value>0){t.goal=+gi.value;t.start=0;t.unit=(ui.value||"").trim();t.inc=Math.max(1,Math.round(+gi.value/10));}state.added.push(t);save();rebuildTasks();ti.value="";gi.value="";ui.value="";isc.checked=false;document.getElementById("ncountfields").style.display="none";toggleAddForm(false);render();}
function removeTask(id){state.added=(state.added||[]).filter(x=>x.id!==id);delete state.t[id];delete state.counters[id];if(state.focus===id)state.focus=null;save();rebuildTasks();render();}
function todayLocal(){const d=new Date();return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");}
function recapDate(){if(CFG.dateKey&&/^\d{4}-\d{2}-\d{2}$/.test(CFG.dateKey)){const p=CFG.dateKey.split("-");return new Date(+p[0],+p[1]-1,+p[2]);}return new Date();}
function moveTask(id,dir){let ids=renderedIds.slice();const i=ids.indexOf(id);if(i<0)return;const j=i+dir;if(j<0||j>=ids.length)return;const tmp=ids[i];ids[i]=ids[j];ids[j]=tmp;state.order=ids;save();render();}
function stopConfetti(){if(rafId!==null){cancelAnimationFrame(rafId);rafId=null;}parts=[];if(cx)cx.clearRect(0,0,cv.width,cv.height);}
function orderIndex(id){const i=(state.order||[]).indexOf(id);return i<0?9999:i;}
function quadOf(t){return state.quad[t.id]!==undefined?state.quad[t.id]:(t.quad||"");}
function tagOf(t){return state.tag[t.id]!==undefined?state.tag[t.id]:(t.tag||"");}
function setQuad(id,v){state.quad[id]=v;save();render();}
function setTag(id,v){state.tag[id]=v;save();render();}
function escAttr(s){return esc(s).replace(/"/g,"&quot;");}
function safeCls(s){return (s||"").replace(/[^a-zA-Z0-9_-]/g,"");}
function validId(id){return typeof id==="string"&&/^[A-Za-z0-9_-]+$/.test(id);}
function reorder(from,targetId){if(!from||from===targetId)return;let ids=renderedIds.slice();const fromI=ids.indexOf(from),tgtI=ids.indexOf(targetId);if(fromI<0||tgtI<0)return;ids.splice(fromI,1);let ins=ids.indexOf(targetId);if(fromI<tgtI)ins+=1;ids.splice(ins,0,from);state.order=ids;save();render();}
function sortByPriority(){const rank={iu:0,ins:1,niu:2,ninu:3,"":4};const ids=[...tasks].sort((a,b)=>{const ca=carriedOf(a)?1:0,cb=carriedOf(b)?1:0;if(ca!==cb)return ca-cb;const ra=(rank[quadOf(a)]===undefined?4:rank[quadOf(a)]),rb=(rank[quadOf(b)]===undefined?4:rank[quadOf(b)]);if(ra!==rb)return ra-rb;return orderIndex(a.id)-orderIndex(b.id);}).map(t=>t.id);state.order=ids;save();render();}
function updateOverload(){const el=document.getElementById("overload");if(!el)return;const live=tasks.filter(t=>!carriedOf(t)&&statusOf(t)!=="done").length;if(live>9){el.style.display="block";el.textContent=`That's ${live} active tasks for today — more than a focus board loves. Consider carrying a few to tomorrow (⤳ not today), or use Focus mode to take one at a time. No pressure — this is a nudge, not a rule.`;}else{el.style.display="none";}}
function dayRecap(){
const dstr=recapDate().toLocaleDateString(undefined,{weekday:"long",month:"long",day:"numeric",year:"numeric"});
const zoneTxt={below:"below the line 🌧️",mid:"in between ⛅",above:"above the line ☀️"};
const L=[`# Focus board — ${dstr}`,""];
const head=[];if(state.checkin&&zoneTxt[state.checkin])head.push(`**Arrived:** ${zoneTxt[state.checkin]}`);if((state.mantra||"").trim())head.push(`**Mantra:** "${state.mantra.trim()}"`);
if(head.length)L.push(head.join(" · "),"");
const live=tasks.filter(t=>!carriedOf(t));
const done=live.filter(t=>statusOf(t)==="done"),doing=live.filter(t=>statusOf(t)==="doing"),todo=live.filter(t=>statusOf(t)==="todo"),carried=tasks.filter(t=>carriedOf(t));
const line=t=>{let extra="";if(isCounter(t))extra=` (${state.counters[t.id].toLocaleString()}/${t.goal.toLocaleString()}${t.unit?" "+t.unit:""})`;const lbl=tagOf(t)?`[${tagOf(t)}] `:"";return `- ${t.emoji||"•"} ${lbl}${t.title}${extra}`;};
L.push(`## Done (${done.length}/${live.length})`);done.length?done.forEach(t=>L.push(line(t))):L.push("- (nothing marked done — and starting still counts)");L.push("");
if(doing.length){L.push("## In progress");doing.forEach(t=>L.push(line(t)));L.push("");}
if(todo.length){L.push("## Still to do");todo.forEach(t=>L.push(line(t)));L.push("");}
if(carried.length){L.push("## Carried to tomorrow (a valid, healthy choice)");carried.forEach(t=>L.push(line(t)));L.push("");}
const feed=[];tasks.forEach(t=>{if(isCounter(t))return;(state.t[t.id].notes||[]).forEach(n=>feed.push({t:n.t,txt:n.txt,e:t.emoji||"•"}));});state.day.forEach(n=>feed.push({t:n.t,txt:n.txt,e:"📌"}));feed.sort((a,b)=>a.t-b.t);
if(feed.length){L.push("## Momentum");feed.forEach(f=>L.push(`- ${fmt(f.t)}${f.e} ${f.txt}`));L.push("");}
if((state.brain||[]).length){L.push("## Parked thoughts");state.brain.forEach(n=>L.push(`- 💭 ${n.txt}`));L.push("");}
L.push(`_saved ${fmt(Date.now())} · 🔥 built with Ember_`);
return L.join("\n");
}
function eodMsg(m){const el=document.getElementById("eodmsg");if(el)el.textContent=m;}
function downloadRecap(){const md=dayRecap();try{const blob=new Blob([md],{type:"text/markdown"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download="focus-"+(CFG.dateKey||todayLocal())+".md";document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);eodMsg("saved a recap to your downloads 💾");}catch(e){eodMsg("couldn't auto-download here — use copy instead 📋");}}
function fallbackCopy(md,cb){try{const ta=document.createElement("textarea");ta.value=md;document.body.appendChild(ta);ta.select();const ok=document.execCommand("copy");ta.remove();if(ok){cb&&cb();}else{eodMsg("couldn't copy here — try download 💾");}}catch(e){eodMsg("couldn't copy here — try download 💾");}}
function copyRecap(){const md=dayRecap();const ok=()=>eodMsg("copied — paste it to Ember to journal your day 📋");if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(ok,()=>fallbackCopy(md,ok));}else{fallbackCopy(md,ok);}}
document.getElementById("focuschip").onclick=()=>{
if(state.focus){state.focus=null;}
else{const cand=tasks.find(t=>statusOf(t)==="doing"&&!carriedOf(t))||tasks.find(t=>statusOf(t)==="todo"&&!carriedOf(t))||tasks[0];state.focus=cand?cand.id:null;}
save();render();
};
document.getElementById("focusexit").onclick=()=>{state.focus=null;save();render();};
document.getElementById("rmchip").onclick=()=>{state.rm=!state.rm;document.body.classList.toggle("rm",state.rm);document.getElementById("rmchip").classList.toggle("on",state.rm);document.getElementById("rmchip").setAttribute("aria-pressed",state.rm?"true":"false");if(state.rm)stopConfetti();save();};
function checkAll(){const live=tasks.filter(t=>!carriedOf(t));if(live.length&&live.every(t=>statusOf(t)==="done"))setTimeout(()=>{burst(320);document.getElementById("spark").textContent="Everything you kept for today — done. 🔥";},250);}
function updateFocusBar(){
document.body.classList.toggle("focusing",!!state.focus);
{const fc=document.getElementById("focuschip");if(fc)fc.setAttribute("aria-pressed",state.focus?"true":"false");}
if(state.focus){const t=tasks.find(x=>x.id===state.focus);document.getElementById("focustxt").innerHTML=`🎯 Just this one right now: <b>${esc(t?t.title:"")}</b>`;}
}
function updateRing(){
const live=tasks.filter(t=>!carriedOf(t));
const done=live.filter(t=>statusOf(t)==="done").length,doing=live.filter(t=>statusOf(t)==="doing").length,carried=tasks.length-live.length;
const n=live.length;
document.getElementById("ring").style.background=`conic-gradient(var(--ember) ${n?done/n*360:0}deg,var(--line) 0deg)`;
document.getElementById("ringtxt").textContent=done+"/"+n;
document.getElementById("tally").textContent=`${done} done · ${doing} in progress · ${n-done-doing} to go`+(carried?` · ${carried} for tomorrow`:"");
}
function tick(){
const now=new Date();
document.getElementById("date").innerHTML=now.toLocaleDateString(undefined,{weekday:"long",month:"long",day:"numeric"})+` · <span class="clock">${fmt(now.getTime())}</span>`;
tasks.forEach(t=>{const el=document.querySelector(`[data-duefor="${t.id}"]`);const dh=dueStr(t);if(el&&dh){el.className="due "+dh.cls;el.textContent=dh.txt;}});
}
setInterval(tick,30000);
const cv=document.getElementById("cc"),cx=cv.getContext("2d");let parts=[];
function size(){cv.width=innerWidth;cv.height=innerHeight;}addEventListener("resize",size);size();
const COL=["#ff7a3c","#ffb23c","#37d39a","#c3a9ff","#ff5d73","#eef1fa"];
let rafId=null;
function burst(n){if(state.rm||!cx)return;for(let i=0;i<n;i++)parts.push({x:innerWidth/2+(Math.random()-.5)*220,y:innerHeight*0.28,vx:(Math.random()-.5)*11,vy:Math.random()*-13-4,g:.42,life:70+Math.random()*40,c:COL[i%COL.length],s:5+Math.random()*6,rot:Math.random()*6});if(rafId===null)rafId=requestAnimationFrame(loop);}
function loop(){if(!cx){rafId=null;return;}cx.clearRect(0,0,cv.width,cv.height);parts.forEach(p=>{p.vy+=p.g;p.x+=p.vx;p.y+=p.vy;p.life--;p.rot+=.2;cx.save();cx.translate(p.x,p.y);cx.rotate(p.rot);cx.fillStyle=p.c;cx.fillRect(-p.s/2,-p.s/2,p.s,p.s*1.6);cx.restore();});parts=parts.filter(p=>p.life>0&&p.y<cv.height+40);rafId=parts.length?requestAnimationFrame(loop):null;}
render();
</script>
</body>
</html>
@@ -0,0 +1,30 @@
# Customizing the Daily Focus Board
## Theming
Colors live in the `:root` CSS block of `assets/board.template.html`
(`--ember`, `--good`, `--urgent`, backgrounds). The look is warm/dark by default. Tag colors:
`new` (green), `deadline` (red), `career` (purple) — add your own by copying a `.tagedit.<name>`
rule and passing that name as a task's `tagc` (use a plain class name: letters, digits, `-`, `_`).
## v2 — file-backed state (closes the agent loop)
v1 stores progress in the browser (`localStorage`), which the agent can't read back. To let
your AI partner *read* your progress (e.g. to write your end-of-day journal) and *write* it
(e.g. "mark the design doc done" from chat), back the board with a JSON file instead:
- Board writes state to `board-state.json` (via a tiny local endpoint or the File System Access
API) instead of localStorage, and reads it on load.
- The agent reads/writes that same JSON. Now it's a two-way loop: you talk, the board updates,
and the agent can summarize the day from the same source of truth.
This is the genuinely *agentic* version — but it needs a small local write path, so it's a
deliberate upgrade, not the zero-setup v1.
## Optional — the "shared signals" bridge (for multi-agent workshop users)
The board's momentum notes are the same shape as an agent **progress signal**: a timestamped
self-report of what got done. If you run a multi-agent setup that emits `.signals/*.json`
(e.g. one signal per unit of work), you can feed those into the momentum feed so *agent* work
and *your* work share one timeline — "my personal board and my agent team share one nervous
system."
Keep this **opt-in**. The board must stand completely alone with zero workshop dependency —
that's what makes it universal. The bridge is a bonus for advanced users, never a requirement.
@@ -0,0 +1,74 @@
# Neurodivergent-friendly design — principles behind the board
This board is built on well-established **executive-functionfriendly** design principles —
the same patterns ADHD/EF-aware tools use. It is designed to help *everyone* focus, and to be
genuinely supportive for neurodivergent people. Two ground rules:
- **Don't medicalize or assume.** Never diagnose a user or call it an "ADHD mode." Frame it as
focus-friendly design for everyone. Every affordance is optional.
- **Neurodivergence is heterogeneous.** "Meet one person with ADHD, and you've met one person with
ADHD." So these are *configurable supports*, not a prescriptive system.
> Provenance: grounded in established executive-function / ADHD design knowledge from recognized
> sources — see **Sources** at the end of this doc (CHADD for executive function, Simply
> Psychology for body doubling, and W3C/MDN for reduced-motion accessibility). These are
> educational references, not a systematic literature review or clinical guidance.
## Challenge → principle → feature
| Executive-function challenge | Design principle | Feature in the board |
|---|---|---|
| **Task initiation** — starting is the wall (activation energy) | shrink the first step; make "next" obvious | agent behavior: offer one tiny first action; **Focus mode** to surface a single task |
| **Time blindness** — deadlines feel abstract until they're on top of you | make time concrete + visible | live **clock**; gentle **due countdowns** ("2h 10m left"), soft amber when past, never red |
| **Working memory / object permanence** — out of sight, out of mind | keep it visible + externalized | always-on canvas; the **momentum feed** as external memory of the day |
| **Overwhelm** — a long list paralyzes | reduce visible load; one thing at a time | **Focus mode** dims all but one; carryover keeps the list short |
| **Dopamine / reward** — interest- & urgency-driven; needs immediate payoff | instant, visible reward + novelty | progress ring, confetti, momentum feed; **starting counts** (flip to in-progress — the partner can log it in the feed); rotating encouragements |
| **Perfectionism / shame spiral** — miss one → abandon the whole system | no punishment; partial credit; easy defer | **"not today" carryover** (no overdue-shaming); "in progress" counts; carried items leave the ring math |
| **Intrusive thoughts** — a stray thought pulls you off task | frictionless capture, deal with it later | **🧠 brain-dump box** ("park it, keep going") |
| **Task-switching / transitions** | explicit "what's next" handoff | agent behavior: name ONE next action, offer Focus mode |
| **Body-doubling** — focus improves with a present partner | be the other-in-the-room | the whole premise: you drive the board *by talking to your AI partner* |
| **Sensory / motion sensitivity** | calm base, optional stimulation | **reduced-motion toggle** (kills confetti/transitions); honors `prefers-reduced-motion` by default |
| **Self-regulation / interoception** — reacting before noticing your state | *locate yourself* without judgment first | **above/below-the-line check-in** (from Conscious Leadership); below-the-line quietly softens the partner's suggestions |
| **Motivation / self-talk** — a bare to-do list is joyless | a kind, chosen intention | **daily mantra** — self-set or 🔄-suggested, keyed to the check-in (grounding when below, momentum when above) |
| **Prioritization overwhelm** — everything feels equally urgent | separate *important* from *urgent* | **Eisenhower quadrants** (Do first / Schedule / Delegate / Later) + **sort by priority** + a 🧭 plain-language legend |
| **Agency / ownership** — rigid systems get abandoned | let people shape their own tool | **drag-to-reorder** (or ▲/▼ / keyboard), **editable labels**, **add tasks** live (remove the ones you add) |
| **Closure / reflection** — days blur together without a marker | end with a gentle, concrete recap | **end-of-day save** — download or copy a Markdown recap; paste it to the partner to journal and plan tomorrow |
## The most important part is behavior, not chrome
The UI affordances matter, but the biggest EF support is **how the partner shows up**: shrink the
first step, suggest one thing, celebrate starting, never shame an incomplete, offer carryover
freely, park intrusive thoughts, make time concrete, protect against overload. Those are encoded
in `SKILL.md` ("Executive-function-friendly behavior"). A pretty board with a shaming, do-everything
partner would miss the point entirely.
## Sources
Educational references behind the principles above (links verified live 2026-07-27). Not clinical
guidance; neurodivergence is heterogeneous, so treat these as supports to adapt, not rules.
- **Executive function — task initiation, activation energy, time management, working memory** —
CHADD (Children and Adults with ADHD), the leading ADHD nonprofit:
- *Executive Function Skills* — https://chadd.org/about-adhd/executive-function-skills/
- *Executive Function Issues and ADHD* (Brown & Barkley models) —
https://chadd.org/attention-article/executive-function-issues-and-adhd/
- **Body doubling** — focus improving in the presence of a partner; the board's core premise
("drive it by talking to your AI") — Simply Psychology, *Body Doubling and ADHD*
https://www.simplypsychology.com/articles/body-doubling-adhd
- **Reduced motion / sensory sensitivity** — behind the reduced-motion toggle and honoring the OS
`prefers-reduced-motion` setting:
- MDN, *`prefers-reduced-motion`*
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- W3C WCAG 2.1, *Understanding SC 2.3.3: Animation from Interactions*
https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html
- **Above/below the line — "locating yourself"** (the arrival check-in) — The Conscious Leadership
Group, *Locating Yourself: A Key to Conscious Leadership*
https://conscious.is/video/locating-yourself-a-key-to-conscious-leadership
- **Important vs. urgent** (the Eisenhower quadrant labels + "Schedule is where the good work lives"):
- Asana, *The Eisenhower Matrix* — https://asana.com/resources/eisenhower-matrix
- Todoist, *The Eisenhower Matrix* — https://todoist.com/productivity-methods/eisenhower-matrix
- **Self-compassion** (the mantra's kind, non-shaming framing) — Dr. Kristin Neff,
self-compassion.org — https://self-compassion.org/
ADDitude Magazine (additudemag.com) is another widely used, ADHD-focused resource worth searching
for deeper reading on task initiation, time blindness, and body doubling.
@@ -0,0 +1,76 @@
# Using the Daily Focus Board
A warm, visual board for your day that you run **by talking to your AI partner** (Ember). You set
the tasks; you keep it current through conversation and a few one-tap controls. This guide covers
running it in the **GitHub Copilot app** (best experience) and **directly through Ember** anywhere.
## Quick start
Just ask:
> "Make me a focus board for today."
Ember will ask for your handful of tasks (or lift them from what you've already said), generate the
board, serve it locally, and open it. That's it — you're running your day with a partner.
## Two ways to run it
### A) In the GitHub Copilot app (browser canvas) — recommended
The app can show a **browser canvas** right next to the chat, so the board lives beside your
conversation.
1. Ask Ember to make the board. It serves the folder (e.g. `python -m http.server 8790 --bind 127.0.0.1`) and gives
you an `http://localhost:…/…html` URL.
2. Open a **browser canvas** to that URL. The board sits in the side panel; the chat stays on the left.
3. Talk to Ember as you work; tap the board for quick updates. Progress saves automatically (localStorage).
### B) Directly through Ember (any Copilot session with this skill)
Same flow, without the side-panel polish:
1. "Make me a focus board for today."
2. Ember serves it and gives you the URL — open it in your browser.
3. Drive it by talking + tapping.
> localStorage needs an `http://` origin, so always **serve** the folder rather than double-clicking
> the file. If you truly can't serve, the file still opens, but progress may not persist.
## The daily loop
**1. Arrive (morning).**
- **Check in:** tap *below / in-between / above the line* — just noticing how you're arriving, no
judgment. (Below the line? Be gentle and shrink the first step.)
- **Set a mantra:** type today's intention, tap 🔄 for a suggestion, or ask Ember for one.
- **List ~49 tasks.** Keep it a focus board, not a backlog. Mark the one real **anchor** (give it
a `due`). Optionally set each task's **priority** (Do first / Schedule / Delegate / Later — tap
🧭 for what they mean).
**2. Work (during the day).**
- Tap a task's pill to move it **to do → in progress → done** (starting counts — celebrate it).
- **Log momentum notes** ("first draft done") — they build the story of your day.
- **Focus mode** (🎯) dims everything but one task when the list feels loud.
- A stray thought? **Park it** in the 🧠 brain-dump box and keep going.
- Need to capture something new? ** add a task** right on the board.
- Reorder by importance with the **⠿** handle, or tap **⬍ sort by priority**.
- Rename any tile's **label** inline.
**3. Close (end of day).**
- Tap **💾 download recap** to keep the day as a Markdown file, or **📋 copy** it.
- If you copy it, **paste it to Ember** — Ember can journal your day and help you set up tomorrow.
- Carry unfinished things with **⤳ not today** — that's a healthy choice, not a miss.
## Things to say to Ember
- "I'm below the line today — give me a gentle mantra and one tiny first step."
- "What should I do next?" → Ember names **one** thing and can turn on Focus mode.
- "Add 'call the dentist' to the board."
- "Help me prioritize — what's actually important vs just urgent?"
- "Here's my end-of-day recap: …" (paste it) → "journal this and plan tomorrow."
## Good to know
- **It's yours and optional.** Every feature is a support, not a requirement. Focus-friendly for
everyone; never a diagnosis.
- **Keep it small.** If it passes ~9 active tasks, the board nudges you — carry a few to tomorrow.
- **The Schedule quadrant** (important, not urgent) is where the good, non-frantic work lives —
protect time for it.
- **State is per-browser.** The end-of-day recap is how you take your day *out* of the browser.
@@ -0,0 +1,23 @@
# serve-board.ps1 — serve a generated focus board and print the URL.
# Usage: pwsh scripts/serve-board.ps1 [-Dir <folder>] [-File focus-board.html] [-Port 8799]
param(
[string]$Dir = (Get-Location).Path,
[string]$File = "focus-board.html",
[int] $Port = 8799
)
$full = Join-Path $Dir $File
if (-not (Test-Path $full)) { Write-Error "Board not found: $full"; exit 1 }
try {
if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) {
Write-Error "Port $Port is already in use — choose another with -Port or stop the existing server."; exit 1
}
} catch { } # Get-NetTCPConnection may be unavailable on some platforms; skip the pre-check there.
$proc = Start-Process -FilePath "python" -ArgumentList @("-m","http.server","$Port","--bind","127.0.0.1","--directory","`"$Dir`"") -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 2
if ($proc.HasExited) {
Write-Error "The server exited on startup (code $($proc.ExitCode)) — is Python installed and the port free?"; exit 1
}
$url = "http://localhost:$Port/$File"
Write-Host "Focus board: $url"
Write-Host "Server PID $($proc.Id) — stop it with: Stop-Process -Id $($proc.Id)"
Start-Process $url # opens in default browser; in a Copilot-app session, open a browser canvas to this URL instead