@quolu/lattice 0.12.14 → 0.12.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.14",
3
+ "version": "0.12.16",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/todo-cli.mjs CHANGED
@@ -653,6 +653,10 @@ export async function renderTodoGanttForProject({
653
653
  const topology = mergedTopology(store);
654
654
  const chain = projectTodoChainV1(topology);
655
655
  const layout = layoutTodoGantt(store, chain, { scope });
656
+ // When the diagram hides history, the page also carries the full diagram so
657
+ // the reader can bring it back in place. Nothing is hidden under `all`.
658
+ const expandedLayout = layout.scope.folded_task_count === 0
659
+ ? null : layoutTodoGantt(store, chain, { scope: 'all' });
656
660
  const narrative = await loadNarratives(store, repoRoot);
657
661
  const anchorOutcomes = verifyNarrativeAnchors({
658
662
  readModel: store,
@@ -685,6 +689,7 @@ export async function renderTodoGanttForProject({
685
689
  const rendered = renderTodoGanttHtml({
686
690
  readModel: store,
687
691
  layout,
692
+ expandedLayout,
688
693
  narratives: narrative.narratives,
689
694
  anchorOutcomes,
690
695
  presentation,
@@ -6,7 +6,7 @@ import {
6
6
  } from './todo-markdown-renderer.mjs';
7
7
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
8
8
 
9
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v11';
9
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v13';
10
10
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
11
11
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
12
12
 
@@ -190,8 +190,12 @@ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds =
190
190
  }).join('')}</ul>`;
191
191
  }
192
192
 
193
+ /** Phase states that are over: nothing is dispatched or judged under them again. */
194
+ const SETTLED_PHASE_STATUS = Object.freeze(['accepted', 'rejected']);
195
+
193
196
  function renderPhaseProgress(readModel) {
194
197
  const rows = [];
198
+ const settledRows = [];
195
199
  for (const member of readModel.members) {
196
200
  if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
197
201
  const phases = new Map(member.snapshot.phases.map((phase) => [phase.phase_id, phase]));
@@ -200,15 +204,23 @@ function renderPhaseProgress(readModel) {
200
204
  const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
201
205
  const done = tasks.filter((task) => states.get(task.task_id) === 'done').length;
202
206
  const state = phases.get(phase.phase_id);
203
- rows.push(`<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p><progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`);
207
+ const row = `<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p><progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`;
208
+ // A settled Phase is history. It stays reachable, but it does not push the
209
+ // live ones off the first screen.
210
+ (SETTLED_PHASE_STATUS.includes(state.status) ? settledRows : rows).push(row);
204
211
  }
205
212
  }
206
213
  const decoupled = readModel.members.some(({ plan }) => plan.schema === 'lattice.todo_plan.v5');
207
214
  const guidance = decoupled
208
215
  ? 'ToDo完了とPhase受理は別です。Phaseは重監査の順序を表し、通常ToDoの開始順はToDo依存だけで決まります。'
209
216
  : 'ToDo完了とPhase受理は別です。<code>gate_ready</code>では後続Phaseはまだ解放されません。';
210
- return rows.length === 0 ? ''
211
- : `<section class="phase-overview"><h2>Phase進捗</h2><p>${guidance}</p><ol>${rows.join('')}</ol></section>`;
217
+ if (rows.length === 0 && settledRows.length === 0) return '';
218
+ const liveList = rows.length === 0
219
+ ? '<p class="readiness-note">進行中のPhaseはありません。</p>'
220
+ : `<ol>${rows.join('')}</ol>`;
221
+ const settledList = settledRows.length === 0 ? ''
222
+ : `<details class="phase-settled"><summary>決着済みPhase ${settledRows.length}件</summary><ol>${settledRows.join('')}</ol></details>`;
223
+ return `<section class="phase-overview"><h2>Phase進捗</h2><p>${guidance}</p>${liveList}${settledList}</section>`;
212
224
  }
213
225
 
214
226
  function renderRightPane(sections, layout, presentation, readModel) {
@@ -273,14 +285,21 @@ function renderRightPane(sections, layout, presentation, readModel) {
273
285
  return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>元Markdown全文</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
274
286
  }
275
287
 
276
- function renderDiagramLegend(presentation, layout = null) {
288
+ function renderDiagramLegend(presentation, layout = null, expandable = false) {
277
289
  const categories = (presentation?.lanes ?? []).map((lane) => `<div class="category-entry"><dt><code>${escapeHtmlText(lane.lane)}</code> — ${escapeHtmlText(lane.name)}</dt><dd>${escapeHtmlText(lane.description)}</dd></div>`).join('');
278
290
  const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
279
291
  const foldedCount = layout?.scope?.folded_task_count ?? 0;
292
+ // The badge says what is missing from the diagram, so it is also the control
293
+ // that brings it back — a reader who notices the count is exactly the reader
294
+ // who wants to see it.
280
295
  const foldChip = foldedCount === 0 ? ''
281
- : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
296
+ : expandable
297
+ ? `<button type="button" class="fold-chip" data-toggle-expanded aria-expanded="false"><span data-toggle-label data-collapsed-label="完走済み ${foldedCount}件を非表示(押すと表示)" data-expanded-label="完走済み ${foldedCount}件を表示中(押すと非表示)">完走済み ${foldedCount}件を非表示(押すと表示)</span></button>`
298
+ : `<span class="fold-chip">完走済み ${foldedCount}件を非表示</span>`;
282
299
  const foldNote = foldedCount === 0 ? ''
283
- : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
300
+ : expandable
301
+ ? '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。上のバッジを押すと外した工程も含めて描きます。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>'
302
+ : '<p class="fold-note">後続に作業中・未着手が残っていない完了工程は図から外しています。生きた工程とその直接の前提工程は必ず描きます。外した工程は右の「全工程」から辿れ、図に出すには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は外す前の全工程で数えています。</p>';
284
303
  return `<div class="diagram-legend" aria-label="工程図の凡例"><span>${statusMarkup('pending', ' 未着手')}</span><span>${statusMarkup('in-progress', ' 作業中')}</span><span>${statusMarkup('done', ' 完了')}</span><span>${statusMarkup('blocked', ' ブロック中')}</span><span>破線枠: ready frontier(同時dispatch推奨)</span><span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${foldChip}${categoryDetails}${foldNote}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
285
304
  }
286
305
 
@@ -344,10 +363,14 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
344
363
  .todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
345
364
  .todo-node .node-title-line{font-size:13.5px;font-weight:400}
346
365
  .todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
347
- .fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
366
+ .fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font:650 13.5px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}
367
+ button.fold-chip{cursor:pointer}button.fold-chip:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
368
+ button.fold-chip[aria-expanded="true"]{border-color:var(--text-primary)}
369
+ [data-diagram][hidden]{display:none}
348
370
  .fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
349
371
  .task-index-folded{margin-top:8px}
350
- .task-index-folded>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
372
+ .task-index-folded>summary,.phase-settled>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
373
+ .phase-settled>summary:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
351
374
  .status-in-progress .node-surface{fill:var(--surface-1);stroke:var(--accent);stroke-width:2}
352
375
  .status-in-progress .status-mark{fill:var(--accent)}
353
376
  .status-in-progress .status-bar{stroke:var(--accent);stroke-width:2;stroke-linecap:round}
@@ -385,18 +408,23 @@ const CONTROLLER = `
385
408
  const nodes=[...root.querySelectorAll('[data-node-key]')];
386
409
  const edges=[...root.querySelectorAll('[data-edge-id]')];
387
410
  const laneChips=[...root.querySelectorAll('.summary-lane[data-lane-key]')];
388
- const svg=root.querySelector('[data-gantt-svg]');
411
+ const diagrams=[...root.querySelectorAll('[data-diagram]')];
412
+ const expandToggle=root.querySelector('[data-toggle-expanded]');
413
+ const toggleLabel=root.querySelector('[data-toggle-label]');
389
414
  const scroller=root.querySelector('[data-diagram-scroll]');
390
415
  const zoomOutput=root.querySelector('[data-zoom-output]');
391
- const baseWidth=Number(svg?.dataset.svgWidth??0);
392
- const baseHeight=Number(svg?.dataset.svgHeight??0);
393
- let zoom=1;let activeLaneKey=null;let selectedKey=null;let resizePointerId=null;
416
+ let svg=root.querySelector('[data-diagram]:not([hidden]) [data-gantt-svg]')??root.querySelector('[data-gantt-svg]');
417
+ let baseWidth=Number(svg?.dataset.svgWidth??0);
418
+ let baseHeight=Number(svg?.dataset.svgHeight??0);
419
+ let zoom=1;let activeLaneKey=null;let selectedKey=null;let resizePointerId=null;let expanded=false;
394
420
  const stacked=()=>window.matchMedia('(max-width:900px)').matches;
395
421
  const setSplit=(clientX)=>{if(!shell||stacked())return;const bounds=shell.getBoundingClientRect();if(bounds.width<=0)return;const percent=Math.max(30,Math.min(75,(clientX-bounds.left)/bounds.width*100));shell.style.setProperty('--split',percent+'%');};
396
422
  const finishResize=(event)=>{if(event.pointerId!==resizePointerId)return;if(divider?.hasPointerCapture(event.pointerId))divider.releasePointerCapture(event.pointerId);resizePointerId=null;};
397
423
  const setZoom=(value,minimum=.2)=>{if(!svg||!Number.isFinite(value)||value<=0)return;zoom=Math.max(minimum,Math.min(2,value));svg.setAttribute('width',String(Math.max(1,Math.round(baseWidth*zoom))));svg.setAttribute('height',String(Math.max(1,Math.round(baseHeight*zoom))));if(zoomOutput){const percent=zoom>=.1?String(Math.round(zoom*100)):String(Number((zoom*100).toFixed(1)));zoomOutput.textContent=percent+'%';}};
398
424
  const applyLane=(key)=>{activeLaneKey=key;for(const chip of laneChips)chip.setAttribute('aria-pressed',String(chip.dataset.laneKey===key));for(const node of nodes)node.classList.toggle('lane-dimmed',key!==null&&node.dataset.laneKey!==key);for(const edge of edges)edge.classList.toggle('lane-dimmed',key!==null&&edge.dataset.fromLaneKey!==key&&edge.dataset.toLaneKey!==key);};
399
425
  const toggleLane=(key)=>applyLane(activeLaneKey===key?null:key);
426
+ // Both diagrams ship in the page; the badge picks which one is on screen.
427
+ const setExpanded=(next)=>{if(diagrams.length<2)return;expanded=next;for(const diagram of diagrams)diagram.hidden=(diagram.dataset.diagram==='expanded')!==expanded;svg=diagrams.find(diagram=>!diagram.hidden)?.querySelector('[data-gantt-svg]')??svg;baseWidth=Number(svg?.dataset.svgWidth??0);baseHeight=Number(svg?.dataset.svgHeight??0);if(expandToggle){expandToggle.setAttribute('aria-expanded',String(expanded));}if(toggleLabel){toggleLabel.textContent=expanded?toggleLabel.dataset.expandedLabel:toggleLabel.dataset.collapsedLabel;}setZoom(zoom);applyLane(activeLaneKey);syncSelection();scroller?.scrollTo(0,0);};
400
428
  const showPanel=(name)=>{if(overviewPanel)overviewPanel.hidden=name!=='overview';if(detailsPanel)detailsPanel.hidden=name!=='details';if(taskIndexPanel)taskIndexPanel.hidden=name!=='task-index';if(selectedReturnButton)selectedReturnButton.hidden=name!=='task-index'||selectedKey===null;root.dataset.viewState=name;};
401
429
  const syncSelection=()=>{for(const detail of detailPanels)detail.hidden=detail.dataset.detailKey!==selectedKey;for(const node of nodes){const selected=node.dataset.nodeKey===selectedKey;node.setAttribute('aria-selected',String(selected));node.classList.toggle('selected-node',selected);}for(const edge of edges){const selected=selectedKey!==null;edge.classList.toggle('selected-incident-edge',selected&&(edge.dataset.fromNodeKey===selectedKey||edge.dataset.toNodeKey===selectedKey));}};
402
430
  const showOverview=()=>{selectedKey=null;syncSelection();showPanel('overview');};
@@ -407,6 +435,7 @@ const CONTROLLER = `
407
435
  const overviewButton=event.target.closest('[data-show-overview]');if(overviewButton&&root.contains(overviewButton)){showOverview();return;}
408
436
  const selectedButton=event.target.closest('[data-show-selected]');if(selectedButton&&root.contains(selectedButton)){showSelected();return;}
409
437
  const taskIndexButton=event.target.closest('[data-show-task-index]');if(taskIndexButton&&root.contains(taskIndexButton)){showTaskIndex();return;}
438
+ const expandButton=event.target.closest('[data-toggle-expanded]');if(expandButton&&root.contains(expandButton)){setExpanded(!expanded);return;}
410
439
  const selectButton=event.target.closest('[data-select-node-key]');if(selectButton&&root.contains(selectButton)){select(selectButton.dataset.selectNodeKey);return;}
411
440
  const zoomButton=event.target.closest('[data-zoom-action]');if(zoomButton&&root.contains(zoomButton)){const action=zoomButton.dataset.zoomAction;if(action==='in')setZoom(zoom<1&&zoom*1.25>=1?1:zoom*1.25,.001);else if(action==='out')setZoom(zoom>1&&zoom/1.25<=1?1:zoom/1.25,.001);else if(action==='reset')setZoom(1);else if(action==='fit'&&scroller){setZoom(Math.min(1,(scroller.clientWidth-16)/baseWidth),.001);scroller.scrollTo(0,0);}return;}
412
441
  const laneChip=event.target.closest('.summary-lane[data-lane-key]');if(laneChip&&root.contains(laneChip)){toggleLane(laneChip.dataset.laneKey);return;}
@@ -423,6 +452,7 @@ const CONTROLLER = `
423
452
 
424
453
  export function renderTodoGanttHtml({
425
454
  readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
455
+ expandedLayout = null,
426
456
  }) {
427
457
  if (readModel?.schema !== 'lattice.todo_store_read.v1' || !Array.isArray(readModel.members)) {
428
458
  throw new TypeError('readModel must be lattice.todo_store_read.v1');
@@ -437,13 +467,19 @@ export function renderTodoGanttHtml({
437
467
  const normalized = normalizeSections(readModel, narratives, anchorOutcomes);
438
468
  const displayName = projectDisplayName(readModel, metadata);
439
469
  const svg = renderTodoGanttSvg(layout, { presentation });
470
+ // The expanded diagram travels with the page so the badge can bring the
471
+ // history back without a round trip. A file:// artifact has nowhere to ask.
472
+ const expandedSvg = expandedLayout === null ? '' : renderTodoGanttSvg(expandedLayout, { presentation });
473
+ const diagrams = expandedSvg === ''
474
+ ? `<div data-diagram="live">${svg}</div>`
475
+ : `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
440
476
  const rightPane = renderRightPane(normalized.sections, layout, presentation, readModel);
441
477
  const staticData = serializeJsonForScript({
442
478
  renderer_version: TODO_GANTT_RENDERER_VERSION,
443
479
  metadata,
444
480
  presentation,
445
481
  });
446
- const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
482
+ const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
447
483
  const htmlBytes = Buffer.byteLength(html, 'utf8');
448
484
  if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
449
485
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {