From da9d3673c10369db20c35f6a63af37f30514e6e6 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Fri, 22 May 2026 21:21:23 +0200 Subject: [PATCH] Finish Prioritix interaction plan --- public/app.js | 91 +++++++++++++++++++++++++++++++++++++---------- public/index.html | 20 ++++++----- public/styles.css | 1 + 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/public/app.js b/public/app.js index 3294a18..00a0b07 100644 --- a/public/app.js +++ b/public/app.js @@ -1,43 +1,96 @@ -const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null }; -const zones = [ - { id:'must', label:'Required / Must Have', copy:'Critical for the solution. Necessary for MVP.', icon:'☆', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must' }, - { id:'should', label:'Should Have', copy:'Important but not critical. Adds significant value.', icon:'○', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should' }, - { id:'nice', label:'Nice to Have', copy:'Good to include if possible. Not essential.', icon:'♡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice' }, - { id:'stretch', label:'Stretch Goal', copy:'Low priority ideas. Consider for the future.', icon:'ϟ', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch' }, -]; +const profiles = { + mvp: { + name: 'MVP (2x2)', + gridLabel: 'Sorting grid (MVP - 2x2)', + zones: [ + { id:'must', label:'Required / Must Have', copy:'Critical for the solution. Necessary for MVP.', icon:'☆', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 }, + { id:'should', label:'Should Have', copy:'Important but not critical. Adds significant value.', icon:'○', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 }, + { id:'nice', label:'Nice to Have', copy:'Good to include if possible. Not essential.', icon:'♡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 }, + { id:'stretch', label:'Stretch Goal', copy:'Low priority ideas. Consider for the future.', icon:'ϟ', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 }, + ], + }, + rice: { + name: 'RICE Score', + gridLabel: 'Sorting grid (RICE)', + zones: [ + { id:'must', label:'High RICE / Build Now', copy:'Reach, impact, confidence all justify immediate work.', icon:'R', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 }, + { id:'should', label:'Promising / Validate', copy:'Strong impact but needs confidence or reach proof.', icon:'I', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 }, + { id:'nice', label:'Low Reach / Batch', copy:'Useful, but only after higher-reach work is handled.', icon:'C', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 }, + { id:'stretch', label:'Expensive Bet', copy:'Interesting, but effort or uncertainty is too high right now.', icon:'E', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 }, + ], + }, + value: { + name: 'Value vs Effort', + gridLabel: 'Sorting grid (Value vs Effort)', + zones: [ + { id:'must', label:'High Value / Low Effort', copy:'Quick win. Pull into the near-term roadmap.', icon:'↗', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 }, + { id:'should', label:'High Value / High Effort', copy:'Strategic work. Shape before committing.', icon:'◆', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 }, + { id:'nice', label:'Low Value / Low Effort', copy:'Polish work. Batch when context is cheap.', icon:'◇', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 }, + { id:'stretch', label:'Low Value / High Effort', copy:'Avoid unless strategy changes.', icon:'×', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 }, + ], + }, + kano: { + name: 'Kano Model', + gridLabel: 'Sorting grid (Kano)', + zones: [ + { id:'must', label:'Basic Expectation', copy:'Users will notice if this is missing.', icon:'B', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 }, + { id:'should', label:'Performance Driver', copy:'More of this directly improves satisfaction.', icon:'P', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 }, + { id:'nice', label:'Delighter', copy:'Memorable, but not necessary for usefulness.', icon:'D', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 }, + { id:'stretch', label:'Indifferent / Future', copy:'Weak user pull. Keep it outside the core release.', icon:'?', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 }, + ], + }, + custom: { + name: 'Custom', + gridLabel: 'Sorting grid (Custom)', + zones: [ + { id:'must', label:'Commit', copy:'This belongs in the next real delivery slice.', icon:'✓', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 }, + { id:'should', label:'Shape', copy:'Worth pursuing after requirements are sharpened.', icon:'✎', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 }, + { id:'nice', label:'Queue', copy:'Keep visible, but do not distract the team now.', icon:'≡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 }, + { id:'stretch', label:'Someday', copy:'A possible future bet, not part of this plan.', icon:'∞', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 }, + ], + }, +}; +const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null, profileId: localStorage.getItem('rank-profile') || 'mvp', undo: null }; const $ = (sel, root=document) => root.querySelector(sel); const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel)); const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline'); const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast'); +function profile(){ return profiles[state.profileId] || profiles.mvp; } +function zones(){ return profile().zones; } function escapeHtml(value){ return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[ch])); } function short(text, len=130){ const v=String(text||'').trim(); return v.length>len ? `${v.slice(0,len-1)}…` : v; } function categoryOf(idea){ return (idea.labels && idea.labels[0]) || idea.sourceName || 'UI / UX'; } function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status || i.status === 'inbox' || i.milestoneId === 'inbox')); } function sortedIdeas(){ return state.ideas.filter(i => !i.archived && !activeIdeas().some(a => a.id === i.id)); } function utilityIdeas(){ return state.ideas.filter(i => ['park','investigate'].includes(i.status)); } +function zoneFor(idea){ return zones().find(z=>z.status===idea.status) || profiles.mvp.zones.find(z=>z.status===idea.status) || zones()[0]; } async function api(path, options={}){ const res = await fetch(path,{ headers:{'Content-Type':'application/json',...(options.headers||{})}, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); const text=await res.text(); const data=text?JSON.parse(text):null; if(!res.ok) throw new Error(data?.error||res.statusText); return data; } -function toast(message){ toastEl.textContent = message; toastEl.hidden = false; setTimeout(()=>{toastEl.hidden=true}, 2400); } +function toast(message, action){ toastEl.innerHTML = `${escapeHtml(message)}${action ? '' : ''}`; toastEl.hidden = false; const btn = $('button', toastEl); if(btn) btn.addEventListener('click', action, { once:true }); clearTimeout(toastEl._timer); toastEl._timer = setTimeout(()=>{toastEl.hidden=true}, action ? 5200 : 2400); } function currentActive(){ const queue = activeIdeas().sort((a,b)=>(a.rank-b.rank)||(new Date(a.createdAt)-new Date(b.createdAt))); if(!state.activeId || !queue.some(i=>i.id===state.activeId)) state.activeId = queue[0]?.id || null; return queue.find(i=>i.id===state.activeId) || null; } function renderProgress(){ const total = state.ideas.filter(i=>!i.archived).length; const sorted = sortedIdeas().length; const pct = total ? Math.round((sorted/total)*100) : 0; $('#progressCard').innerHTML = `${sorted} / ${total}features sorted
`; } +function renderProfile(){ const p=profile(); $('.profile-card strong').textContent=p.name; $('#gridLabel').textContent=p.gridLabel; $$('#profileMenu [data-profile]').forEach(btn=>{ const active=btn.dataset.profile===state.profileId; btn.classList.toggle('selected', active); btn.textContent = profiles[btn.dataset.profile].name + (active ? ' ✓' : ''); }); } function renderDeck(){ const idea = currentActive(); if(!idea){ featureDeck.innerHTML = `
No active feature
Add a feature above and it will become the next card in the decision deck.
`; return; } featureDeck.innerHTML = `
${escapeHtml(categoryOf(idea))}

${escapeHtml(idea.title)}

${escapeHtml(short(idea.description || 'No brief yet. Open details to add one.'))}

`; $('.feature-card').addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', idea.id)); $('.open-details').addEventListener('click', () => openDetail(idea.id)); $('.feature-card').addEventListener('dblclick', () => openDetail(idea.id)); } -function renderGrid(){ sortingGrid.innerHTML = zones.map(z => ``).join(''); $$('.zone').forEach(el => { el.addEventListener('click', () => placeActive(el.dataset.zone)); el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); el.addEventListener('dragleave', () => el.classList.remove('drag-over')); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain') || state.activeId; placeFeature(id, el.dataset.zone); }); }); } -function renderTimeline(){ const items = sortedIdeas().filter(i => !['park','investigate','remove'].includes(i.status)); const milestones = [{label:'MVP',left:8,color:'#16A34A'},{label:'Beta',left:42,color:'#2563EB'},{label:'1.0',left:66,color:'#DC2626'},{label:'Stretch Goal',left:88,color:'#7C3AED'}]; const positions = items.map((idea, idx) => { const z = zones.find(z=>z.status===idea.status) || zones[idx % zones.length]; const base = {must:13,should:47,nice:68,stretch:88}[z.status] || 20; return {idea,z,left: Math.min(96, base + (idx%5)*4)}; }); timeline.innerHTML = `
${milestones.map(m=>`${m.label}`).join('')}${positions.map((p,idx)=>``).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}
`; $$('.node', timeline).forEach(n => n.addEventListener('click', () => openDetail(n.dataset.id))); } +function renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => ``).join(''); $$('.zone').forEach(el => { el.addEventListener('click', () => placeActive(el.dataset.zone)); el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); el.addEventListener('dragleave', () => el.classList.remove('drag-over')); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain') || state.activeId; placeFeature(id, el.dataset.zone); }); }); } +function renderTimeline(){ const items = sortedIdeas().filter(i => !['park','investigate','remove'].includes(i.status)).sort((a,b)=>(a.rank-b.rank)||(new Date(a.updatedAt)-new Date(b.updatedAt))); const milestones = [{label:'MVP',left:8,color:'#16A34A'},{label:'Beta',left:42,color:'#2563EB'},{label:'1.0',left:66,color:'#DC2626'},{label:'Stretch Goal',left:88,color:'#7C3AED'}]; const positions = items.map((idea, idx) => { const z = zoneFor(idea); const base = typeof idea.rank === 'number' && idea.rank > 0 ? Math.max(7, Math.min(94, idea.rank / 1000)) : z.left; return {idea,z,left: Math.min(96, base + (idx%3)*1.5)}; }); timeline.innerHTML = `
${milestones.map(m=>`${m.label}`).join('')}${positions.map((p,idx)=>``).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}
`; const line=$('#timelineLine'); line.addEventListener('dragover', e => { e.preventDefault(); line.classList.add('drag-over'); }); line.addEventListener('dragleave', () => line.classList.remove('drag-over')); line.addEventListener('drop', e => { e.preventDefault(); line.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain'); if(id) reorderOnTimeline(id,e); }); $$('.node', timeline).forEach(n => { n.addEventListener('click', () => openDetail(n.dataset.id)); n.addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', n.dataset.id)); }); } function renderBacklog(){ const items = utilityIdeas(); $('#backlogList').innerHTML = items.length ? items.map(i=>``).join('') : '
Clean utility lanesParked and investigate items will appear here.
'; $$('.mini-card[data-id]').forEach(c => c.addEventListener('click',()=>openDetail(c.dataset.id))); } -function render(){ renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); } +function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); } function replaceIdea(idea){ const idx=state.ideas.findIndex(i=>i.id===idea.id); if(idx>=0) state.ideas[idx]=idea; else state.ideas.unshift(idea); } async function placeActive(zoneId){ const idea = currentActive(); if(!idea) return toast('Capture a feature first'); return placeFeature(idea.id, zoneId); } -async function placeFeature(id, zoneId){ const z = zones.find(z=>z.id===zoneId); const idea = state.ideas.find(i=>i.id===id); if(!z || !idea) return; const previous = {...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Date.now()%100000}); render(); try{ const updated = await api(`/api/ideas/${id}`, {method:'PATCH', body:{status:z.status,milestoneId:z.milestoneId,rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(`Placed in ${z.label}`); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } } -async function setUtility(action){ const idea=currentActive(); if(!idea) return toast('No active feature'); if(action==='remove') return archiveIdea(idea.id); const previous={...idea}; Object.assign(idea,{status:action,milestoneId:'later',rank:Date.now()%100000}); render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{status:action,milestoneId:'later',rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(action === 'park' ? 'Parked' : 'Sent to investigate'); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } } -function openDetail(id){ const idea=state.ideas.find(i=>i.id===id); if(!idea) return; state.selected=id; detailForm.title.value=idea.title||''; detailForm.description.value=idea.description||''; detailForm.impact.value=idea.impact??5; detailForm.effort.value=idea.effort??5; detailForm.confidence.value=idea.confidence??5; detailForm.urgency.value=idea.urgency??5; detailForm.notes.value=idea.notes||''; $('#detailCategory').textContent=categoryOf(idea); detail.classList.add('open'); detail.setAttribute('aria-hidden','false'); } +async function placeFeature(id, zoneId){ const z = zones().find(z=>z.id===zoneId); const idea = state.ideas.find(i=>i.id===id); if(!z || !idea) return; const previous = {...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Math.round(z.left*1000)}); render(); try{ const updated = await api(`/api/ideas/${id}`, {method:'PATCH', body:{status:z.status,milestoneId:z.milestoneId,rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(`Placed in ${z.label}`, () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } } +async function undoIdea(previous){ if(!previous?.id) return; try{ const updated=await api(`/api/ideas/${previous.id}`,{method:'PATCH',body:{status:previous.status,milestoneId:previous.milestoneId,rank:previous.rank,archived:previous.archived}}); replaceIdea(updated); state.activeId=updated.status === 'inbox' ? updated.id : state.activeId; render(); toast('Undone'); }catch(error){ toast(error.message); } } +async function setUtility(action){ const idea=currentActive(); if(!idea) return toast('No active feature'); if(action==='remove') return archiveIdea(idea.id); const previous={...idea}; Object.assign(idea,{status:action,milestoneId:'later',rank:Date.now()%100000}); render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{status:action,milestoneId:'later',rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(action === 'park' ? 'Parked' : 'Sent to investigate', () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } } +async function reorderOnTimeline(id,e){ const line=e.currentTarget; const idea=state.ideas.find(i=>i.id===id); if(!idea) return; const rect=line.getBoundingClientRect(); const pct=Math.max(6,Math.min(94,((e.clientX-rect.left)/rect.width)*100)); const z=zones().reduce((best,item)=>Math.abs(item.left-pct) undoIdea(previous)); }catch(error){ Object.assign(idea, previous); render(); toast(error.message); } } +function openDetail(id){ const idea=state.ideas.find(i=>i.id===id); if(!idea) return; state.selected=id; detailForm.title.value=idea.title||''; detailForm.description.value=idea.description||''; detailForm.labels.value=(idea.labels||[]).join(', '); detailForm.impact.value=idea.impact??5; detailForm.effort.value=idea.effort??5; detailForm.confidence.value=idea.confidence??5; detailForm.urgency.value=idea.urgency??5; detailForm.notes.value=idea.notes||''; $('#detailCategory').textContent=categoryOf(idea); $('.detail-head .chip').textContent=zoneFor(idea).label; detail.classList.add('open'); detail.setAttribute('aria-hidden','false'); } function closeDetail(){ detail.classList.remove('open'); detail.setAttribute('aria-hidden','true'); state.selected=null; } -async function archiveIdea(id=state.selected){ if(!id) return; try{ await api(`/api/ideas/${id}`,{method:'PATCH',body:{archived:true,status:'remove'}}); state.ideas = state.ideas.filter(i=>i.id!==id); closeDetail(); render(); toast('Removed'); } catch(error){ toast(error.message); } } -$('#ideaForm').addEventListener('submit', async e => { e.preventDefault(); const fd=new FormData(e.currentTarget); const payload=Object.fromEntries(fd.entries()); payload.labels=String(payload.labels||'UI / UX').split(',').map(s=>s.trim()).filter(Boolean); payload.source='human'; payload.status='inbox'; try{ const idea=await api('/api/ideas',{method:'POST',body:payload}); replaceIdea(idea); state.activeId=idea.id; e.currentTarget.reset(); $('#title').value=''; $('#description').value=''; $('#labels').value=''; render(); toast('Captured'); }catch(error){ toast(error.message); } }); +async function archiveIdea(id=state.selected){ if(!id) return; const previous=state.ideas.find(i=>i.id===id); try{ await api(`/api/ideas/${id}`,{method:'PATCH',body:{archived:true,status:'remove'}}); state.ideas = state.ideas.filter(i=>i.id!==id); closeDetail(); render(); toast('Removed', previous ? () => undoIdea({...previous, archived:false}) : null); } catch(error){ toast(error.message); } } +$('#ideaForm').addEventListener('submit', async e => { e.preventDefault(); const form=e.currentTarget; const fd=new FormData(form); const payload=Object.fromEntries(fd.entries()); payload.labels=String(payload.labels||'UI / UX').split(',').map(s=>s.trim()).filter(Boolean); payload.source='human'; payload.status='inbox'; try{ const idea=await api('/api/ideas',{method:'POST',body:payload}); replaceIdea(idea); state.activeId=idea.id; form.reset(); $('#title').value=''; $('#description').value=''; $('#labels').value=''; render(); toast('Captured'); }catch(error){ toast(error.message); } }); $('#skipFeature').addEventListener('click',()=>{ const q=activeIdeas(); if(q.length<2) return toast('No next feature'); const idx=q.findIndex(i=>i.id===state.activeId); state.activeId=q[(idx+1)%q.length].id; renderDeck(); }); $('#postponeFeature').addEventListener('click',async()=>{ const idea=currentActive(); if(!idea) return; idea.rank=Date.now()%100000; state.activeId=null; render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{rank:idea.rank}}); replaceIdea(updated); render(); toast('Postponed'); }catch(error){ toast(error.message); await load(); } }); $$('[data-utility]').forEach(b=>b.addEventListener('click',()=>setUtility(b.dataset.utility))); -$('#closeDetail').addEventListener('click',closeDetail); $('#archiveIdea').addEventListener('click',()=>archiveIdea()); $('#parkDetail').addEventListener('click',async()=>{ if(!state.selected) return; const id=state.selected; try{ const updated=await api(`/api/ideas/${id}`,{method:'PATCH',body:{status:'park',milestoneId:'later'}}); replaceIdea(updated); closeDetail(); render(); toast('Parked'); }catch(error){toast(error.message)} }); -detailForm.addEventListener('submit', async e => { e.preventDefault(); if(!state.selected) return; const payload=Object.fromEntries(new FormData(detailForm).entries()); try{ const idea=await api(`/api/ideas/${state.selected}`,{method:'PATCH',body:payload}); replaceIdea(idea); closeDetail(); render(); toast('Saved'); }catch(error){ toast(error.message); } }); +$('#closeDetail').addEventListener('click',closeDetail); $('#archiveIdea').addEventListener('click',()=>archiveIdea()); $('#parkDetail').addEventListener('click',async()=>{ if(!state.selected) return; const id=state.selected; const previous=state.ideas.find(i=>i.id===id); try{ const updated=await api(`/api/ideas/${id}`,{method:'PATCH',body:{status:'park',milestoneId:'later'}}); replaceIdea(updated); closeDetail(); render(); toast('Parked', previous ? () => undoIdea(previous) : null); }catch(error){toast(error.message)} }); +detailForm.addEventListener('submit', async e => { e.preventDefault(); if(!state.selected) return; const payload=Object.fromEntries(new FormData(detailForm).entries()); payload.labels=String(payload.labels||'').split(',').map(s=>s.trim()).filter(Boolean); try{ const idea=await api(`/api/ideas/${state.selected}`,{method:'PATCH',body:payload}); replaceIdea(idea); closeDetail(); render(); toast('Saved'); }catch(error){ toast(error.message); } }); $('#profileToggle').addEventListener('click',()=>{ const m=$('#profileMenu'); m.hidden=!m.hidden; }); -document.addEventListener('keydown', e => { if(e.key==='/' && !['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){ e.preventDefault(); $('#title').focus(); } if(e.key==='Escape') closeDetail(); }); +$$('#profileMenu [data-profile]').forEach(btn=>btn.addEventListener('click',()=>{ state.profileId=btn.dataset.profile; localStorage.setItem('rank-profile',state.profileId); $('#profileMenu').hidden=true; render(); toast(`Profile: ${profile().name}`); })); +document.addEventListener('keydown', e => { const typing=['INPUT','TEXTAREA'].includes(document.activeElement.tagName); if(e.key==='/' && !typing){ e.preventDefault(); $('#title').focus(); return; } if(e.key==='Escape') closeDetail(); if(typing) return; if(['1','2','3','4'].includes(e.key)){ e.preventDefault(); placeActive(zones()[Number(e.key)-1]?.id); } if(e.key.toLowerCase()==='p') setUtility('park'); if(e.key.toLowerCase()==='i') setUtility('investigate'); if(e.key.toLowerCase()==='u' && toastEl.querySelector('button')) toastEl.querySelector('button').click(); }); async function load(){ try{ const data=await api('/api/bootstrap'); state.ideas=data.ideas||[]; state.milestones=data.milestones||[]; state.activity=data.activity||[]; render(); } catch(error){ featureDeck.innerHTML=`
Backend is grumpy
${escapeHtml(error.message)}
`; renderGrid(); } } load(); diff --git a/public/index.html b/public/index.html index 1977887..3e37c28 100644 --- a/public/index.html +++ b/public/index.html @@ -33,11 +33,11 @@ MVP (2x2)
@@ -74,8 +74,9 @@
- +
+

Shortcuts: 1-4 place active card · P park · I investigate · U undo · / capture

@@ -89,7 +90,7 @@

Timeline view

-

Sorted features become nodes. Click a node to inspect; drag-and-drop can come later without breaking the model.

+

Sorted features become nodes. Drag nodes horizontally to reorder the roadmap; click a node to inspect details.

@@ -112,13 +113,14 @@ +
- +
@@ -128,6 +130,6 @@ - + diff --git a/public/styles.css b/public/styles.css index 8d07f52..ceacf38 100644 --- a/public/styles.css +++ b/public/styles.css @@ -16,4 +16,5 @@ button,input,textarea{font:inherit} button{cursor:pointer} a{color:inherit;text- .sidebar nav{display:grid;gap:10px;width:100%}.sidebar a{display:grid;place-items:center;gap:6px;padding:13px 6px;border-radius:6px;color:#D9E8FF;font-size:12px}.sidebar a span{font-size:25px;line-height:1}.sidebar a.active{background:linear-gradient(135deg,#0B63F6,#4E2AD8);color:#fff;box-shadow:0 10px 28px rgba(11,99,246,.30)}.collapse{margin-top:auto;background:transparent;border:0;color:white;font-size:28px;opacity:.9} .workspace{padding:22px 32px 42px;max-width:1440px;width:100%;margin:0 auto}.topbar{display:grid;grid-template-columns:auto 1fr minmax(220px,270px) 190px;align-items:center;gap:22px;background:rgba(255,255,255,.72);backdrop-filter:blur(18px);border:1px solid var(--border);box-shadow:var(--shadow-soft);border-radius:var(--radius);padding:18px 22px;margin-bottom:18px}.menu-button{width:48px;height:48px;border:1px solid var(--border);border-radius:6px;background:var(--navy-950);color:white;font-size:24px}.project-title h1{font-size:26px;letter-spacing:-.04em;margin:0 0 4px}.project-title p{margin:0;color:var(--slate-700);font-size:14px}.profile-card,.progress-card{position:relative;background:white;border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;box-shadow:var(--shadow-soft)}.profile-card span,.progress-card span{display:block;color:var(--slate-600);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.profile-card strong,.progress-card strong{font-size:16px}.profile-card>button{position:absolute;right:10px;top:22px;border:0;background:transparent;font-size:20px;color:var(--slate-900)}.profile-menu{position:absolute;top:64px;left:0;right:0;background:#fff;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow);padding:8px;z-index:20}.profile-menu button{display:block;width:100%;text-align:left;border:0;background:white;padding:9px 10px;border-radius:4px}.profile-menu .selected{background:#F0E9FF;color:var(--purple-600);font-weight:800}.progressbar{height:5px;background:var(--muted);border-radius:999px;margin-top:10px;overflow:hidden}.progressbar i{display:block;height:100%;background:linear-gradient(90deg,var(--purple-600),var(--blue-600));border-radius:999px}.capture-strip{background:white;border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-soft);padding:12px;margin-bottom:18px}.capture-strip form{display:grid;grid-template-columns:1.4fr 1.4fr .8fr 132px;gap:10px;align-items:end}.capture-strip label,.detail label{display:grid;gap:6px;color:var(--slate-600);font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.capture-strip input,.detail input,.detail textarea{width:100%;border:1px solid var(--border);border-radius:4px;background:#FBFCFF;color:var(--slate-900);padding:12px 13px;outline:none}.capture-strip input:focus,.detail input:focus,.detail textarea:focus{border-color:var(--blue-600);box-shadow:0 0 0 3px rgba(11,99,246,.10)}.capture-strip button,.deck-actions button,.utility-row button,.detail-actions button,.zoom-controls button{border:1px solid var(--border);background:#fff;color:var(--slate-900);border-radius:4px;box-shadow:var(--shadow-soft);padding:12px 16px;font-weight:800}.capture-strip button{background:var(--navy-950);color:white;height:43px}.sorting-layout{display:grid;grid-template-columns:minmax(330px,470px) 1fr;gap:28px;align-items:start;margin-top:18px}.section-label{text-transform:uppercase;letter-spacing:.06em;color:var(--navy-900);font-size:13px;font-weight:900;margin-bottom:12px}.active-column{padding:10px 0}.feature-deck{position:relative;min-height:250px;display:grid;place-items:center}.feature-card{position:relative;width:min(390px,100%);min-height:185px;background:white;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow);padding:22px 22px 18px;display:grid;gap:12px;z-index:3;transition:.18s ease}.feature-card::before,.feature-card::after{content:"";position:absolute;inset:14px -18px -14px 18px;background:white;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow-soft);z-index:-1}.feature-card::before{border-right:6px solid var(--amber-500);transform:rotate(-1.4deg)}.feature-card::after{inset:28px -34px -28px 34px;border-right:6px solid var(--green-600);transform:rotate(1.5deg);z-index:-2}.feature-meta,.detail-head>div{display:flex;align-items:center;gap:9px;color:var(--slate-700);font-size:13px;font-weight:700}.category-dot{width:12px;height:12px;border-radius:50%;background:var(--purple-600);box-shadow:0 0 0 4px rgba(124,58,237,.10)}.feature-card h2{margin:0;font-size:22px;letter-spacing:-.03em}.feature-card p{margin:0;color:var(--slate-700);line-height:1.45}.open-details{justify-self:end;width:40px;height:40px;border:1px solid var(--border);border-radius:5px;background:#F8FAFF;color:var(--navy-900);font-size:20px}.empty-deck{background:white;border:1px dashed var(--border);border-radius:6px;box-shadow:var(--shadow-soft);padding:28px;text-align:center;color:var(--slate-600);max-width:390px}.deck-actions{display:grid;grid-template-columns:1fr 1fr;gap:12px;width:min(390px,100%);margin:4px auto 0}.sorting-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:14px}.zone{min-height:188px;border:1.5px dashed var(--zone-color);background:linear-gradient(135deg,rgba(255,255,255,.94),var(--zone-bg));border-radius:6px;display:grid;place-items:center;text-align:center;padding:22px;transition:.16s ease}.zone:hover,.zone.drag-over{transform:translateY(-2px);box-shadow:var(--shadow);border-style:solid}.zone-icon{font-size:42px;color:var(--zone-color);line-height:1}.zone strong{display:block;color:var(--zone-color);font-size:18px;margin:8px 0}.zone span{display:block;color:var(--slate-700);font-size:14px;line-height:1.35}.drop-hint{margin-top:18px;display:inline-grid;place-items:center;width:42px;height:42px;border:1px dashed var(--border);border-radius:6px;color:var(--slate-600);background:white}.utility-row{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--border);border-radius:6px;background:white;box-shadow:var(--shadow-soft);overflow:hidden;margin:18px 0}.utility-row button{display:grid;grid-template-columns:42px 1fr;grid-template-rows:auto auto;text-align:left;border:0;border-right:1px solid var(--border);border-radius:0;box-shadow:none;background:white;padding:18px}.utility-row button:last-child{border-right:0}.utility-row span{grid-row:1/3;font-size:28px;color:var(--navy-900)}.utility-row strong{font-size:15px}.utility-row small{color:var(--slate-600);font-size:12px;line-height:1.35}.timeline-panel,.backlog-panel{background:white;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow-soft);padding:18px;margin-top:18px}.timeline-head{display:flex;justify-content:space-between;gap:16px;align-items:start}.timeline-head h2{font-size:18px;margin:0 0 4px}.timeline-head p{margin:0;color:var(--slate-600);font-size:13px}.zoom-controls{display:flex;gap:6px}.zoom-controls button{padding:9px 13px}.timeline{position:relative;min-height:145px;padding:54px 26px 26px}.timeline-line{position:relative;height:2px;background:#AAB4C0}.milestone-label{position:absolute;top:-30px;transform:translateX(-50%);font-weight:800;font-size:13px;color:var(--milestone-color)}.milestone-tick{position:absolute;top:-17px;height:34px;border-left:3px solid var(--milestone-color)}.node{position:absolute;top:-13px;transform:translateX(-50%);width:28px;height:28px;border-radius:50%;border:3px solid var(--node-color);background:white;display:grid;place-items:center;font-size:11px;font-weight:900;color:var(--node-color);box-shadow:0 3px 10px rgba(16,24,40,.12)}.node-card{position:absolute;top:42px;transform:translateX(-50%);min-width:230px;max-width:300px;background:white;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow-soft);padding:11px}.node-card strong{display:block;font-size:13px}.node-card span{color:var(--slate-600);font-size:12px}.backlog-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}.mini-card{border:1px solid var(--border);border-radius:6px;background:#FBFCFF;padding:12px;display:grid;gap:7px}.mini-card strong{font-size:14px}.mini-card span{font-size:12px;color:var(--slate-600)}.chip{background:#EEE6FF;color:var(--purple-600);padding:6px 10px;border-radius:8px;font-size:12px;font-weight:900}.detail{position:fixed;right:20px;top:20px;bottom:20px;width:min(520px,calc(100vw - 40px));z-index:50;background:white;border:1px solid var(--border);border-radius:12px;box-shadow:0 28px 90px rgba(16,24,40,.20);padding:22px;transform:translateX(calc(100% + 40px));transition:.18s ease;overflow:auto}.detail.open{transform:translateX(0)}.detail form{display:grid;gap:14px}.detail-head{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center}.detail-head button{border:0;background:#F5F7FB;border-radius:6px;width:36px;height:36px;font-size:24px}.detail-title{font-size:22px!important;font-weight:850;letter-spacing:-.03em}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.detail-actions{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}.detail-actions .danger{border-color:#F8C9C9;color:var(--red-600);background:#FFF6F6}.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:90;background:var(--navy-950);color:white;border-radius:6px;padding:12px 16px;box-shadow:var(--shadow);font-weight:800}.loading{color:var(--slate-600);padding:20px;text-align:center} @media(max-width:1050px){.app-shell{grid-template-columns:1fr}.sidebar{display:none}.workspace{padding:14px}.topbar{grid-template-columns:auto 1fr}.profile-card,.progress-card{grid-column:1/-1}.sorting-layout{grid-template-columns:1fr}.capture-strip form{grid-template-columns:1fr 1fr}.capture-strip button{grid-column:1/-1}.utility-row{grid-template-columns:1fr}.utility-row button{border-right:0;border-bottom:1px solid var(--border)}.utility-row button:last-child{border-bottom:0}} +.shortcut-hint{margin:10px 2px 0;color:var(--slate-600);font-size:12px}.shortcut-hint kbd{display:inline-grid;place-items:center;min-width:22px;height:20px;padding:0 5px;border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;background:#fff;color:var(--navy-950);font-weight:800}.timeline-line.drag-over{outline:2px dashed var(--blue-600);outline-offset:8px;background:linear-gradient(90deg,rgba(22,163,74,.06),rgba(37,99,235,.06),rgba(245,158,11,.06),rgba(124,58,237,.06))}.node[draggable="true"]{cursor:grab}.node[draggable="true"]:active{cursor:grabbing}.toast{display:flex;align-items:center;gap:12px}.toast button{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.12);color:#fff;border-radius:4px;padding:6px 9px;font-weight:800}.detail label input[name="labels"]{width:100%;border:1px solid var(--border);border-radius:6px;padding:11px 12px;background:#F8FAFC;color:var(--slate-900);margin-top:6px}.profile-menu button.selected{background:#F0E9FF;color:var(--purple-600);font-weight:900} @media(max-width:680px){.workspace{padding:10px}.topbar{padding:12px;gap:12px}.project-title h1{font-size:21px}.sorting-grid{grid-template-columns:1fr 1fr;gap:9px}.zone{min-height:142px;padding:12px}.zone-icon{font-size:30px}.zone strong{font-size:14px}.zone span{font-size:12px}.capture-strip form,.deck-actions,.detail-grid,.detail-actions{grid-template-columns:1fr}.timeline{overflow-x:auto}.timeline-line{min-width:760px}.feature-card{min-height:160px}.feature-card::before,.feature-card::after{display:none}}