mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-08 18:19:14 +00:00
chore: publish from main
This commit is contained in:
@@ -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=>({"&":"&","<":"<",">":">"}[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"
|
||||
]
|
||||
}
|
||||
@@ -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 ~4–9 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 & 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=>({"&":"&","<":"<",">":">"}[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,""");}
|
||||
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 & 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=>({"&":"&","<":"<",">":">"}[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,""");}
|
||||
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-function–friendly** 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 ~4–9 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
|
||||
Reference in New Issue
Block a user