memoir-cli 3.14.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/docs/LOCAL-PRODUCTION-VALIDATION.md +71 -0
- package/docs/PROJECT-HANDOFF.md +50 -0
- package/docs/PROJECT-MAP-TRIAL.md +149 -0
- package/docs/PROJECT-RECOVERY.md +139 -0
- package/docs/RELIABILITY-ROLLOUT.md +6 -1
- package/package.json +1 -1
- package/src/security/files.js +14 -2
- package/src/session/lock.js +18 -6
- package/src/work/cli.js +33 -2
- package/src/work/errors.js +3 -3
- package/src/work/recovery.js +142 -0
- package/src/work/server.js +1 -1
- package/src/work/setup.js +1 -1
- package/src/work/snapshots.js +87 -0
- package/src/work/store.js +36 -16
- package/src/work/ui/app.js +220 -27
- package/src/work/ui/index.html +26 -11
- package/src/work/ui/style.css +248 -3
- package/src/work/view.js +3 -2
package/src/work/ui/app.js
CHANGED
|
@@ -6,11 +6,141 @@ let token = fragment.get('token');
|
|
|
6
6
|
try { if (token) sessionStorage.setItem('memoir-view-token', token); else token = sessionStorage.getItem('memoir-view-token'); } catch {}
|
|
7
7
|
if (fragment.has('token')) history.replaceState(null, '', '/');
|
|
8
8
|
let state, selected = 'overview', editing, latestEdit, editorOpener, busy = false, stateRequest = 0, renderAfterEditor = false;
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
let viewMode = 'records', focusKey = 'project', showSuggestions = false;
|
|
10
|
+
const labels = { overview: 'Overview', next: 'Next actions', answer: 'Answers', decision: 'Decisions', check: 'Checks', goal: 'Goals', removed: 'Removed' };
|
|
11
|
+
const descriptions = { overview: 'What matters for your next session.', answer: 'Already answered. Ready to carry forward.', decision: 'What you decided, with the reasons behind it.', check: 'Completed checks and the changes that need another look.', next: 'Open work first. Completed actions stay in the record.', goal: 'What this project is working toward.', removed: 'Hidden from the handoff. You can restore records here.' };
|
|
11
12
|
function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }
|
|
12
13
|
function button(text, action, className = '') { const node = el('button', text, className); node.type = 'button'; node.addEventListener('click', action); return node; }
|
|
13
14
|
function date(value) { return new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
|
|
15
|
+
function searchableText(item) { return [item.text,item.answer,item.title,item.why,item.source,item.id,...Object.keys(item.inputs||{})].filter(Boolean).join(' ').toLowerCase(); }
|
|
16
|
+
function entryLabel(item) {
|
|
17
|
+
const text = item.kind === 'next' || /^record\.[0-9a-f-]{36}$/i.test(item.id) ? item.text.split(/(?<=[.!?])\s/)[0] : item.id.replace(/^(?:record|decision|answer|check|next|goal)\./, '').replace(/[._-]+/g, ' ').replace(/^./, c => c.toUpperCase()).replace(/\bcli\b/ig, 'CLI');
|
|
18
|
+
return text.length > 62 ? text.slice(0,59).trimEnd() + '…' : text;
|
|
19
|
+
}
|
|
20
|
+
// A bounded, local projection of existing records. Shared words are suggestions,
|
|
21
|
+
// never saved relationships, causal claims, or proof that a decision is correct.
|
|
22
|
+
function buildProjectMap(data, { query = '', focus = 'project', kind = 'all' } = {}) {
|
|
23
|
+
const body = item => [item.text, item.answer, item.title, item.why, item.source].filter(Boolean).join(' ');
|
|
24
|
+
const label = entryLabel;
|
|
25
|
+
const all = [...data.records.map(item => ({key:'record:'+item.id,kind:item.kind,item,label:label(item)})), ...data.checks.map(item => ({key:'check:'+item.id,kind:'check',item,label:label(item)}))].sort((a,b) => b.item.revision-a.item.revision);
|
|
26
|
+
const search = query.toLowerCase().trim();
|
|
27
|
+
const matches = node => (kind==='all'||node.kind===kind) && (!search || searchableText(node.item).includes(search));
|
|
28
|
+
const ordered = [...all].sort((a,b) => (b.key===focus)-(a.key===focus) || Number(matches(b))-Number(matches(a)));
|
|
29
|
+
const nodes = ordered.slice(0,120), edges = [];
|
|
30
|
+
for (const node of nodes) edges.push({source:'project',target:node.key,type:'recorded',label:'Stored in this project on the current branch.'});
|
|
31
|
+
const stop = new Set('about after again also already another before being branch checks completed context current decisions during existing files first found from have into local memoir memory needs next only passed personal project record records release review saved session should source state test tests that their these they this through using verified version were what when which will with work workflow would'.split(' '));
|
|
32
|
+
const terms = new Map(nodes.map(node => [node.key,new Set((body(node.item).normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}]+/gu)||[]).filter(word => word.length>=4 && /\p{L}/u.test(word) && !stop.has(word)).slice(0,180))]));
|
|
33
|
+
const frequency = new Map(); for (const set of terms.values()) for (const term of set) frequency.set(term,(frequency.get(term)||0)+1);
|
|
34
|
+
const references=new Map(nodes.map(node=>[node.key,new Set((body(node.item).match(/[A-Za-z0-9][A-Za-z0-9._/-]*/g)||[]).map(token=>token.replace(/\.+$/,'')))]));
|
|
35
|
+
for (let i=0;i<nodes.length;i++) for (let j=i+1;j<nodes.length;j++) {
|
|
36
|
+
const a=nodes[i], b=nodes[j];
|
|
37
|
+
const reference = references.get(a.key).has(b.item.id) ? [a,b] : references.get(b.key).has(a.item.id) ? [b,a] : null;
|
|
38
|
+
if (reference) { edges.push({source:a.key,target:b.key,type:'recorded',label:reference[0].label+' explicitly mentions '+reference[1].item.id+'.'}); continue; }
|
|
39
|
+
const receipt = a.kind==='check'?a:b.kind==='check'?b:null, note=receipt===a?b:a;
|
|
40
|
+
const paths = receipt && note.kind!=='check' ? Object.keys(receipt.item.inputs||{}).filter(file=>references.get(note.key).has(file)) : [];
|
|
41
|
+
if (paths.length) { edges.push({source:a.key,target:b.key,type:'recorded',label:'This entry names '+paths.slice(0,3).join(', ')+'. The check declares '+(paths.length===1?'that file':'those files')+' as input; this does not verify the entry’s claims.'}); continue; }
|
|
42
|
+
const shared=[...terms.get(a.key)].filter(word=>terms.get(b.key).has(word) && frequency.get(word)<=Math.max(3,nodes.length*.6));
|
|
43
|
+
if (shared.length>=3) {
|
|
44
|
+
shared.sort((x,y)=>frequency.get(x)-frequency.get(y)||x.localeCompare(y));
|
|
45
|
+
edges.push({source:a.key,target:b.key,type:'suggested',score:shared.reduce((sum,word)=>sum+1/frequency.get(word),0),label:'Suggested from shared words: '+shared.slice(0,5).join(', ')+'. This is not a saved relationship.'});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {nodes,edges,total:all.length,matches:nodes.filter(matches),matchingTotal:all.filter(matches).length};
|
|
49
|
+
}
|
|
50
|
+
function mapNeighborhood(model, focus, suggestions = false, overview = false) {
|
|
51
|
+
const linked = focus ? model.edges.filter(edge => edge.source !== 'project' && edge.target !== 'project' &&
|
|
52
|
+
(edge.source === focus.key || edge.target === focus.key) && (suggestions || edge.type === 'recorded'))
|
|
53
|
+
.sort((a,b) => (a.type === 'suggested') - (b.type === 'suggested') || (b.score || 0) - (a.score || 0)) : [];
|
|
54
|
+
let ranked = focus ? linked.map(edge => model.nodes.find(node => node.key === (edge.source === focus.key ? edge.target : edge.source))) : [...model.matches];
|
|
55
|
+
if (!focus && overview) {
|
|
56
|
+
const picked = [];
|
|
57
|
+
for (const kind of ['goal','next','answer','decision','check']) {
|
|
58
|
+
const entry = ranked.find(node => node.kind === kind && (kind !== 'next' || node.item.status !== 'done'));
|
|
59
|
+
if (entry) picked.push(entry);
|
|
60
|
+
}
|
|
61
|
+
ranked = [...picked,...ranked.filter(node => !picked.includes(node))];
|
|
62
|
+
}
|
|
63
|
+
const shown = ranked.slice(0,6), visibleKeys = new Set(shown.map(node => node.key));
|
|
64
|
+
const edges = focus ? linked.filter(edge => visibleKeys.has(edge.source) || visibleKeys.has(edge.target)) :
|
|
65
|
+
model.edges.filter(edge => edge.source === 'project' && visibleKeys.has(edge.target));
|
|
66
|
+
return {ranked,shown,edges,linked};
|
|
67
|
+
}
|
|
68
|
+
function selectMapNode(key) {
|
|
69
|
+
focusKey=key; renderMap(true);
|
|
70
|
+
}
|
|
71
|
+
function renderMap(restoreFocus = false) {
|
|
72
|
+
if (!state) return;
|
|
73
|
+
const mapKind = selected === 'overview' || selected === 'removed' ? 'all' : selected;
|
|
74
|
+
const model=buildProjectMap(state,{query:$('search').value,focus:focusKey,kind:mapKind});
|
|
75
|
+
if (focusKey!=='project'&&!model.nodes.some(node=>node.key===focusKey)) focusKey='project';
|
|
76
|
+
const focus=model.nodes.find(node=>node.key===focusKey);
|
|
77
|
+
$('map-workspace').dataset.focused=String(!!focus);
|
|
78
|
+
const {ranked,shown,edges,linked}=mapNeighborhood(model,focus,showSuggestions,!$('search').value.trim()&&mapKind==='all');
|
|
79
|
+
$('map-count').textContent=focus ? shown.length+' of '+ranked.length+' connections · '+focus.label :
|
|
80
|
+
shown.length+' of '+model.matchingTotal+' entries'+(model.total>120?' · search to find older entries':'');
|
|
81
|
+
$('map-suggestions').setAttribute('aria-pressed',String(showSuggestions));
|
|
82
|
+
$('map-suggested-legend').hidden=!showSuggestions;
|
|
83
|
+
const canvas=$('map-canvas');canvas.replaceChildren();
|
|
84
|
+
const positions=[[22,18],[78,18],[19,47],[81,47],[22,77],[78,77]];
|
|
85
|
+
const center=focus||{key:'project',kind:'project',label:state.project_name};
|
|
86
|
+
const points=new Map([[center.key,[50,47]],...shown.map((node,index)=>[node.key,positions[index]])]);
|
|
87
|
+
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 1000 640');svg.setAttribute('preserveAspectRatio','none');svg.setAttribute('aria-hidden','true');svg.setAttribute('class','map-lines');
|
|
88
|
+
for(const edge of edges) {
|
|
89
|
+
const a=points.get(edge.source),b=points.get(edge.target),line=document.createElementNS('http://www.w3.org/2000/svg','path');
|
|
90
|
+
line.setAttribute('d',`M ${a[0]*10} ${a[1]*6.4} L ${b[0]*10} ${b[1]*6.4}`);
|
|
91
|
+
line.setAttribute('class','map-line '+edge.type+((edge.source===focusKey||edge.target===focusKey)&&focusKey!=='project'?' focused':''));svg.append(line);
|
|
92
|
+
}
|
|
93
|
+
canvas.append(svg);
|
|
94
|
+
let focusedButton;
|
|
95
|
+
for(const node of [center,...shown]) {
|
|
96
|
+
const active=node.key===focusKey;
|
|
97
|
+
const entry=button('',()=>selectMapNode(node.key),'map-node'+(active?' selected':''));
|
|
98
|
+
entry.dataset.center=String(node.key===center.key);
|
|
99
|
+
entry.dataset.kind=node.kind;entry.dataset.nodeKey=node.key;entry.style.left=points.get(node.key)[0]+'%';entry.style.top=points.get(node.key)[1]+'%';entry.setAttribute('aria-pressed',String(active));entry.setAttribute('aria-label',(node.kind==='project'?'Project':labels[node.kind])+': '+(node.item?.text||node.item?.title||node.label));
|
|
100
|
+
const dot=el('span',undefined,'node-dot');dot.setAttribute('aria-hidden','true');
|
|
101
|
+
entry.append(dot,el('span',node.label,'node-label'),el('span',node.kind==='project'?model.total+' entries':node.kind==='check'?(node.item.freshness==='inputs-match'?'Inputs match':'Needs review'):node.kind==='next'?(node.item.status==='done'?'Completed':'Next action'):labels[node.kind],'node-kind'));
|
|
102
|
+
canvas.append(entry);if(active)focusedButton=entry;
|
|
103
|
+
}
|
|
104
|
+
if(!shown.length)canvas.append(el('p',focus?(showSuggestions?'No connected entries found.':'No recorded connections yet. Try Suggested links.'):$('search').value?'No matching entries. Try another term.':'No entries in this category yet.','map-empty'));
|
|
105
|
+
const panel=$('map-inspector');panel.replaceChildren();
|
|
106
|
+
if(focus)panel.append(button('← Back to project',()=>{focusKey='project';renderMap(true);},'inspector-back'));
|
|
107
|
+
const panelTitle=el('h2',focus?focus.label:state.project_name);panelTitle.tabIndex=-1;
|
|
108
|
+
panel.append(el('p',focus?'IN FOCUS':'PROJECT BRIEF','inspector-eyebrow'),panelTitle);
|
|
109
|
+
if(focus) {
|
|
110
|
+
const card=focus.kind==='check'?checkCard(focus.item):recordCard(focus.item);card.className+=' expanded';panel.append(card);
|
|
111
|
+
panel.append(button('Open in Records ↗', () => { selected = focus.kind; $('search').value = ''; setView('records'); $('section-title').focus(); }, 'inspector-back'));
|
|
112
|
+
const connections=el('div',undefined,'inspector-connections');connections.append(el('h3','Connected context'));
|
|
113
|
+
const relevant=linked.slice(0,6);
|
|
114
|
+
if(!relevant.length)connections.append(el('p',showSuggestions?'No recorded references or shared topics found.':'No recorded references found. Suggested links can reveal possible shared topics.','connection-note'));
|
|
115
|
+
for(const edge of relevant) {
|
|
116
|
+
const other=model.nodes.find(node=>node.key===(edge.source===focusKey?edge.target:edge.source));
|
|
117
|
+
const row=button('',()=>selectMapNode(other.key),'connection-row');
|
|
118
|
+
row.append(el('span',edge.type==='recorded'?'RECORDED LINK':'SUGGESTED LINK','connection-type '+edge.type),el('strong',other.label),el('span',edge.label,'connection-reason'));connections.append(row);
|
|
119
|
+
}
|
|
120
|
+
if(linked.length>6)connections.append(el('p','Showing 6 of '+linked.length+' connections. Search for another entry to explore more.','connection-note'));
|
|
121
|
+
panel.append(connections);
|
|
122
|
+
} else {
|
|
123
|
+
const goal=state.records.filter(item=>item.kind==='goal').sort((a,b)=>b.revision-a.revision)[0];
|
|
124
|
+
panel.append(el('p',goal?.text||'Start with an answer, a decision or a next action. Connections appear as your project record grows.','project-brief'));
|
|
125
|
+
panel.append(el('p','Select an entry to read the full context and see why it is connected.','inspector-hint'));
|
|
126
|
+
panel.append(button('+ Add project context',()=>edit(), 'map-add'));
|
|
127
|
+
const index=el('div',undefined,'inspector-index');index.append(el('h3',$('search').value?'Search results':mapKind==='all'?'Project entries':labels[mapKind]));
|
|
128
|
+
for(const node of ranked.slice(0,24)) {const row=button('',()=>selectMapNode(node.key),'index-entry');row.dataset.kind=node.kind;row.setAttribute('aria-label',labels[node.kind]+': '+node.label);row.append(el('i'),el('span',node.label),el('span','↗'));index.append(row);}
|
|
129
|
+
if(ranked.length>24)index.append(el('p','Search to narrow '+ranked.length+' matching entries.','connection-note'));
|
|
130
|
+
panel.append(index);
|
|
131
|
+
}
|
|
132
|
+
panel.scrollTop=0;
|
|
133
|
+
// In narrow windows the detail panel follows the map in normal flow. Move
|
|
134
|
+
// focus to its heading so the selected context is visible without an overlay.
|
|
135
|
+
if(restoreFocus)(focus?panelTitle:focusedButton)?.focus();
|
|
136
|
+
}
|
|
137
|
+
function setView(mode) {
|
|
138
|
+
viewMode=mode;
|
|
139
|
+
if (mode === 'map' && selected === 'removed') { selected = 'overview'; focusKey = 'project'; }
|
|
140
|
+
$('map-workspace').hidden=mode!=='map';$('records-workspace').hidden=mode!=='records';
|
|
141
|
+
$('show-map').setAttribute('aria-pressed',String(mode==='map'));$('show-records').setAttribute('aria-pressed',String(mode==='records'));
|
|
142
|
+
render();
|
|
143
|
+
}
|
|
14
144
|
function notice(message, error = false, undo) {
|
|
15
145
|
$('notice').replaceChildren(); const node = el('div', undefined, 'notice' + (error ? ' error' : '')); node.append(el('span', message));
|
|
16
146
|
if (undo) node.append(button('Undo', undo)); $('notice').append(node);
|
|
@@ -43,11 +173,27 @@ function nav() {
|
|
|
43
173
|
$('navigation').replaceChildren();
|
|
44
174
|
for (const [key, label] of Object.entries(labels)) {
|
|
45
175
|
const count = key === 'overview' ? null : key === 'removed' ? state.removed.length : key === 'check' ? state.checks.length : state.records.filter(r => r.kind === key).length;
|
|
46
|
-
const node = button(
|
|
176
|
+
const node = button('', () => { selected = key; focusKey = 'project'; if (key === 'removed') setView('records'); else render(); [...$('navigation').children].find(entry => entry.dataset.kind === key)?.focus({preventScroll:true}); });
|
|
177
|
+
node.dataset.kind = key;
|
|
178
|
+
const icon = el('span', undefined, 'nav-icon icon-' + key); icon.setAttribute('aria-hidden', 'true'); node.append(icon, el('span', label, 'nav-name'));
|
|
47
179
|
if (selected === key) node.setAttribute('aria-current', 'page');
|
|
48
180
|
if (count !== null) node.append(el('span', count, 'count')); $('navigation').append(node);
|
|
49
181
|
}
|
|
50
182
|
}
|
|
183
|
+
function summary() {
|
|
184
|
+
const stale = state.checks.filter(r => r.freshness !== 'inputs-match').length;
|
|
185
|
+
const entries = [
|
|
186
|
+
['next', state.records.filter(r => r.kind === 'next' && r.status !== 'done').length, 'Open actions'],
|
|
187
|
+
['answer', state.records.filter(r => r.kind === 'answer').length, 'Answers saved'],
|
|
188
|
+
['check', stale || state.checks.length, stale ? 'Checks to review' : 'Checks match files'],
|
|
189
|
+
];
|
|
190
|
+
$('summary').replaceChildren();
|
|
191
|
+
for (const [kind, count, label] of entries) {
|
|
192
|
+
const node = button('', () => { selected = kind; focusKey = 'project'; $('search').value = ''; render(); $('section-title').focus(); }, 'summary-item' + (kind === 'check' && stale ? ' needs-attention' : ''));
|
|
193
|
+
const arrow = el('span', '↗', 'summary-arrow'); arrow.setAttribute('aria-hidden', 'true');
|
|
194
|
+
node.append(el('strong', count), el('span', label), arrow); $('summary').append(node);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
51
197
|
function metadata(item, check = false) {
|
|
52
198
|
const details = el('details', undefined, 'metadata'); details.append(el('summary', check ? 'Evidence and covered files' : 'Source and earlier versions'));
|
|
53
199
|
details.append(el('p', `Saved ${date(item.recorded_at)} · revision ${item.revision}`));
|
|
@@ -56,7 +202,7 @@ function metadata(item, check = false) {
|
|
|
56
202
|
const list = el('ul'); Object.keys(item.inputs).forEach(file => list.append(el('li', file))); details.append(list);
|
|
57
203
|
details.append(el('p', 'Output was discarded. Its fingerprint:')); details.append(el('code', item.output_sha256));
|
|
58
204
|
} else {
|
|
59
|
-
details.append(el('p', item.source)); if (item.why) details.append(el('p', 'Why: ' + item.why));
|
|
205
|
+
details.append(el('p', item.source)); if (item.why && item.kind !== 'next') details.append(el('p', 'Why: ' + item.why));
|
|
60
206
|
const history = state.history.filter(r => r.id === item.id && r.revision < item.revision).reverse();
|
|
61
207
|
for (const old of history) { const row = el('p', `Revision ${old.revision} · ${date(old.recorded_at)}\n${old.text}${old.answer ? '\n' + old.answer : ''}\nSource: ${old.source}`); details.append(row); }
|
|
62
208
|
if (!history.length) details.append(el('p', 'No earlier versions.'));
|
|
@@ -67,14 +213,28 @@ function recordCard(item, removed = false) {
|
|
|
67
213
|
const card = el('article', undefined, 'card' + (item.kind === 'next' && item.status === 'done' ? ' done' : ''));
|
|
68
214
|
card.dataset.recordId = item.id;
|
|
69
215
|
const kind = item.kind === 'next' ? (item.status === 'done' ? 'DONE' : 'TO DO') : item.kind === 'answer' ? 'ANSWERED' : item.kind.toUpperCase();
|
|
70
|
-
card.append(el('span', removed ? 'REMOVED' : kind, 'badge' + (removed ? ' neutral' : '')));
|
|
71
|
-
|
|
72
|
-
|
|
216
|
+
const heading = el('div', undefined, 'card-heading'); heading.append(el('span', removed ? 'REMOVED' : kind, 'badge' + (removed ? ' neutral' : '')), el('span', new Date(item.recorded_at).toLocaleDateString([], { month:'short', day:'numeric' }), 'record-date')); card.append(heading);
|
|
217
|
+
const copy = el('div', undefined, 'record-copy');
|
|
218
|
+
copy.append(el('h3', item.kind === 'answer' ? item.text : entryLabel(item)));
|
|
219
|
+
if (item.kind !== 'answer') copy.append(el('p', item.text, 'record-text'));
|
|
220
|
+
if (item.kind === 'next' && item.why) copy.append(el('p', item.why, 'record-text'));
|
|
221
|
+
if (item.answer) copy.append(el('p', item.answer, 'answer')); card.append(copy);
|
|
222
|
+
const compact = selected === 'overview' && !$('search').value.trim() && viewMode === 'records';
|
|
223
|
+
if (compact || item.text.length > 200 || item.answer?.length > 220 || item.kind === 'next' && item.why?.length > 220) {
|
|
224
|
+
card.className += ' has-preview';
|
|
225
|
+
let expanded = focusKey === 'record:' + item.id;
|
|
226
|
+
if (expanded) card.className += ' expanded';
|
|
227
|
+
const collapsedLabel = compact ? 'Details' : 'Read full entry';
|
|
228
|
+
const more = button(collapsedLabel, () => { expanded = !expanded; card.className = card.className.replace(' expanded', '') + (expanded ? ' expanded' : ''); more.textContent = expanded ? 'Show less' : collapsedLabel; more.setAttribute('aria-expanded', String(expanded)); }, 'read-more');
|
|
229
|
+
more.textContent = expanded ? 'Show less' : collapsedLabel;
|
|
230
|
+
more.setAttribute('aria-expanded', String(expanded)); card.append(more);
|
|
231
|
+
}
|
|
73
232
|
const actions = el('div', undefined, 'card-actions');
|
|
74
233
|
if (removed) actions.append(button('Restore to handoff', () => restore(item)));
|
|
75
234
|
else {
|
|
76
235
|
actions.append(button('Correct', () => edit(item)));
|
|
77
236
|
if (item.kind === 'next') actions.append(button(item.status === 'done' ? 'Reopen' : 'Mark done', () => changeStatus(item)));
|
|
237
|
+
if (viewMode === 'records') actions.append(button('Connections', () => { focusKey = 'record:' + item.id; setView('map'); renderMap(true); }));
|
|
78
238
|
actions.append(button('Remove from handoff', () => remove(item), 'remove'));
|
|
79
239
|
}
|
|
80
240
|
card.append(actions, metadata(item)); return card;
|
|
@@ -90,31 +250,57 @@ function checkCard(item, removed = false) {
|
|
|
90
250
|
card.append(metadata(item, true)); if (!removed) { const actions = el('div', undefined, 'card-actions'); actions.append(button('Remove from handoff', () => remove(item, 'check'), 'remove')); card.append(actions); }
|
|
91
251
|
return card;
|
|
92
252
|
}
|
|
93
|
-
function matches(item) { const query = $('search').value.toLowerCase().trim(); return !query ||
|
|
253
|
+
function matches(item) { const query = $('search').value.toLowerCase().trim(); return !query || searchableText(item).includes(query); }
|
|
94
254
|
function group(kind, items, limit = Infinity) {
|
|
95
255
|
const section = el('section', undefined, 'group'); const heading = el('div', undefined, 'group-title'); heading.append(el('h3', labels[kind]));
|
|
96
|
-
if (selected === 'overview') heading.append(button('View all →', () => { selected = kind; render(); $('section-title').focus(); }));
|
|
97
|
-
|
|
256
|
+
if (selected === 'overview') heading.append(button('View all →', () => { selected = kind; render(); $('section-title').focus(); }));
|
|
257
|
+
if (selected === 'overview') section.append(heading);
|
|
258
|
+
const cards = el('div', undefined, 'cards'); items.filter(matches).slice(0, limit).forEach(item => cards.append(kind === 'check' ? checkCard(item) : recordCard(item))); section.append(cards);
|
|
259
|
+
if (selected === 'overview' && !$('search').value.trim() && ['answer','decision'].includes(kind)) {
|
|
260
|
+
const archive = el('details', undefined, 'archive-group');
|
|
261
|
+
archive.append(el('summary', (kind === 'answer' ? 'Saved answers' : 'Recent decisions') + ' · ' + items.length), section);
|
|
262
|
+
return archive;
|
|
263
|
+
}
|
|
264
|
+
return section;
|
|
98
265
|
}
|
|
99
266
|
function render() {
|
|
100
267
|
if (!state) return;
|
|
101
268
|
renderAfterEditor = false; $('add').disabled = busy;
|
|
102
|
-
nav(); $('project').
|
|
269
|
+
nav(); summary(); $('project').replaceChildren(el('span', state.project_name, 'project-name'), el('span', state.branch || 'No Git branch', 'branch-name'));
|
|
103
270
|
$('revision').textContent = `Handoff revision ${state.revision}. Refreshed ${new Date().toLocaleTimeString()}.`;
|
|
104
271
|
$('section-title').textContent = labels[selected]; $('section-description').textContent = descriptions[selected];
|
|
105
272
|
$('goal').replaceChildren();
|
|
106
|
-
|
|
273
|
+
$('view-caption').textContent = viewMode === 'map' ? 'Connections' : 'Saved context';
|
|
274
|
+
$('summary').hidden = selected !== 'overview' || viewMode === 'map';
|
|
275
|
+
if (selected === 'overview' && viewMode === 'records') {
|
|
276
|
+
const goal = state.records.filter(r => r.kind === 'goal').sort((a,b) => b.revision - a.revision)[0];
|
|
277
|
+
if (goal) {
|
|
278
|
+
const node = el('details', undefined, 'goal');
|
|
279
|
+
node.append(el('summary', 'Current focus'), el('p', goal.text), button('Edit goal', () => edit(goal), 'quiet')); $('goal').append(node);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
107
282
|
const content = $('content'); content.replaceChildren();
|
|
283
|
+
content.dataset.compact = String(selected === 'overview' && !$('search').value.trim());
|
|
108
284
|
if (selected === 'removed') {
|
|
109
285
|
const cards = el('div', undefined, 'cards'); state.removed.filter(r => matches(r.item)).forEach(r => cards.append(r.category === 'check' ? checkCard(r.item, true) : recordCard(r.item, true))); content.append(cards);
|
|
110
286
|
} else if (selected === 'overview') {
|
|
111
|
-
for (const kind of ['next','answer','check','decision']) {
|
|
287
|
+
for (const kind of ['next','answer','check','decision','goal']) {
|
|
112
288
|
let items = [...(kind === 'check' ? state.checks : state.records.filter(r => r.kind === kind))].sort((a,b) => b.revision - a.revision);
|
|
113
|
-
if (kind === '
|
|
114
|
-
if (
|
|
289
|
+
if (kind === 'goal' && !$('search').value.trim()) continue;
|
|
290
|
+
if (kind === 'next' && !$('search').value.trim()) items = items.filter(item => item.status !== 'done');
|
|
291
|
+
if (kind === 'check' && !$('search').value.trim()) items = items.filter(item => item.freshness !== 'inputs-match');
|
|
292
|
+
if (kind === 'check') items.sort((a,b) => (a.freshness === 'inputs-match') - (b.freshness === 'inputs-match') || b.revision - a.revision);
|
|
293
|
+
if (items.some(matches)) content.append(group(kind, items, $('search').value.trim() || kind === 'next' || kind === 'check' ? Infinity : 2));
|
|
115
294
|
}
|
|
116
|
-
} else
|
|
295
|
+
} else {
|
|
296
|
+
const items = [...(selected === 'check' ? state.checks : state.records.filter(r => r.kind === selected))].sort((a,b) => b.revision - a.revision);
|
|
297
|
+
if (selected === 'next') items.sort((a,b) => (a.status === 'done') - (b.status === 'done') || b.revision - a.revision);
|
|
298
|
+
if (selected === 'check') items.sort((a,b) => (a.freshness === 'inputs-match') - (b.freshness === 'inputs-match') || b.revision - a.revision);
|
|
299
|
+
if (focusKey !== 'project') items.sort((a,b) => Number(focusKey.endsWith(':' + b.id)) - Number(focusKey.endsWith(':' + a.id)));
|
|
300
|
+
content.append(group(selected, items));
|
|
301
|
+
}
|
|
117
302
|
if (!content.querySelector('.card')) { const empty = el('div', undefined, 'empty'); empty.append(el('strong', $('search').value ? 'No matching memories' : selected === 'removed' ? 'Nothing removed' : 'A fresh start'), el('span', $('search').value ? 'Try a shorter search.' : selected === 'removed' ? 'Items you remove will appear here.' : 'Add an answer, a decision or the next step.')); content.replaceChildren(empty); }
|
|
303
|
+
renderMap();
|
|
118
304
|
}
|
|
119
305
|
function kindFields() {
|
|
120
306
|
$('answer-label').hidden = $('kind').value !== 'answer'; $('answer').required = $('kind').value === 'answer';
|
|
@@ -126,7 +312,7 @@ function edit(item) {
|
|
|
126
312
|
editorOpener = document.activeElement; latestEdit = null;
|
|
127
313
|
// Reuse a new record's ID after an uncertain response. A retry must conflict
|
|
128
314
|
// with a committed save instead of creating a second copy of the same draft.
|
|
129
|
-
editing = { item, id: item?.id || 'record.' + crypto.randomUUID(), branch: state.branch };
|
|
315
|
+
editing = { item, id: item?.id || 'record.' + crypto.randomUUID(), branch: state.branch, expected_recovery: state.recovery_id };
|
|
130
316
|
$('review-latest').hidden = true; $('comparison').hidden = true;
|
|
131
317
|
$('editor-title').textContent = item ? 'Correct memory' : 'Add memory'; $('save').textContent = item ? 'Save correction' : 'Save memory';
|
|
132
318
|
$('kind').value = item?.kind || 'answer'; $('kind').disabled = !!item;
|
|
@@ -140,17 +326,17 @@ async function action(input) {
|
|
|
140
326
|
finally { busy = false; $('add').disabled = !state; $('refresh').disabled = false; }
|
|
141
327
|
}
|
|
142
328
|
async function remove(item, category = 'record') {
|
|
143
|
-
const branch = state.branch;
|
|
144
|
-
try { await action({ action:'remove', branch, id:item.id, category, expected_revision:item.revision }); notice('Removed from the handoff. Earlier versions are kept locally.', false, category === 'record' ? () => restore(item, branch) : undefined); }
|
|
329
|
+
const branch = state.branch, recovery = state.recovery_id;
|
|
330
|
+
try { await action({ action:'remove', branch, expected_recovery: recovery, id:item.id, category, expected_revision:item.revision }); notice('Removed from the handoff. Earlier versions are kept locally.', false, category === 'record' ? () => restore(item, branch, recovery) : undefined); }
|
|
145
331
|
catch (error) { notice(error.message, true); }
|
|
146
332
|
}
|
|
147
|
-
async function restore(item, branch = state.branch) {
|
|
148
|
-
try { await action({ action:'restore', branch, id:item.id, expected_revision:item.revision }); notice('Restored to the handoff.'); }
|
|
333
|
+
async function restore(item, branch = state.branch, recovery = state.recovery_id) {
|
|
334
|
+
try { await action({ action:'restore', branch, expected_recovery: recovery, id:item.id, expected_revision:item.revision }); notice('Restored to the handoff.'); }
|
|
149
335
|
catch (error) { notice(error.message, true); }
|
|
150
336
|
}
|
|
151
337
|
function fields(item) { return { kind:item.kind, text:item.text, ...(item.answer ? {answer:item.answer} : {}), ...(item.why ? {why:item.why} : {}), status:item.status }; }
|
|
152
338
|
async function changeStatus(item) {
|
|
153
|
-
try { await action({ action:'save', branch:state.branch, id:item.id, expected_revision:item.revision, fields:{...fields(item),status:item.status === 'done' ? 'open' : 'done'} }); notice(item.status === 'done' ? 'Step reopened.' : 'Step marked done.'); }
|
|
339
|
+
try { await action({ action:'save', branch:state.branch, expected_recovery: state.recovery_id, id:item.id, expected_revision:item.revision, fields:{...fields(item),status:item.status === 'done' ? 'open' : 'done'} }); notice(item.status === 'done' ? 'Step reopened.' : 'Step marked done.'); }
|
|
154
340
|
catch (error) { notice(error.message, true); }
|
|
155
341
|
}
|
|
156
342
|
function editorSaving(saving) {
|
|
@@ -163,7 +349,10 @@ $('edit-form').addEventListener('submit', async event => {
|
|
|
163
349
|
submitted.saving = true; editorSaving(true); $('form-error').textContent = '';
|
|
164
350
|
try {
|
|
165
351
|
const kind = $('kind').value;
|
|
166
|
-
await action({ action:'save', branch:submitted.branch, id:submitted.id, expected_revision:submitted.item?.revision || 0, fields:{kind,text:$('text').value, ...(kind === 'answer' ? {answer:$('answer').value} : {}), ...($('why').value ? {why:$('why').value} : {}),status:kind === 'next' ? $('status').value : 'open'} });
|
|
352
|
+
await action({ action:'save', branch:submitted.branch, expected_recovery: submitted.expected_recovery, id:submitted.id, expected_revision:submitted.item?.revision || 0, fields:{kind,text:$('text').value, ...(kind === 'answer' ? {answer:$('answer').value} : {}), ...($('why').value ? {why:$('why').value} : {}),status:kind === 'next' ? $('status').value : 'open'} });
|
|
353
|
+
// A successful save must be visible even if the old search or category
|
|
354
|
+
// would exclude it. Both views continue from the same saved entry.
|
|
355
|
+
focusKey = 'record:' + submitted.id; selected = kind; $('search').value = ''; render();
|
|
167
356
|
if (editing === submitted) $('editor').close();
|
|
168
357
|
notice(submitted.item ? 'Correction saved. The next session will use this version.' : 'Memory saved for the next session.');
|
|
169
358
|
} catch (error) { if (editing === submitted) { $('form-error').textContent = error.message; $('review-latest').hidden = !['refresh_required', 'save_unconfirmed'].includes(error.code); } }
|
|
@@ -179,7 +368,7 @@ $('review-latest').addEventListener('click', async () => {
|
|
|
179
368
|
state = latest; renderAfterEditor = true;
|
|
180
369
|
const item = latest.records.find(record => record.id === reviewed.id);
|
|
181
370
|
if (!item) throw new Error(reviewed.item || latest.removed.some(record => record.item.id === reviewed.id) ? 'This item was removed. Your draft is still here. Close the editor and refresh, then use Removed to review or restore it.' : 'This new memory is not in the saved handoff yet. Your draft is kept. Try Save memory again.');
|
|
182
|
-
latestEdit = { item, id:item.id, branch: latest.branch };
|
|
371
|
+
latestEdit = { item, id:item.id, branch: latest.branch, expected_recovery: latest.recovery_id };
|
|
183
372
|
$('latest-text').textContent = item.text + (item.answer ? '\n\n' + item.answer : '') + (item.why ? '\n\nWhy: ' + item.why : '') + (item.kind === 'next' ? '\nProgress: ' + item.status : '');
|
|
184
373
|
$('comparison').hidden = false; $('keep-draft').focus();
|
|
185
374
|
} catch (error) { if (editing === reviewed && generation === stateRequest) $('form-error').textContent = error.message; }
|
|
@@ -194,12 +383,16 @@ $('keep-draft').addEventListener('click', () => {
|
|
|
194
383
|
$('editor').addEventListener('close', () => {
|
|
195
384
|
++stateRequest;
|
|
196
385
|
if (renderAfterEditor) render();
|
|
197
|
-
const
|
|
198
|
-
|
|
386
|
+
const activeWorkspace = $(viewMode === 'map' ? 'map-inspector' : 'content');
|
|
387
|
+
const card = [...activeWorkspace.querySelectorAll('[data-record-id]')].find(node => node.dataset.recordId === editing?.id);
|
|
388
|
+
(editorOpener?.isConnected ? editorOpener : card?.querySelector('.card-actions button') || (editing?.item?.kind === 'goal' && $('goal').querySelector('button')) || $('add')).focus();
|
|
199
389
|
editing = null; latestEdit = null;
|
|
200
390
|
});
|
|
201
391
|
$('editor').addEventListener('cancel', event => { if (editing?.saving) event.preventDefault(); });
|
|
202
392
|
$('kind').addEventListener('change', kindFields); $('cancel').addEventListener('click', () => $('editor').close()); $('cancel-top').addEventListener('click', () => $('editor').close());
|
|
203
|
-
$('add').addEventListener('click', () => edit()); $('refresh').addEventListener('click', refresh); $('search').addEventListener('input', render);
|
|
393
|
+
$('add').addEventListener('click', () => edit()); $('refresh').addEventListener('click', refresh); $('search').addEventListener('input', () => { focusKey = 'project'; render(); });
|
|
394
|
+
$('show-map').addEventListener('click',()=>setView('map'));$('show-records').addEventListener('click',()=>setView('records'));
|
|
395
|
+
$('map-suggestions').addEventListener('click',()=>{showSuggestions=!showSuggestions;renderMap();});
|
|
396
|
+
$('map-reset').addEventListener('click',()=>{focusKey='project';selected='overview';$('search').value='';render();});
|
|
204
397
|
document.addEventListener('visibilitychange', () => { if (!document.hidden && !$('editor').open && !busy) refresh(); });
|
|
205
398
|
refresh();
|
package/src/work/ui/index.html
CHANGED
|
@@ -2,24 +2,39 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Memoir · Project memory</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
|
4
4
|
<body>
|
|
5
|
-
<a class="skip-link" href="#section-title">Skip to project memory</a>
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
<
|
|
9
|
-
<
|
|
5
|
+
<a id="skip-link" class="skip-link" href="#section-title">Skip to project memory</a>
|
|
6
|
+
<header class="workspace-bar">
|
|
7
|
+
<a class="workspace-brand" href="/" aria-label="Memoir project home"><span aria-hidden="true">m</span> memoir</a>
|
|
8
|
+
<span class="workspace-divider" aria-hidden="true">/</span><div id="project">Opening project…</div>
|
|
9
|
+
<span class="workspace-local"><i></i> On this computer</span><button id="refresh" class="quiet">Refresh</button>
|
|
10
|
+
</header>
|
|
11
|
+
<main class="workspace">
|
|
12
|
+
<header class="workspace-heading"><div><h1>Project memory</h1><p class="subtitle">Answers, decisions and checks for your next coding session.</p></div><button id="add" class="primary" disabled>+ Add context</button></header>
|
|
10
13
|
<div id="notice" role="status" aria-live="polite"></div>
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<
|
|
14
|
+
<div class="workspace-tools">
|
|
15
|
+
<label class="search-label"><span class="search-icon" aria-hidden="true"></span><span class="sr-only">Search saved project context</span><input id="search" type="search" placeholder="Search answers, decisions, files…" autocomplete="off"></label>
|
|
16
|
+
<div class="view-switch" aria-label="Project view"><button id="show-records" type="button" aria-pressed="true">Records</button><button id="show-map" type="button" aria-pressed="false">Map</button></div>
|
|
17
|
+
</div>
|
|
18
|
+
<nav id="navigation" aria-label="Memory categories"></nav>
|
|
19
|
+
<div class="review-summary"><section id="summary" class="summary-strip" aria-label="Project at a glance"></section><section id="goal" aria-label="Current goal"></section></div>
|
|
20
|
+
<div class="section-heading"><div><h2 id="section-title" tabindex="-1">Overview</h2><p id="section-description">What matters for your next session.</p></div><span id="view-caption">Saved context</span></div>
|
|
21
|
+
<section id="records-workspace" aria-label="Project records"><div id="content" aria-live="polite"></div></section>
|
|
22
|
+
<section id="map-workspace" hidden aria-label="Project knowledge map">
|
|
23
|
+
<div class="map-controls"><p>Choose an entry to follow its connections.</p><button id="map-suggestions" type="button" aria-pressed="false">Suggested links</button></div>
|
|
24
|
+
<div class="map-layout">
|
|
25
|
+
<div class="map-surface"><div class="canvas-caption"><span id="map-count">Opening project memory</span><button id="map-reset" type="button">Back to overview</button></div><div id="map-canvas" class="map-canvas" aria-label="Connected project entries"></div><div class="map-bottom"><div class="map-legend"><span><i class="line-solid"></i> Recorded</span><span id="map-suggested-legend" hidden><i class="line-dashed"></i> Suggested word match</span></div></div></div>
|
|
26
|
+
<aside id="map-inspector" class="map-inspector" aria-live="polite" aria-label="Selected context"></aside>
|
|
27
|
+
</div>
|
|
28
|
+
<p class="map-footnote">Recorded links show project membership, named records or covered files. Suggested links are optional word matches.</p>
|
|
29
|
+
</section>
|
|
30
|
+
<footer><span id="revision"></span><details><summary>Used by your coding tools</summary><p>Memoir’s CLI, connected coding tools and this view read the same local project record. Saved corrections are available the next time a tool reads it. You can close this page and keep working.</p><p>Personal memory is separate. Check results cover their listed files; changed files may need a new check. A saved record is never permission to run a command or publish.</p></details></footer>
|
|
16
31
|
</main>
|
|
17
32
|
<dialog id="editor" aria-labelledby="editor-title"><form id="edit-form">
|
|
18
33
|
<div class="dialog-heading"><h2 id="editor-title">Correct memory</h2><button type="button" id="cancel-top" class="quiet" aria-label="Close editor">×</button></div>
|
|
19
34
|
<label>Type<select id="kind"><option value="answer">Answered question</option><option value="decision">Decision</option><option value="next">Next step</option><option value="goal">Goal</option></select></label>
|
|
20
35
|
<label><span id="text-label">Question</span><textarea id="text" rows="3" maxlength="2000" required></textarea></label>
|
|
21
36
|
<label id="answer-label">Answer<textarea id="answer" rows="3" maxlength="2000"></textarea></label>
|
|
22
|
-
<label>Why this matters <span class="optional">(optional)</span><textarea id="why" rows="2" maxlength="2000"></textarea></label>
|
|
37
|
+
<label><span>Why this matters <span class="optional">(optional)</span></span><textarea id="why" rows="2" maxlength="2000"></textarea></label>
|
|
23
38
|
<label id="status-label">Progress<select id="status"><option value="open">To do</option><option value="done">Done</option></select></label>
|
|
24
39
|
<p class="hint">Only save project context. Keep credentials and personal details out. Corrections keep earlier versions.</p>
|
|
25
40
|
<p id="form-error" class="form-error" role="alert"></p>
|