ember: add daily-focus-board canvas extension

Adds a canvas version of the daily-focus-board alongside the skill, addressing
@aaronpowell's review suggestion. The canvas renders the board in the Copilot
app and is backed by a JSON state file the assistant reads and writes, so you
can mark tasks done, add tasks, log progress, and recap your day from chat --
the file-backed "close the loop" upgrade over the localStorage skill.

The skill stays as the zero-install universal fallback for anyone not in the
Copilot app (same both-not-either pattern as the-workshop's signals-dashboard).

- extensions/daily-focus-board/: extension.mjs (canvas + session wiring) +
  board-core.mjs (loopback server, JSON state file, mutations, recap) +
  assets/board.html (file-backed UI) + preview.png + manifests.
- plugins/ember: register via x-awesome-copilot.extensions, bump 1.1.0 -> 1.2.0,
  add a Components row.

Note: assets/preview.png is a placeholder pending a real board screenshot.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cb356aa8-0af2-48f3-b3c6-8086c69d5308
This commit is contained in:
Jenny Ferries
2026-07-28 11:39:21 -07:00
parent 7cf869a549
commit 9f911f18ce
8 changed files with 871 additions and 2 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "daily-focus-board",
"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. Universal companion to the daily-focus-board skill for people who run the GitHub Copilot app.",
"version": "0.1.0",
"author": {
"name": "jennyf19",
"url": "https://github.com/jennyf19"
},
"keywords": [
"focus",
"daily-planner",
"executive-function",
"adhd-friendly",
"productivity",
"canvas"
],
"logo": "assets/preview.png",
"extensions": "."
}
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<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");
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");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"},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":"");
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">${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"/>
<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">${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>${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: 33 KiB

+357
View File
@@ -0,0 +1,357 @@
// 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";
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"];
export function validId(s) { return typeof s === "string" && ID_RE.test(s); }
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" ? 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" ? doc.progress : {};
p.counters = p.counters && typeof p.counters === "object" ? p.counters : {};
p.t = p.t && typeof p.t === "object" ? p.t : {};
p.day = Array.isArray(p.day) ? p.day : [];
p.brain = Array.isArray(p.brain) ? p.brain : [];
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;
return doc;
}
export async function loadDoc(file) {
let doc = {};
try { doc = JSON.parse(await readFile(file, "utf-8")); } catch { /* missing/corrupt -> fresh */ }
return normalize(doc);
}
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 || isCounter(t)) return;
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 || isCounter(t)) return;
txt = text(txt).trim();
if (!txt) return;
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) e.notes.splice(idx, 1);
}
export function opCount(doc, id, { value, inc } = {}) {
const t = findTask(doc, id);
if (!t || !isCounter(t)) return;
let v = doc.progress.counters[id] || 0;
const nv = num(value), ni = num(inc);
if (nv !== undefined) v = nv;
else if (ni !== undefined) 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 || isCounter(t)) return;
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;
p.focus = p.focus === id ? null : id;
}
export function opDay(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.day.unshift({ t: Date.now(), txt }); }
export function opDayDel(doc, idx) { const d = doc.progress.day; if (idx >= 0 && idx < d.length) d.splice(idx, 1); }
export function opBrain(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.brain.unshift({ t: Date.now(), txt }); }
export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (idx >= 0 && idx < b.length) 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" };
}
}
// Start the local board server bound to loopback. Serves the board HTML at / and
// the JSON state + mutation API under /api/. Returns { server, url }.
export async function startServer(stateFile) {
let boardHtml;
try { boardHtml = await readFile(BOARD_HTML_PATH, "utf-8"); }
catch { boardHtml = "<!doctype html><meta charset=utf-8><p>board.html asset is missing.</p>"; }
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === "POST" && url.pathname.startsWith("/api/") && 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;
return { server, url: `http://127.0.0.1:${port}/` };
}
// --- 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 file in
// cwd; if an existing directory is given, place the 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-state.json"); } catch { /* not yet created */ }
return file;
}
return join(process.cwd(), "focus-board-state.json");
}
// Create + seed the state file if it doesn't exist yet. Returns the resolved path.
export async function ensureStateFile(inputPath, seed) {
const file = await resolveStateFile(inputPath);
if (!existsSync(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;
}
+175
View 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 focus-board-state.json in the current working directory.",
},
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()));
}
},
}),
],
});
+18
View File
@@ -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"
]
}