projectkeeper 0.0.0-stage → 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
package/ui/views.js
ADDED
|
@@ -0,0 +1,2796 @@
|
|
|
1
|
+
// Views of the workbench. Each view renders from the assets the API returns; fixed text is
|
|
2
|
+
// English and identical across projects (Spec §6.13).
|
|
3
|
+
import { api, append, clear, fmtRel, fmtTime, h, navigate, openDialog, refreshActivity, refreshProject, select, state, toast, toggleKeeper, activity, renderBody, openActivity, visHtml, popover, flyout, setKeeperMode, keeperMode, setTopTools } from './app.js';
|
|
4
|
+
import { createGraph, starredIds, NOT_DRAWN, OBSERVED, observedCells, cellSummary, cleanName, MARKS, currentPalette, placementOf } from './graph.js';
|
|
5
|
+
import { NO_PLAN, INTENT_KINDS, destinationLine, destinationText, isCopyIn, sharedMark, areaWorkLine, listRowsOf, MAIN_BY, replacementOf, whereItSits, FOUNDATION_LABEL, WHOLE_LABEL, WHOLE_PLAN_LABEL } from './placement.js';
|
|
6
|
+
import { fillMarkdown } from './markdown.js';
|
|
7
|
+
import { failuresOf, reasonText } from './failures.js';
|
|
8
|
+
import { FILTERS, activeFilters, clearFilters, filterLabel, readabilityMark } from './graph-tools.js';
|
|
9
|
+
import { attentionHeader, attentionRow, attentionTargets, changeRow, drawerAttention, drawerSummary, objectAttentionNotes, sinceRows } from './strip-rows.js';
|
|
10
|
+
// Increment K (CKC-24, §6.3, §6.4, §6.17, §6.9, §6.7): the process view, the popover's lineage and versions, and the
|
|
11
|
+
// three blocks AS's renderers draw (Code, the round tree, the scope additions) — placeholders until that merge lands.
|
|
12
|
+
import * as kProcess from './k-process.js';
|
|
13
|
+
import * as kData from './k-data.js';
|
|
14
|
+
import { renderCodeView } from './k/code-view.js';
|
|
15
|
+
import { keeperView } from './keeper-view.js';
|
|
16
|
+
import { activityView } from './keeper-activity.js';
|
|
17
|
+
import { renderScopeK } from './k/scope-k.js';
|
|
18
|
+
import { openDraft } from './k/draft-view.js';
|
|
19
|
+
import { openInside } from './k-fold.js';
|
|
20
|
+
import { createListFold, PLATE_KEY, CROSS_KEY, blockKey, generationKey } from './list-fold.js';
|
|
21
|
+
import { createFolderChooser } from './folder-chooser.js';
|
|
22
|
+
|
|
23
|
+
const P = () => encodeURIComponent(state.projectId);
|
|
24
|
+
/** Replace a rendered block only when it actually differs: an unconditional swap on every asset event made the
|
|
25
|
+
* legend, the strip and the rail blink while the Keeper worked (owner, 2026-09-17). */
|
|
26
|
+
/** A row that is pressed like a button but holds a button of its own: Tab reaches it, Enter or Space presses it. */
|
|
27
|
+
const pressable = (onPress) => ({ role: 'button', tabindex: 0, onClick: onPress, onKeydown: (e) => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onPress(e); } } });
|
|
28
|
+
const swap = (current, next) => {
|
|
29
|
+
if (!current?.isConnected) return next;
|
|
30
|
+
if (visHtml(current.outerHTML) === visHtml(next.outerHTML)) return current;
|
|
31
|
+
current.replaceWith(next);
|
|
32
|
+
return next;
|
|
33
|
+
};
|
|
34
|
+
const validityTag = (v) => v === 'Current' ? null : v === 'Unjudged' ? h('span', { class: 'tag' }, 'Not yet judged') : h('span', { class: `tag ${v === 'Proposed' ? 'blue' : v === 'Replaced' || v === 'Abandoned' || v === 'Removed' ? 'strike' : ''}` }, v);
|
|
35
|
+
/** An object named in a change: one removed from the project's current version is struck through (Spec §2.1, §6.3). */
|
|
36
|
+
const REMOVED_TITLE = 'Removed from the project’s current version';
|
|
37
|
+
const namedObject = (x) => x.removed ? h('s', { title: REMOVED_TITLE }, x.label) : x.label;
|
|
38
|
+
// Owner acceptance sits next to progress and is its own fact (owner D41): `Done` says the result exists, this says
|
|
39
|
+
// whether the owner accepted it. A project with no acceptance step leaves it empty and nothing is shown.
|
|
40
|
+
const acceptanceTag = (n) => !n?.acceptance ? null : h('span', { class: `tag ${n.acceptance === 'Accepted' ? 'green' : ''}`, title: n.acceptance === 'Accepted' ? 'The owner accepted this work' : 'Not yet accepted by the owner; this does not change its progress' }, n.acceptance);
|
|
41
|
+
const basisTag = (b) => b === 'Inferred' ? h('span', { class: 'tag purple' }, 'Inferred') : null;
|
|
42
|
+
const assessmentTag = (a) => h('span', { class: `tag ${a === 'Holds' ? 'green' : a === 'Questioned' ? 'amber' : ''}` }, a);
|
|
43
|
+
|
|
44
|
+
function emptyState(title, text, action) {
|
|
45
|
+
return h('div', { class: 'empty' }, h('h2', {}, title), text ? h('p', {}, text) : null, action ?? null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Sources ───────────────────────────────────────────────────────────────
|
|
49
|
+
export async function openSource(sourceId) {
|
|
50
|
+
const s = await api(`/api/projects/${P()}/sources/${encodeURIComponent(sourceId)}`);
|
|
51
|
+
const lines = s.excerpt.split('\n');
|
|
52
|
+
const start = s.anchor.kind === 'file' ? s.anchor.lineStart : 1;
|
|
53
|
+
const notice = s.currentState === 'changed' ? 'This source changed after it was read; the excerpt below is what was read.'
|
|
54
|
+
: s.currentState === 'missing' ? 'This source is no longer available at its path; the excerpt below is what was read.' : null;
|
|
55
|
+
const quote = h('div', { class: 'source-quote' }, ...lines.map((line, i) => h('div', { class: 'src-line' }, h('span', { class: 'no' }, start + i), h('span', { class: 'tx' }, line))));
|
|
56
|
+
const copy = h('button', { class: 'btn small', onClick: async () => { try { await navigator.clipboard.writeText(s.excerpt); toast('Excerpt copied'); } catch { quote.setAttribute('tabindex', '0'); quote.focus(); const r = document.createRange(); r.selectNodeContents(quote); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); toast('Clipboard unavailable — the excerpt is selected, copy it with Ctrl+C'); } } }, 'Copy excerpt');
|
|
57
|
+
openDialog(s.title, [
|
|
58
|
+
h('dl', { class: 'kv' },
|
|
59
|
+
h('dt', {}, 'Kind'), h('dd', {}, s.anchor.kind),
|
|
60
|
+
h('dt', {}, 'Location'), h('dd', { class: 'mono' }, s.label),
|
|
61
|
+
h('dt', {}, 'Read at'), h('dd', {}, fmtTime(s.version.readAt)),
|
|
62
|
+
h('dt', {}, 'Version'), h('dd', { class: 'mono' }, s.version.commit ? `${s.version.commit.slice(0, 10)} · ` : '', s.version.fingerprint.slice(0, 23)),
|
|
63
|
+
h('dt', {}, 'Used as'), h('dd', {}, s.usedAs ?? 'Not yet judged'),
|
|
64
|
+
s.origin ? h('dt', {}, 'Origin') : null, s.origin ? h('dd', {}, `${s.origin.relation} · ${s.origin.path}`) : null,
|
|
65
|
+
s.availabilityNow ? h('dt', {}, 'Availability') : null, s.availabilityNow ? h('dd', {}, h('span', { class: 'tag amber' }, s.availabilityNow)) : null,
|
|
66
|
+
s.hasCredential ? h('dt', {}, 'Credential') : null, s.hasCredential ? h('dd', {}, 'This source contains a credential; its value is not shown or stored.') : null),
|
|
67
|
+
notice ? h('p', { class: 'muted' }, notice) : null,
|
|
68
|
+
quote,
|
|
69
|
+
h('p', { class: 'faint' }, 'Excerpts keep the original text. Interpretations and relations built on them can be corrected; the original is never changed.'),
|
|
70
|
+
h('div', { class: 'dialog-foot' }, copy),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The process view opens originals through this module (k-process.js is imported here and does not import it back).
|
|
75
|
+
kProcess.setOpeners({ openSource: (id) => openSource(id).catch((e) => toast(`${id}: ${e.message}`)) });
|
|
76
|
+
|
|
77
|
+
// ── Sources (D34: shown inside Project scope, under the scope items) ─────
|
|
78
|
+
const sources = {
|
|
79
|
+
async render(main, { scopeItems = [], levels = [] } = {}) {
|
|
80
|
+
const q = h('input', { class: 'input', placeholder: 'Filter by file, section or content', value: state.filters.sourceQuery ?? '' });
|
|
81
|
+
const usedAs = h('select', { class: 'input' }, h('option', { value: '' }, 'Used as: any'), ...['Not yet judged', 'Purpose', 'Decision', 'Requirement', 'Design', 'Plan', 'Task', 'Status', 'Code', 'Test', 'QC', 'Session', 'Run result', 'Other'].map((v) => h('option', { value: v, selected: state.filters.usedAs === v }, v)));
|
|
82
|
+
const avail = h('select', { class: 'input' }, h('option', { value: '' }, 'Availability: any'), ...['Changed since read', 'Moved', 'No longer available'].map((v) => h('option', { value: v, selected: state.filters.availability === v }, v)));
|
|
83
|
+
const origin = h('select', { class: 'input' }, h('option', { value: '' }, 'Origin: any scope item'), ...scopeItems.map((i) => h('option', { value: i.id, selected: state.filters.sourceOrigin === i.id }, `${i.relation === 'Main project' ? 'This project' : i.relation} · ${i.path}`)));
|
|
84
|
+
// §6.7: also by organizing level; the level names come from the vocabulary the server sends, not from the page.
|
|
85
|
+
const level = levels.length ? h('select', { class: 'input' }, h('option', { value: '' }, 'Level: any'), ...levels.map((v) => h('option', { value: v, selected: state.filters.sourceLevel === v }, v))) : null;
|
|
86
|
+
const summary = h('div', { class: 'muted' });
|
|
87
|
+
const table = h('div');
|
|
88
|
+
const load = async () => {
|
|
89
|
+
state.filters.sourceQuery = q.value; state.filters.usedAs = usedAs.value; state.filters.availability = avail.value; state.filters.sourceOrigin = origin.value;
|
|
90
|
+
state.filters.sourceLevel = level?.value ?? '';
|
|
91
|
+
const params = new URLSearchParams({ q: q.value, usedAs: usedAs.value, availability: avail.value });
|
|
92
|
+
if (level?.value) params.set('level', level.value);
|
|
93
|
+
let list = await api(`/api/projects/${P()}/sources?${params}`);
|
|
94
|
+
if (origin.value) list = list.filter((x) => x.scopeItemId === origin.value);
|
|
95
|
+
clear(table);
|
|
96
|
+
summary.textContent = list.length ? `${list.length} source(s)${origin.value || q.value || usedAs.value || avail.value || level?.value ? ' match' : ''}` : '';
|
|
97
|
+
if (list.length === 0) { append(table, h('div', { class: 'empty' }, h('h2', {}, 'No sources read yet'), h('p', {}, 'Sources appear as the Keeper reads files, sessions and commits inside the project scope.'))); return; }
|
|
98
|
+
append(table, h('table', { class: 'list' },
|
|
99
|
+
h('thead', {}, h('tr', {}, h('th', {}, 'Source'), h('th', {}, 'Used as'), h('th', {}, 'Origin'))),
|
|
100
|
+
h('tbody', {}, ...list.map((x) => h('tr', { class: `clickable${state.selection?.id === x.id ? ' sel' : ''}`, onClick: () => openSource(x.id) },
|
|
101
|
+
h('td', {}, h('div', {}, x.title), h('small', { class: 'mono' }, x.label), x.ids.length ? h('div', {}, ...x.ids.slice(0, 8).map((id) => h('span', { class: 'tag' }, id))) : null),
|
|
102
|
+
h('td', {}, x.usedAs ?? h('span', { class: 'faint' }, 'Not yet judged')),
|
|
103
|
+
h('td', {}, h('div', {}, originLabel(x.scopeItemId)), x.availability ? h('span', { class: 'tag amber' }, x.availability) : null, x.hasCredential ? h('span', { class: 'tag red' }, 'credential') : null))))));
|
|
104
|
+
};
|
|
105
|
+
let timer;
|
|
106
|
+
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 150); });
|
|
107
|
+
usedAs.addEventListener('change', load); avail.addEventListener('change', load); origin.addEventListener('change', load);
|
|
108
|
+
level?.addEventListener('change', load);
|
|
109
|
+
append(main,
|
|
110
|
+
h('p', { class: 'sub' }, 'Every piece of material the Keeper has read from the scope above, with where it came from and how it was used. Click a row to read the original excerpt.'),
|
|
111
|
+
h('div', { class: 'row wrap' }, h('div', { class: 'grow' }, q), origin, level, usedAs, avail),
|
|
112
|
+
summary, table);
|
|
113
|
+
await load();
|
|
114
|
+
sources._origin = origin; sources._reload = load;
|
|
115
|
+
},
|
|
116
|
+
/** Called from a scope item: show only that item's sources and scroll to them. */
|
|
117
|
+
showOrigin(id) { if (sources._origin) { sources._origin.value = id; sources._reload?.(); document.querySelector('#sources')?.scrollIntoView({ behavior: 'smooth' }); } },
|
|
118
|
+
};
|
|
119
|
+
function originLabel(scopeItemId) {
|
|
120
|
+
const item = state.project?.scope?.find((i) => i.id === scopeItemId);
|
|
121
|
+
return item ? `${item.relation === 'Main project' ? 'This project' : item.relation}${item.copyOf ? ` (copy of ${item.copyOf})` : ''}` : '—';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── How this project works, and the organizing plan (Spec §1.15, §3.7, §6.7; CKC-21 AC-11) ──
|
|
125
|
+
// Every word comes from the server (workbench-content.ts); the owner confirms or corrects through Ask Keeper, and
|
|
126
|
+
// the answer is recorded as their statement.
|
|
127
|
+
const PLAN_CONTEXT = { kind: 'plan', id: 'organizing-plan', label: 'Organizing plan and focus' };
|
|
128
|
+
const scrollToRule = (id) => { const el = document.getElementById(`rule-${id}`); if (!el) return; el.closest('details')?.setAttribute('open', ''); el.scrollIntoView({ block: 'center' }); el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); };
|
|
129
|
+
const sourceButtons = (list) => list.map((s) => h('button', { class: 'text-btn', title: s.label, onClick: () => openSource(s.id) }, s.title));
|
|
130
|
+
|
|
131
|
+
function ruleRow(r) {
|
|
132
|
+
const c = r.confirmation;
|
|
133
|
+
const state = c.state === 'Waiting for the owner to confirm' ? [h('span', { class: 'tag purple' }, 'Inferred'), ' ', h('span', { class: 'tag amber' }, c.state)]
|
|
134
|
+
: c.state === 'Confirmed by the owner' ? [h('span', { class: 'tag green' }, c.state), c.at ? h('small', { class: 'faint' }, ` ${fmtTime(c.at)}`) : null]
|
|
135
|
+
: [h('span', { class: 'tag' }, c.state)];
|
|
136
|
+
return h('div', { class: 'rule-row', id: `rule-${r.id}` },
|
|
137
|
+
h('div', { class: 'row spread' },
|
|
138
|
+
h('div', {}, h('b', {}, r.summary), r.category ? [' ', h('span', { class: 'tag' }, r.category)] : null, r.ownerSystem ? [' ', h('span', { class: 'tag blue', title: 'From the way of working the owner summarized' }, r.ownerSystem)] : null),
|
|
139
|
+
h('button', { class: 'btn small', title: 'Confirm or correct this rule in the conversation; your answer is recorded as your statement', onClick: () => toggleKeeper(true, { kind: 'rule', id: r.id, label: r.summary }) }, 'Ask Keeper')),
|
|
140
|
+
h('div', { class: 'row wrap' }, ...state, validityTag(r.validity)),
|
|
141
|
+
r.excerpt ? h('blockquote', { class: 'quote' }, r.excerpt) : null,
|
|
142
|
+
c.quote ? h('div', { class: 'muted' }, '“', c.quote, '” ', c.source ? h('button', { class: 'text-btn', onClick: () => openSource(c.source.id) }, 'source') : null) : null,
|
|
143
|
+
r.appliesTo.length ? h('div', { class: 'faint' }, 'Applies to: ', r.appliesTo.join(', ')) : null,
|
|
144
|
+
r.sources.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Source'), ...sourceButtons(r.sources)) : null,
|
|
145
|
+
...r.differsInPractice.map((d) => h('div', {}, h('span', { class: 'tag amber' }, 'Differs in practice'), ' ', d.text, ' ', ...sourceButtons(d.sources))),
|
|
146
|
+
...r.marks.map((m) => h('div', {}, h('span', { class: `tag ${m.kind === 'Decided without owner' ? 'red' : 'amber'}` }, `⚑ ${m.label}`), ' ', h('span', { class: m.inForce ? 'muted' : 'faint' }, m.detail))));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What the project's rules settle directly, and which rule or plan decided it (Spec §1.11, §3.7): shown with the
|
|
151
|
+
* coverage's organizing levels and in the Keeper view's takeover progress. `onRule` opens the rule — the rule rows
|
|
152
|
+
* live in Project scope, so elsewhere it navigates there first.
|
|
153
|
+
*/
|
|
154
|
+
function settledByRuleBlock(s, onRule) {
|
|
155
|
+
if (!s || !s.byRule?.length) return null;
|
|
156
|
+
const via = { plan: 'the organizing plan names it under this rule', sources: 'the rule set how its sources are used', away: 'the rule took it out of what is organized' };
|
|
157
|
+
return h('div', {}, h('h4', {}, `Settled by rule (${s.materials})`),
|
|
158
|
+
h('div', { class: 'faint' }, 'Material the project’s own rules judge directly, without close reading:'),
|
|
159
|
+
...s.byRule.map((r) => h('div', {}, h('button', { class: 'text-btn', title: 'Open the rule in Project scope', onClick: () => onRule(r.ruleId) }, r.summary),
|
|
160
|
+
h('span', { class: 'faint' }, ` — ${r.count} (${r.names.join(', ')}${r.count > r.names.length ? ', …' : ''}) · ${via[r.via] ?? r.via}`))));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rulesSection(how) { const group = (g) => h('div', { class: 'rule-group' }, h('h4', {}, g.group, h('small', { class: 'faint' }, ` ${g.rules.length}`)),
|
|
164
|
+
...g.rules.map(ruleRow),
|
|
165
|
+
g.replaced.length ? h('details', { class: 'fold' }, h('summary', {}, `Replaced (${g.replaced.length})`), h('div', { class: 'content stack' }, ...g.replaced.map((r) => h('div', { class: 'rule-row replaced', id: `rule-${r.id}` },
|
|
166
|
+
h('s', {}, r.summary), r.replacedBy ? [' → ', h('button', { class: 'text-btn', title: 'The rule that replaced it', onClick: () => scrollToRule(r.replacedBy.id) }, r.replacedBy.summary)] : null,
|
|
167
|
+
r.sources.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Source'), ...sourceButtons(r.sources)) : null)))) : null);
|
|
168
|
+
const counts = how.counts;
|
|
169
|
+
return h('div', { class: 'section', id: 'how-this-project-works' },
|
|
170
|
+
h('header', {}, 'How this project works', h('span', { class: 'faint', style: { fontWeight: 400 } }, counts.rules ? ` ${counts.rules} rule${counts.rules === 1 ? '' : 's'}${counts.waiting ? ` · ${counts.waiting} waiting for the owner to confirm` : ''}` : '')),
|
|
171
|
+
h('div', { class: 'content stack' },
|
|
172
|
+
how.groups.length === 0 ? h('div', { class: 'faint' }, 'No rule of this project has been recorded yet. Orientation looks for them first.') : null,
|
|
173
|
+
how.ownerSystems.length ? h('div', { class: 'muted' }, 'From the way of working the owner summarized: ', how.ownerSystems.join(', '), '. Where practice differs from it, the rule says so.')
|
|
174
|
+
: how.groups.length ? h('div', { class: 'muted' }, 'Found by the Keeper in the project’s own records. What it inferred is marked and waits for the owner to confirm.') : null,
|
|
175
|
+
...how.groups.map(group)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function planSection(plan) {
|
|
179
|
+
const ask = h('button', { class: 'btn small', title: 'Correct the plan in the conversation; your words are kept with what the plan said before', onClick: () => toggleKeeper(true, PLAN_CONTEXT) }, 'Ask Keeper');
|
|
180
|
+
const head = h('header', {}, 'Organizing plan and focus', h('span', { class: 'faint', style: { fontWeight: 400 } }, ' what the rules settle, what is read closely, where the focus is'), plan ? ask : null);
|
|
181
|
+
if (!plan) return h('div', { class: 'section', id: 'organizing-plan' }, head, h('div', { class: 'content faint' }, 'No organizing plan has been recorded yet. Orientation writes it once it has found the project’s rules; you can also tell the Keeper what to read closely, where to focus and in what order.'));
|
|
182
|
+
const targets = (t) => t.length ? h('span', { class: 'mono faint' }, ` ${t.join(', ')}`) : null;
|
|
183
|
+
const previous = (p) => [
|
|
184
|
+
p.byRule.length ? h('div', {}, h('b', {}, 'Settled by the project’s rules: '), p.byRule.map((e) => `${e.what} → ${e.treatment}`).join('; ')) : null,
|
|
185
|
+
p.readClosely.length ? h('div', {}, h('b', {}, 'Read closely: '), p.readClosely.map((e) => e.what).join('; ')) : null,
|
|
186
|
+
p.focus.length ? h('div', {}, h('b', {}, 'Focus: '), p.focus.map((e) => e.what).join('; ')) : null,
|
|
187
|
+
p.order.length ? h('div', {}, h('b', {}, 'Order: '), p.order.join(' → ')) : null,
|
|
188
|
+
];
|
|
189
|
+
return h('div', { class: 'section', id: 'organizing-plan' }, head,
|
|
190
|
+
h('div', { class: 'content stack' },
|
|
191
|
+
plan.byRule.length ? h('div', {}, h('h4', {}, 'Settled by the project’s rules'), ...plan.byRule.map((e) => h('div', {}, h('b', {}, e.what), targets(e.targets), ' → ', h('span', { class: 'tag' }, e.treatment), ' ', h('button', { class: 'text-btn', title: 'The rule that settles it', onClick: () => scrollToRule(e.rule.id) }, e.rule.summary)))) : null,
|
|
192
|
+
plan.readClosely.length ? h('div', {}, h('h4', {}, 'Read closely'), ...plan.readClosely.map((e) => h('div', {}, h('b', {}, e.what), targets(e.targets), e.why ? h('div', { class: 'muted' }, e.why) : null))) : null,
|
|
193
|
+
plan.focus.length ? h('div', {}, h('h4', {}, 'Focus'), ...plan.focus.map((f) => h('div', {}, h('b', {}, f.what), f.why ? h('div', { class: 'muted' }, f.why) : null, f.sources.length ? h('div', { class: 'row wrap' }, ...sourceButtons(f.sources)) : null))) : null,
|
|
194
|
+
plan.order.length ? h('div', {}, h('h4', {}, 'Order'), h('ol', {}, ...plan.order.map((o) => h('li', {}, o)))) : null,
|
|
195
|
+
plan.corrections.length ? h('div', {}, h('h4', {}, 'Corrected by the owner'), ...plan.corrections.map((c) => h('div', { class: 'stack' },
|
|
196
|
+
h('div', {}, h('small', { class: 'faint' }, fmtTime(c.at)), ' “', c.quote, '” ', h('button', { class: 'text-btn', onClick: () => openSource(c.source.id) }, 'source')),
|
|
197
|
+
h('div', { class: 'muted' }, c.changed),
|
|
198
|
+
h('details', { class: 'fold' }, h('summary', {}, 'What the plan said before'), h('div', { class: 'content' }, ...previous(c.previous)))))) : null,
|
|
199
|
+
h('div', { class: 'faint' }, `As of ${fmtTime(plan.asOf)}`)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Project scope ─────────────────────────────────────────────────────────
|
|
203
|
+
const scope = {
|
|
204
|
+
async render(main) {
|
|
205
|
+
const d = await api(`/api/projects/${P()}/scope`);
|
|
206
|
+
const p = state.project;
|
|
207
|
+
// read counts per scope item, so each item says what was read from it (D34)
|
|
208
|
+
const bySource = new Map();
|
|
209
|
+
try { for (const x of await api(`/api/projects/${P()}/sources?q=&usedAs=&availability=`)) { const c = bySource.get(x.scopeItemId) ?? { read: 0, unjudged: 0 }; c.read++; if (!x.usedAs) c.unjudged++; bySource.set(x.scopeItemId, c); } } catch { /* counts are a convenience */ }
|
|
210
|
+
const readLine = (item) => { const c = bySource.get(item.id); return c ? h('div', { class: 'faint' }, h('button', { class: 'text-btn', title: 'Show the sources read from this item', onClick: () => sources.showOrigin(item.id) }, `${c.read} source${c.read === 1 ? '' : 's'} read`), c.unjudged ? ` · ${c.unjudged} not yet judged` : '') : h('div', { class: 'faint' }, 'nothing read yet'); };
|
|
211
|
+
const included = d.scope.filter((i) => i.relation !== 'Excluded');
|
|
212
|
+
const excluded = d.scope.filter((i) => i.relation === 'Excluded');
|
|
213
|
+
const reasonLink = (item) => item.reasonSourceIds.length ? h('button', { class: 'text-btn', onClick: () => openSource(item.reasonSourceIds[0]) }, 'source') : null;
|
|
214
|
+
const mergedTag = (w) => w.merged === null ? null : h('span', { class: `tag ${w.merged ? 'green' : 'amber'}` }, w.merged ? 'merged' : 'not merged');
|
|
215
|
+
const itemRow = (view) => {
|
|
216
|
+
const item = view.item ?? view; // the sectioned view data, or a bare item for an older server
|
|
217
|
+
const worktree = view.worktree ?? (item.worktree ? { sentence: '', merged: item.worktree.merged ?? null } : null);
|
|
218
|
+
return h('div', { class: `scope-item${item.relation === 'Excluded' ? ' excluded' : ''}` },
|
|
219
|
+
h('div', {}, h('span', { class: 'tag' }, item.category), item.readOnly ? h('span', { class: 'tag blue' }, 'read-only') : null),
|
|
220
|
+
h('div', {}, item.relation, item.copyOf ? h('div', { class: 'faint' }, `copy of ${item.copyOf}`) : null, item.worktreeOf ? h('div', { class: 'faint' }, `of ${item.worktreeOf}`) : null, item.versionControl === 'none' ? h('div', { class: 'faint' }, 'no version control') : null),
|
|
221
|
+
h('div', {}, h('div', { class: 'path mono' }, item.path), h('div', { class: 'reason' }, view.reason ?? item.reason, ' ', reasonLink(item)), item.missing ? h('div', {}, h('span', { class: 'tag red' }, item.missing.reason)) : null, readLine(item),
|
|
222
|
+
worktree ? h('div', { class: 'faint' }, mergedTag(worktree), ' ', worktree.sentence || `${item.worktree.files} files measured against the trunk`) : null,
|
|
223
|
+
view.ignored ? h('div', { class: 'faint' }, 'Left out by ', h('span', { class: 'mono' }, view.ignored.rule), ' · ', view.ignored.sentence) : null,
|
|
224
|
+
view.classification ? h('div', { class: 'faint' }, view.classification.sentence, ' ', basisTag(view.classification.basis)) : null,
|
|
225
|
+
view.covers?.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Covered by'), ...view.covers.map((c) => h('button', { class: 'text-btn', title: c.excerpt ?? c.summary, onClick: () => scrollToRule(c.ruleId) }, c.summary))) : null),
|
|
226
|
+
h('div', {}, h('button', { class: 'btn small', title: 'Remove from scope', onClick: async () => { await api(`/api/projects/${P()}/scope/items/${encodeURIComponent(item.id)}`, { method: 'DELETE' }); toast('Scope changed — the Keeper re-evaluates the affected part'); await refreshProject(); } }, '×')));
|
|
227
|
+
};
|
|
228
|
+
const listView = d.scopeView ?? null;
|
|
229
|
+
const scopeSection = (id, title, items, note) => h('div', { class: 'section', id }, h('header', {}, title, note ? h('span', { class: 'faint', style: { fontWeight: 400 } }, ` ${note}`) : null), h('div', {}, ...items.map(itemRow)));
|
|
230
|
+
const sourcesSection = h('div', { class: 'section', id: 'sources' }, h('header', {}, `Sources (${[...bySource.values()].reduce((a, c) => a + c.read, 0)})`), h('div', { class: 'content' }));
|
|
231
|
+
const followUpSection = h('div', { class: 'section', id: 'follow-up' }, h('header', {}, 'Change follow-up', h('span', { class: 'faint', style: { fontWeight: 400 } }, ' what moved upstream, and whether what depends on it followed')), h('div', { class: 'content' }, h('div', { class: 'faint' }, 'Loading…')));
|
|
232
|
+
const cov = d.coverage;
|
|
233
|
+
const covRows = cov.scopes.map((s) => h('tr', {}, h('td', {}, s.label), h('td', {}, h('span', { class: `tag ${s.coverage === 'Up to date' ? 'green' : 'amber'}` }, s.coverage)), h('td', {}, s.asOf ? `${fmtTime(s.asOf)}${s.commit ? ` @ ${s.commit.slice(0, 8)}` : ''}` : '—'), h('td', {}, s.pending.length ? s.pending.slice(0, 6).map((x) => h('div', { class: 'mono' }, x.label)) : '—', s.pending.length > 6 ? h('small', {}, `+${s.pending.length - 6} more`) : null), h('td', {}, s.organizing.length ? s.organizing.map((x) => h('div', { class: 'mono' }, x.label)) : '—'), h('td', {}, s.failed.length ? failuresOf({ scopes: [s] }).map((f) => h('div', {}, h('span', { class: 'mono' }, f.ref), h('small', {}, ` ${reasonText(f.reason)}`))) : '—'), h('td', {}, s.lastRelookAt ? fmtRel(s.lastRelookAt) : '—')));
|
|
234
|
+
const addItem = () => {
|
|
235
|
+
const path = h('input', { class: 'input', placeholder: 'D:\\path' });
|
|
236
|
+
// The choices come from the vocabularies the server sends, not from a list written into the page.
|
|
237
|
+
const category = h('select', { class: 'input' }, ...(d.scopeVocab?.categories ?? ['Directory', 'Repository', 'Worktree', 'Session source']).map((v) => h('option', { value: v }, v)));
|
|
238
|
+
const relation = h('select', { class: 'input' }, ...(d.scopeVocab?.relations ?? ['Main project', 'Worktree of main repo', 'Copy of another project', 'Nested repository', 'Experiment', 'Third-party material', 'Generated', 'Excluded', 'Session source']).map((v) => h('option', { value: v }, v)));
|
|
239
|
+
const reason = h('input', { class: 'input', placeholder: 'Why it belongs (or does not)' });
|
|
240
|
+
const err = h('small');
|
|
241
|
+
// `Browse…` walks to the folder instead of typing its path (folder-chooser.js); the field stays a field.
|
|
242
|
+
const chooser = createFolderChooser({
|
|
243
|
+
// A folder already in scope is marked and can still be chosen: an item added for it again replaces the one there.
|
|
244
|
+
h, api, single: true, chooseAdded: true, addLabel: 'Use this folder', addedWord: 'in scope',
|
|
245
|
+
added: () => d.scope.map((i) => i.path), start: () => path.value.trim() || state.project?.locations?.[0] || '',
|
|
246
|
+
onAdd: (chosen) => { path.value = chosen; err.textContent = ''; },
|
|
247
|
+
});
|
|
248
|
+
openDialog('Add scope item', [h('div', { class: 'field' }, h('div', { class: 'row spread' }, h('label', {}, 'Path'), chooser.button), path, chooser.panel), h('div', { class: 'row' }, h('div', { class: 'field grow' }, h('label', {}, 'Category'), category), h('div', { class: 'field grow' }, h('label', {}, 'Relation'), relation)), h('div', { class: 'field' }, h('label', {}, 'Reason'), reason), err,
|
|
249
|
+
h('div', { class: 'dialog-foot' }, h('button', { class: 'btn primary', onClick: async () => { try { await api(`/api/projects/${P()}/scope/items`, { method: 'POST', body: { path: path.value, category: category.value, relation: relation.value, reason: reason.value } }); document.querySelector('#dialog').close(); await refreshProject(); } catch (e) { err.textContent = e.message; } } }, 'Add'))]);
|
|
250
|
+
};
|
|
251
|
+
append(main,
|
|
252
|
+
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Project scope'), h('p', { class: 'sub' }, 'What the Keeper treats as this project, with the reason for each item. Understanding state: ', h('span', { class: cov.state === 'Takeover complete' ? 'tag green' : 'tag amber' }, cov.state), cov.takeover && cov.state !== 'Not organized yet' ? h('span', { class: 'faint' }, ' · takeover depth ', h('button', { class: 'text-btn', onClick: () => navigate(state.projectId, 'keeper', 'takeover') }, cov.takeover.depth)) : null, d.lastScopedAt ? h('span', { class: 'faint' }, ` · boundary drawn ${fmtRel(d.lastScopedAt)}`) : null, d.roles.length ? h('span', { class: 'faint' }, ` · roles recognised: ${d.roles.join(', ')}`) : h('span', { class: 'faint' }, ' · no role system recognised (that is not a gap)'))),
|
|
253
|
+
h('div', { class: 'row' }, h('button', { class: 'btn small', onClick: addItem }, 'Add item'), h('button', { class: 'btn small', onClick: async () => { await api(`/api/projects/${P()}/scope/rescan`, { method: 'POST' }); toast('Boundary re-drawn'); await refreshProject(); } }, 'Rescan'), h('button', { class: 'btn small', onClick: () => document.querySelector('#sources')?.scrollIntoView({ behavior: 'smooth' }) }, 'Sources'))),
|
|
254
|
+
d.questions.filter((q) => !q.answer).length ? h('div', { class: 'section' }, h('header', {}, 'Questions for you'), h('div', { class: 'content' }, ...d.questions.filter((q) => !q.answer).map((q) => {
|
|
255
|
+
const answer = h('input', { class: 'input', placeholder: q.options.join(' / ') });
|
|
256
|
+
return h('div', { class: 'stack' }, h('b', {}, q.question), h('div', { class: 'muted' }, 'Why it matters: ', q.whyItMatters), q.clues.length ? h('ul', {}, ...q.clues.map((c) => h('li', {}, c))) : null, h('div', { class: 'row' }, h('div', { class: 'grow' }, answer), h('button', { class: 'btn small', onClick: async () => { await api(`/api/projects/${P()}/scope/questions/${encodeURIComponent(q.id)}`, { method: 'POST', body: { answer: answer.value } }); await refreshProject(); } }, 'Answer')));
|
|
257
|
+
}))) : null,
|
|
258
|
+
listView
|
|
259
|
+
? [
|
|
260
|
+
scopeSection('in-scope', `In scope (${listView.inScope.length})`, listView.inScope),
|
|
261
|
+
listView.thirdParty.length ? scopeSection('third-party', `Third-party material (${listView.thirdParty.length})`, listView.thirdParty, 'what the project uses, not its own intent; its documents are read as Reference only') : null,
|
|
262
|
+
listView.generated.length ? scopeSection('generated', `Generated (${listView.generated.length})`, listView.generated, 'build output, caches and exports — listed, not organized') : null,
|
|
263
|
+
listView.ignoredDocuments.length ? scopeSection('ignored-documents', `Ignored, holding documents (${listView.ignoredDocuments.length})`, listView.ignoredDocuments, 'the ignore rules leave these out, but they hold documents that may carry intent — the owner decides whether to include them') : null,
|
|
264
|
+
listView.excluded.length ? scopeSection('excluded', `Excluded (${listView.excluded.length})`, listView.excluded) : null,
|
|
265
|
+
]
|
|
266
|
+
: [
|
|
267
|
+
h('div', { class: 'section' }, h('header', {}, `In scope (${included.length})`), h('div', {}, ...included.map(itemRow))),
|
|
268
|
+
excluded.length ? h('div', { class: 'section' }, h('header', {}, `Excluded (${excluded.length})`), h('div', {}, ...excluded.map(itemRow))) : null,
|
|
269
|
+
],
|
|
270
|
+
planSection(d.organizingPlan ?? null),
|
|
271
|
+
h('div', { class: 'section', id: 'coverage' }, h('header', {}, 'Coverage'), h('div', { class: 'content' },
|
|
272
|
+
h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Scope'), h('th', {}, 'Coverage'), h('th', {}, 'As of'), h('th', {}, 'Pending'), h('th', {}, 'Organizing'), h('th', {}, 'Failed'), h('th', {}, 'Last re-look'))), h('tbody', {}, ...covRows)),
|
|
273
|
+
cov.missingSourceKinds.length ? h('div', {}, h('h4', {}, 'Missing source kinds'), ...cov.missingSourceKinds.map((m) => h('div', {}, h('b', {}, m.kind), ' — ', m.reason))) : h('div', { class: 'faint' }, 'No missing source kinds'),
|
|
274
|
+
Object.keys(cov.processedByKind).length ? h('div', { class: 'faint' }, 'Processed: ', Object.entries(cov.processedByKind).map(([k, v]) => `${k} ${v}`).join(' · '), ' · Pending: ', Object.entries(cov.pendingByKind).map(([k, v]) => `${k} ${v}`).join(' · ') || 'none') : null,
|
|
275
|
+
cov.takeover ? h('div', {}, h('h4', {}, 'Organizing levels'), h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Level'), h('th', {}, 'Materials'), h('th', {}, 'By kind'))), h('tbody', {}, ...cov.takeover.levels.filter((l) => l.materials > 0).map((l) => h('tr', {}, h('td', {}, l.level), h('td', {}, l.materials), h('td', { class: 'faint' }, kindsText(l.byKind)))))), partReadsLine(cov.takeover.readInPart), cov.takeover.remaining ? h('div', { class: 'faint' }, `Remaining under ${cov.takeover.depth}: ${cov.takeover.remaining.materials} materials, about ${fmtMinutes(cov.takeover.remaining.minutes)}.`) : cov.takeover.historyNotOrganized ? h('div', { class: 'faint' }, `${cov.takeover.historyNotOrganized} older materials are not organized (depth ${cov.takeover.depth}); they are indexed and read on demand.`) : null) : null,
|
|
276
|
+
settledByRuleBlock(d.settledByRule, scrollToRule))),
|
|
277
|
+
d.howThisProjectWorks ? rulesSection(d.howThisProjectWorks) : null,
|
|
278
|
+
h('div', { class: 'section' }, h('header', {}, 'Standing authorizations'), h('div', { class: 'content' }, d.authorizations.length ? d.authorizations.map((a) => h('div', { class: 'row spread' }, h('div', {}, h('b', {}, a.scope), h('div', { class: 'muted' }, `“${a.quote}” · ${fmtTime(a.at)} `, a.sourceId ? h('button', { class: 'text-btn', onClick: () => openSource(a.sourceId) }, 'source') : null)), h('button', { class: 'btn small danger', onClick: async () => { await api(`/api/projects/${P()}/authorizations/${encodeURIComponent(a.id)}/revoke`, { method: 'POST' }); await refreshProject(); } }, 'Revoke'))) : h('div', { class: 'faint' }, 'None. A standing authorization is recorded when you give one in the conversation, e.g. “fix stale document references directly”.'))),
|
|
279
|
+
h('div', { class: 'section' }, h('header', {}, 'Files ProjectKeeper produces for this project'), h('div', { class: 'content' }, ...d.keeperFiles.map((f) => h('div', {}, h('span', { class: 'mono' }, f.path), h('div', { class: 'muted' }, f.kind, ' — can be removed without affecting the project'))))),
|
|
280
|
+
d.toolchain?.entries?.length ? h('div', { class: 'section', id: 'toolchain' }, h('header', {}, 'Toolchain', h('span', { class: 'faint', style: { fontWeight: 400 } }, ' what the project’s configuration points at')), h('div', { class: 'content' },
|
|
281
|
+
h('div', { class: 'muted' }, d.toolchain.note, ' ', h('button', { class: 'text-btn', title: 'Correct a wrong entry in the conversation', onClick: () => toggleKeeper(true, { kind: 'path', id: 'toolchain', label: 'Toolchain locations' }) }, 'Ask Keeper')),
|
|
282
|
+
...d.toolchain.entries.map((t) => h('div', { class: 'stack' },
|
|
283
|
+
h('div', { class: 'row wrap', style: { alignItems: 'baseline', gap: '8px' } }, h('span', { class: 'mono' }, t.path), t.used ? h('span', { class: 'tag green', title: 'An allowed read root for the Keeper' }, 'used') : h('span', { class: 'tag amber' }, 'listed, not used')),
|
|
284
|
+
h('div', { class: 'faint' }, t.reason, t.used ? '' : ` · ${t.notUsedReason}`))))) : null,
|
|
285
|
+
followUpSection,
|
|
286
|
+
sourcesSection,
|
|
287
|
+
// The ledger's coverage, layers, generations and the project folder (§6.7), by AS's renderer, after the rest.
|
|
288
|
+
h('div', { class: 'section', id: 'k-scope' }, h('header', {}, 'Project ledger'), h('div', { class: 'content', id: 'k-scope-body' }, h('div', { class: 'faint' }, 'Loading…'))),
|
|
289
|
+
);
|
|
290
|
+
followUpBlock(followUpSection.querySelector('.content'));
|
|
291
|
+
await sources.render(sourcesSection.querySelector('.content'), { scopeItems: d.scope, levels: d.scopeVocab?.organizingLevels ?? [] });
|
|
292
|
+
const scopeKBody = main.querySelector('#k-scope-body');
|
|
293
|
+
kData.getScopeK(state.projectId).then((scopeK) => {
|
|
294
|
+
if (!scopeKBody.isConnected) return;
|
|
295
|
+
clear(scopeKBody);
|
|
296
|
+
if (!scopeK) { scopeKBody.append(h('div', { class: 'faint' }, kData.noData())); return; }
|
|
297
|
+
renderScopeK(scopeKBody, scopeK, { navigate: (view, rest) => navigate(state.projectId, view, rest) });
|
|
298
|
+
}).catch(() => { if (scopeKBody.isConnected) { clear(scopeKBody); scopeKBody.append(h('div', { class: 'faint' }, kData.noData())); } });
|
|
299
|
+
if (state.routeRest === 'coverage') document.querySelector('#coverage')?.scrollIntoView();
|
|
300
|
+
if (state.routeRest === 'sources') document.querySelector('#sources')?.scrollIntoView();
|
|
301
|
+
if (state.routeRest === 'rules') document.querySelector('#how-this-project-works')?.scrollIntoView();
|
|
302
|
+
if (state.routeRest === 'plan') document.querySelector('#organizing-plan')?.scrollIntoView();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// ── Takeover depth (Spec §3.7, D36) — the same choice on the depth note, in Project scope and in the Keeper view ──
|
|
307
|
+
const DEPTH_NOTE_ID = 'note_takeover-depth';
|
|
308
|
+
const fmtMinutes = (m) => (m == null ? '—' : m < 1 ? 'under a minute' : m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} h`);
|
|
309
|
+
const kindsText = (byKind) => Object.entries(byKind || {}).map(([k, v]) => `${v} ${k}${v === 1 ? '' : 's'}`).join(', ') || '—';
|
|
310
|
+
const JUDGED = ['Updated', 'Still on old understanding', 'Reusable as is'];
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Change follow-up coverage (Spec §5.5, D48): how many downstream objects are still waiting to be judged, where they
|
|
314
|
+
* are, and what the current (or last) round judged. Counted from the propagation entries themselves, so it agrees
|
|
315
|
+
* with the Change log.
|
|
316
|
+
*/
|
|
317
|
+
async function followUpBlock(el) {
|
|
318
|
+
let g;
|
|
319
|
+
try { g = await api(`/api/projects/${P()}/graph`); } catch (e) { el.replaceChildren(h('div', { class: 'faint' }, `Could not load: ${e.message}`)); return; }
|
|
320
|
+
const nodes = new Map(g.nodes.map((n) => [n.id, n]));
|
|
321
|
+
const areaOf = (id) => { const n = nodes.get(id); return n ? (n.category === 'Area' ? n.id : n.areaId ?? '') : ''; };
|
|
322
|
+
const areaName = (id) => (id ? nodes.get(id)?.label ?? id : 'No area');
|
|
323
|
+
const p = state.project ?? {};
|
|
324
|
+
const roundFrom = p.followUpAt ?? null;
|
|
325
|
+
const roundOpen = roundFrom && (!p.lastRoundAt || p.lastRoundAt < roundFrom);
|
|
326
|
+
const roundTo = roundOpen ? null : p.lastRoundAt ?? null;
|
|
327
|
+
const inRound = (e) => roundFrom && e.state !== 'Not yet checked' && e.updatedAt >= roundFrom && (!roundTo || e.updatedAt <= roundTo);
|
|
328
|
+
const totals = Object.fromEntries([...JUDGED, 'Not yet checked'].map((k) => [k, 0]));
|
|
329
|
+
const rows = new Map();
|
|
330
|
+
const row = (a) => rows.get(a) ?? (rows.set(a, { left: 0, changes: new Set(), round: Object.fromEntries(JUDGED.map((k) => [k, 0])) }), rows.get(a));
|
|
331
|
+
for (const c of g.changes) for (const e of c.propagation) {
|
|
332
|
+
totals[e.state] = (totals[e.state] ?? 0) + 1;
|
|
333
|
+
const r = row(areaOf(e.nodeId));
|
|
334
|
+
if (e.state === 'Not yet checked') { r.left++; r.changes.add(c.id); }
|
|
335
|
+
else if (inRound(e)) r.round[e.state]++;
|
|
336
|
+
}
|
|
337
|
+
const left = totals['Not yet checked'];
|
|
338
|
+
const judgedThisRound = [...rows.values()].reduce((n, r) => n + JUDGED.reduce((m, k) => m + r.round[k], 0), 0);
|
|
339
|
+
const roundLabel = !roundFrom ? null : roundOpen ? `This round (since ${fmtTime(roundFrom)})` : `Last round (${fmtTime(roundFrom)} – ${fmtTime(roundTo)})`;
|
|
340
|
+
const ordered = [...rows.entries()].filter(([, r]) => r.left || JUDGED.some((k) => r.round[k])).sort((a, b) => b[1].left - a[1].left);
|
|
341
|
+
el.replaceChildren(
|
|
342
|
+
h('div', {}, left ? h('span', {}, h('b', {}, new Set(g.changes.flatMap((c) => c.propagation.filter((e) => e.state === 'Not yet checked').map((e) => e.nodeId))).size), ' objects not yet judged against ', h('b', {}, left), ` change entr${left === 1 ? 'y' : 'ies'} from ${new Set(g.changes.filter((c) => c.propagation.some((e) => e.state === 'Not yet checked')).map((c) => c.id)).size} changes. Each object is judged once against every change that reached it, area by area.`) : 'Every downstream object has been judged.'),
|
|
343
|
+
h('div', { class: 'faint' }, 'All entries: ', [...JUDGED, 'Not yet checked'].map((k) => `${k} ${totals[k]}`).join(' · ')),
|
|
344
|
+
ordered.length ? h('table', { class: 'list' },
|
|
345
|
+
h('thead', {}, h('tr', {}, h('th', {}, 'Area'), h('th', {}, 'Not yet judged'), roundLabel ? h('th', {}, `${roundLabel}: ${judgedThisRound} judged`) : null)),
|
|
346
|
+
h('tbody', {}, ...ordered.map(([a, r]) => h('tr', {},
|
|
347
|
+
h('td', {}, areaName(a)),
|
|
348
|
+
h('td', {}, r.left ? `${r.left} (${r.changes.size} change${r.changes.size === 1 ? '' : 's'})` : '—'),
|
|
349
|
+
roundLabel ? h('td', { class: 'faint' }, JUDGED.some((k) => r.round[k]) ? JUDGED.filter((k) => r.round[k]).map((k) => `${k} ${r.round[k]}`).join(' · ') : '—') : null)))) : null,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Beside the levels (CKC-13 AC-8): the materials a job read only in part. §1.11 has no level for a part read, so each
|
|
355
|
+
* keeps its own, and the line says which.
|
|
356
|
+
*/
|
|
357
|
+
function partReadsLine(rp) {
|
|
358
|
+
if (!rp || !rp.materials) return null;
|
|
359
|
+
return h('div', { class: 'faint read-in-part' }, `Read in part: ${rp.materials} material${rp.materials === 1 ? '' : 's'} (${kindsText(rp.byKind)}) — some of their lines were read, not all. Each stays at its level: ${Object.entries(rp.byLevel).map(([l, n]) => `${l} ${n}`).join(', ')}.`);
|
|
360
|
+
}
|
|
361
|
+
// The Keeper view's two pages are in keeper-view.js, and Keeper activity — the one list of the Keeper's work, with
|
|
362
|
+
// each round's tree — in keeper-activity.js (Spec §6.9, §6.10; D105).
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* One follow-up round: its own account, the numbers the program counted, and what is still behind — one item in
|
|
366
|
+
* `Notes (attention)` for the whole round, which leaves once it has been opened (D56 item 3). Opened from an item with
|
|
367
|
+
* news (§3.8), it is named as the item names it (the round's number among all rounds) and carries the item's news with
|
|
368
|
+
* their jumps; the news counts stand with the others. It stays reachable once opened — from `Since last visit`, from the
|
|
369
|
+
* round in the Keeper view (§6.9), from an object the round judged — and reopened from where the news has no jumps at
|
|
370
|
+
* hand, it lists the round's own news, each where it is.
|
|
371
|
+
*/
|
|
372
|
+
export async function openRound(roundId, { name = null, news = null } = {}) {
|
|
373
|
+
const r = await api(`/api/projects/${P()}/rounds`);
|
|
374
|
+
const round = r.rounds.find((x) => x.id === roundId);
|
|
375
|
+
if (!round || !round.result) { toast('This round has no result yet'); return; }
|
|
376
|
+
const c = round.result.counts;
|
|
377
|
+
const n = c.news ?? null;
|
|
378
|
+
news = [news, round.result.news ?? null].find((x) => x && !x.nothingNew) ?? null;
|
|
379
|
+
const count = (k, one, many = `${one}s`) => (n?.[k] ? h('span', { class: 'tag news-count', 'data-news': k }, `${n[k]} ${n[k] === 1 ? one : many}`) : null);
|
|
380
|
+
const body = h('div', { class: 'stack' },
|
|
381
|
+
h('p', {}, round.result.summary),
|
|
382
|
+
n ? h('div', { class: 'row wrap round-news-counts' },
|
|
383
|
+
count('breakpoints', 'breakpoint newly lit', 'breakpoints newly lit'), count('sendbacksNew', 'new send-back'), count('sendbacksMoved', 'send-back moved', 'send-backs moved'),
|
|
384
|
+
count('sixThings', 'newly among the six things', 'newly among the six things'), count('patches', 'semantic patch confirmed', 'semantic patches confirmed'), count('notes', 'note written or updated', 'notes written or updated')) : null,
|
|
385
|
+
news ? newsBlock(news, () => document.querySelector('#dialog')?.close()) : null,
|
|
386
|
+
h('div', { class: 'row wrap' },
|
|
387
|
+
h('span', { class: 'tag' }, `${c.objectsJudged} object${c.objectsJudged === 1 ? '' : 's'} judged`),
|
|
388
|
+
c.behind ? h('span', { class: 'tag amber' }, `${c.behind} still on the old understanding`) : null,
|
|
389
|
+
c.itemsLacked ? h('span', { class: 'tag' }, `${c.itemsLacked} item${c.itemsLacked === 1 ? '' : 's'} not followed`) : null,
|
|
390
|
+
c.notJudged ? h('span', { class: 'tag' }, `${c.notJudged} recorded at the time, not judged`) : null,
|
|
391
|
+
c.requests ? h('span', { class: 'tag' }, `${c.requests} modification request${c.requests === 1 ? '' : 's'}`) : null,
|
|
392
|
+
c.notes ? h('span', { class: 'tag' }, `${c.notes} note${c.notes === 1 ? '' : 's'}`) : null,
|
|
393
|
+
c.decisionsReplaced ? h('span', { class: 'tag' }, `${c.decisionsReplaced} decision${c.decisionsReplaced === 1 ? '' : 's'} replaced`) : null),
|
|
394
|
+
round.result.behind.length
|
|
395
|
+
? h('div', {}, h('b', {}, 'Still on the old understanding'), ...round.result.behind.map((b) => h('div', { class: 'item', ...pressable(() => openObject({ kind: 'node', id: b.nodeId, label: b.label })) }, h('div', {}, b.label), h('div', { class: 'meta' }, `${b.lacks} item${b.lacks === 1 ? '' : 's'} not followed${b.holder ? ` · held by ${b.holder}` : ''}`))))
|
|
396
|
+
: null,
|
|
397
|
+
round.result.unassigned.length ? h('div', {}, h('b', {}, 'Nobody to hand these to'), ...round.result.unassigned.map((u) => h('div', { class: 'muted' }, u))) : null,
|
|
398
|
+
// Named as its item names it, the round's number is its number among all rounds; the record keeps a number of its own.
|
|
399
|
+
h('div', { class: 'faint' }, `${name ? `Follow up record ${round.number}` : `Round ${round.number}`} · started ${fmtTime(round.startedAt)} · ended ${fmtTime(round.endedAt)}${round.mainJob ? ` · ${round.mainJob}` : ''}`));
|
|
400
|
+
openDialog(name ?? `Follow up round ${round.number}`, [body]);
|
|
401
|
+
// Opening it is the owner looking: it leaves the attention list. Opening it again leaves the record as it is.
|
|
402
|
+
if (round.seenAt) return;
|
|
403
|
+
await api(`/api/projects/${P()}/rounds/${encodeURIComponent(roundId)}/seen`, { method: 'POST' }).catch(() => undefined);
|
|
404
|
+
await refreshProject();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Keeper conversation panel (§6.8) ─────────────────────────────────────
|
|
408
|
+
const tokenAmount = (n) => {
|
|
409
|
+
if (!Number.isFinite(n)) return '—';
|
|
410
|
+
if (n < 1000) return String(Math.round(n));
|
|
411
|
+
if (n < 1000000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0).replace(/\.0$/, '')}k`;
|
|
412
|
+
return `${(n / 1000000).toFixed(n < 10000000 ? 1 : 0).replace(/\.0$/, '')}m`;
|
|
413
|
+
};
|
|
414
|
+
const contextAmount = (context) => {
|
|
415
|
+
if (!context || (context.usedTokens == null && context.limitTokens == null)) return '—';
|
|
416
|
+
const used = `${context.estimated && context.usedTokens != null ? '≈' : ''}${tokenAmount(context.usedTokens)}`;
|
|
417
|
+
return `${used} / ${tokenAmount(context.limitTokens)}`;
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
420
|
+
* The conversation is built once and then only updated in its parts — the state and context in its head, the list of
|
|
421
|
+
* sessions, the messages — so the panel can be docked, popped out and docked again, the view and even the project can
|
|
422
|
+
* change, and a half-typed message and the scroll position are still there (CKC-10 AC-20). The Keeper's answers are
|
|
423
|
+
* drawn as Markdown (AC-21); they are untrusted text, so they only ever become elements and text nodes (markdown.js).
|
|
424
|
+
*/
|
|
425
|
+
const conversation = {
|
|
426
|
+
_ui: null, _loadedFor: null, _loading: 0, _branching: false, _lastJobId: null, _telemetry: null,
|
|
427
|
+
loadKey() { const c = state.keeperContext; return `${state.projectId}|${state.conversationId ?? ''}|${c ? `${c.kind}:${c.id}` : ''}`; },
|
|
428
|
+
|
|
429
|
+
render(panel) {
|
|
430
|
+
if (conversation._ui?.panel !== panel || !panel.firstChild) conversation.mount(panel);
|
|
431
|
+
conversation.sync();
|
|
432
|
+
if (conversation.loadKey() !== conversation._loadedFor) void conversation.load();
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
mount(panel) {
|
|
436
|
+
clear(panel);
|
|
437
|
+
const stream = h('div', { class: 'keeper-stream', id: 'keeper-stream', role: 'log', 'aria-label': 'Conversation', tabindex: 0 });
|
|
438
|
+
const sessionSel = h('select', { class: 'input small', title: 'Switch session', 'aria-label': 'Session', onChange: (e) => { state.conversationId = e.target.value || 'new'; void conversation.load(); } });
|
|
439
|
+
const ta = h('textarea', { 'aria-label': 'Message to the Keeper' });
|
|
440
|
+
let composing = false;
|
|
441
|
+
ta.addEventListener('compositionstart', () => { composing = true; });
|
|
442
|
+
ta.addEventListener('compositionend', () => { composing = false; });
|
|
443
|
+
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !composing && !e.isComposing) { e.preventDefault(); send(); } });
|
|
444
|
+
const send = async () => {
|
|
445
|
+
const text = ta.value.trim();
|
|
446
|
+
if (!text || ta.disabled) return;
|
|
447
|
+
ta.value = '';
|
|
448
|
+
append(stream, h('div', { class: 'msg user' }, text));
|
|
449
|
+
stream.scrollTop = stream.scrollHeight;
|
|
450
|
+
try {
|
|
451
|
+
const r = await api(`/api/projects/${P()}/chat`, { method: 'POST', body: { text, context: state.keeperContext, conversationId: state.conversationId } });
|
|
452
|
+
state.conversationId = r.conversationId;
|
|
453
|
+
conversation._loadedFor = conversation.loadKey(); // the session this message started is the one already on screen
|
|
454
|
+
if (r.mode === 'steer') append(stream, h('div', { class: 'msg system' }, 'Delivered as a mid-course adjustment; it reaches the Keeper after the current tool step.'));
|
|
455
|
+
else if (r.status !== 'Running' && r.status !== 'Queued') append(stream, h('div', { class: 'msg system' }, `Queued (${r.status.toLowerCase()}). ${activity.reason || 'It is answered when the Keeper is back.'}`));
|
|
456
|
+
conversation.loadSessions(sessionSel);
|
|
457
|
+
} catch (e) { append(stream, h('div', { class: 'msg system' }, e.message)); }
|
|
458
|
+
};
|
|
459
|
+
const usage = h('span', { class: 'keeper-context-usage', dataset: { chatContext: '' } }, '—');
|
|
460
|
+
const model = h('span', { class: 'keeper-model-name', dataset: { chatModel: '' } }, '—');
|
|
461
|
+
const form = h('button', { class: 'btn small', onClick: () => setKeeperMode(keeperMode() === 'floating' ? 'docked' : 'floating') }, 'Pop out');
|
|
462
|
+
const ctxName = h('strong', {});
|
|
463
|
+
const ctxClear = h('button', { class: 'text-btn', onClick: () => toggleKeeper(true, null) }, 'clear');
|
|
464
|
+
const foot = h('span', {});
|
|
465
|
+
const sendBtn = h('button', { class: 'btn small primary', onClick: send }, 'Send');
|
|
466
|
+
append(panel,
|
|
467
|
+
h('div', { class: 'keeper-head' },
|
|
468
|
+
h('div', { class: 'title' },
|
|
469
|
+
h('span', { class: 'keeper-who' }, h('span', { class: 'pi-mark' }, 'π'), h('span', {}, 'Keeper')),
|
|
470
|
+
h('span', { class: 'keeper-head-right' },
|
|
471
|
+
h('span', { class: 'keeper-telemetry' },
|
|
472
|
+
h('span', { class: 'keeper-telemetry-item' }, h('small', {}, 'Context'), usage),
|
|
473
|
+
h('span', { class: 'keeper-telemetry-item' }, h('small', {}, 'Model'), model)),
|
|
474
|
+
h('span', { class: 'row keeper-actions' }, form, h('button', { class: 'btn small', onClick: () => toggleKeeper(false) }, 'Close')))),
|
|
475
|
+
h('div', { class: 'ctx' }, 'Context ', ctxName, ctxClear),
|
|
476
|
+
h('div', { class: 'row wrap' }, h('div', { class: 'grow' }, sessionSel),
|
|
477
|
+
h('button', { class: 'btn small', title: 'Start a new session; earlier sessions stay available', onClick: () => { state.conversationId = 'new'; void conversation.load(); } }, 'New session'),
|
|
478
|
+
h('button', { class: 'btn small', title: 'Continue in pi’s own interface with the same project assets', onClick: async () => { try { const r = await api(`/api/projects/${P()}/keeper/open-in-pi`, { method: 'POST', body: { jobId: conversation._lastJobId ?? null } }); toast(r.message); } catch (e) { toast(e.message); } } }, 'Open in pi'))),
|
|
479
|
+
stream,
|
|
480
|
+
h('div', { class: 'composer' }, ta, h('div', { class: 'foot' }, foot, sendBtn)));
|
|
481
|
+
conversation._ui = { panel, stream, sessionSel, ta, usage, model, form, ctxName, ctxClear, foot, sendBtn };
|
|
482
|
+
conversation._loadedFor = null;
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
/** The head and the composer follow the Keeper's state, the context and the panel's form, in place. */
|
|
486
|
+
sync() {
|
|
487
|
+
const ui = conversation._ui;
|
|
488
|
+
if (!ui) return;
|
|
489
|
+
const st = activity.status;
|
|
490
|
+
const ctx = state.keeperContext;
|
|
491
|
+
const off = st === 'Not connected';
|
|
492
|
+
conversation.paintTelemetry();
|
|
493
|
+
ui.ctxName.textContent = ctx ? (ctx.label || `${ctx.kind} ${ctx.id}`) : 'Whole project';
|
|
494
|
+
ui.ctxName.title = ui.ctxName.textContent;
|
|
495
|
+
ui.ctxClear.hidden = !ctx;
|
|
496
|
+
const floating = keeperMode() === 'floating';
|
|
497
|
+
ui.form.textContent = floating ? 'Dock' : 'Pop out';
|
|
498
|
+
ui.form.title = floating ? 'Put the conversation back on the right of the workbench' : 'Make the conversation a window you can move and resize';
|
|
499
|
+
ui.ta.disabled = off;
|
|
500
|
+
ui.sendBtn.disabled = off;
|
|
501
|
+
ui.ta.placeholder = off ? `Input unavailable: ${activity.reason || 'the Keeper is not connected'}` : 'Ask, correct, or delegate. Enter sends, Shift+Enter is a new line.';
|
|
502
|
+
ui.foot.textContent = off ? 'Messages cannot be sent while not connected.' : st === 'Waiting for quota' ? 'Messages are queued until quota is back; you can also switch model or provider in Keeper.' : st === 'Unavailable' ? `Messages are queued; the Keeper is temporarily unavailable${activity.reason ? `: ${activity.reason}` : ''}.` : st === 'Organizing paused' ? 'Organizing is paused; the conversation works as usual.' : '';
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
setTelemetry(telemetry) {
|
|
506
|
+
conversation._telemetry = telemetry ?? null;
|
|
507
|
+
conversation.paintTelemetry();
|
|
508
|
+
},
|
|
509
|
+
paintTelemetry() {
|
|
510
|
+
const ui = conversation._ui;
|
|
511
|
+
if (!ui) return;
|
|
512
|
+
const telemetry = conversation._telemetry;
|
|
513
|
+
const ctx = telemetry?.context ?? null;
|
|
514
|
+
const model = telemetry?.model ?? null;
|
|
515
|
+
ui.usage.textContent = contextAmount(ctx);
|
|
516
|
+
ui.usage.title = ctx ? `${ctx.estimated ? 'Estimated ' : ''}context use${ctx.source ? ` · ${ctx.source}` : ''}${ctx.percent == null ? '' : ` · ${ctx.percent}%`}` : 'Context use unavailable';
|
|
517
|
+
ui.model.textContent = model?.name || model?.id || '—';
|
|
518
|
+
ui.model.title = model ? [model.name, model.provider && model.id ? `${model.provider}/${model.id}` : model.provider || model.id].filter(Boolean).join(' · ') : 'Model unavailable';
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
async refreshTelemetry() {
|
|
522
|
+
if (!conversation._ui || !state.projectId) return;
|
|
523
|
+
const key = conversation.loadKey();
|
|
524
|
+
const ui = conversation._ui;
|
|
525
|
+
try {
|
|
526
|
+
const r = await api(`/api/projects/${P()}/chat?conversation=${encodeURIComponent(state.conversationId ?? '')}`);
|
|
527
|
+
if (key !== conversation.loadKey() || ui !== conversation._ui) return;
|
|
528
|
+
conversation.setTelemetry(r.telemetry);
|
|
529
|
+
} catch { /* keep the last live reading */ }
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
/** Another project, session or context: the messages are read again. What is being typed is not touched. */
|
|
533
|
+
async load() {
|
|
534
|
+
const ui = conversation._ui;
|
|
535
|
+
if (!ui) return;
|
|
536
|
+
const token = ++conversation._loading;
|
|
537
|
+
const key = conversation.loadKey();
|
|
538
|
+
if (conversation._loadedFor !== key) conversation.setTelemetry(null);
|
|
539
|
+
conversation._loadedFor = key;
|
|
540
|
+
const stream = h('div');
|
|
541
|
+
const acceptedKey = await conversation.loadHistory(stream, state.keeperContext, ui.sessionSel, { key, token, ui });
|
|
542
|
+
if (!acceptedKey || token !== conversation._loading || acceptedKey !== conversation.loadKey() || ui !== conversation._ui) return;
|
|
543
|
+
conversation._loadedFor = acceptedKey;
|
|
544
|
+
ui.stream.replaceChildren(...stream.childNodes);
|
|
545
|
+
ui.stream.scrollTop = ui.stream.scrollHeight;
|
|
546
|
+
},
|
|
547
|
+
focus() { const ui = conversation._ui; if (!ui) return; if (!ui.ta.disabled) ui.ta.focus({ preventScroll: true }); else ui.panel.closest('#keeper')?.focus({ preventScroll: true }); },
|
|
548
|
+
/**
|
|
549
|
+
* Before the panel changes form: remember what the owner is looking at, and return a function that brings it back.
|
|
550
|
+
* At the end of the conversation it stays at the end; elsewhere the message at the top of the view stays there,
|
|
551
|
+
* whatever the new width does to the lines.
|
|
552
|
+
*/
|
|
553
|
+
holdScroll() {
|
|
554
|
+
const s = conversation._ui?.stream;
|
|
555
|
+
if (!s) return () => {};
|
|
556
|
+
const atEnd = s.scrollHeight - s.scrollTop - s.clientHeight < 4;
|
|
557
|
+
const top = s.getBoundingClientRect().top;
|
|
558
|
+
const first = [...s.children].find((m) => m.getBoundingClientRect().bottom > top + 1) ?? null;
|
|
559
|
+
const offset = first ? first.getBoundingClientRect().top - top : 0;
|
|
560
|
+
return () => {
|
|
561
|
+
if (atEnd) s.scrollTop = s.scrollHeight;
|
|
562
|
+
else if (first?.isConnected) s.scrollTop += first.getBoundingClientRect().top - s.getBoundingClientRect().top - offset;
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
async loadSessions(sel) {
|
|
567
|
+
const key = conversation.loadKey();
|
|
568
|
+
const ui = conversation._ui;
|
|
569
|
+
try {
|
|
570
|
+
const r = await api(`/api/projects/${P()}/chat?conversation=${encodeURIComponent(state.conversationId ?? '')}`);
|
|
571
|
+
if (key !== conversation.loadKey() || ui !== conversation._ui || sel !== ui?.sessionSel) return;
|
|
572
|
+
conversation.fillSessions(sel, r);
|
|
573
|
+
conversation.setTelemetry(r.telemetry);
|
|
574
|
+
} catch { /* keep */ }
|
|
575
|
+
},
|
|
576
|
+
fillSessions(sel, r) {
|
|
577
|
+
clear(sel);
|
|
578
|
+
if (state.conversationId === 'new' || (!r.conversationId && r.sessions.length === 0)) sel.append(h('option', { value: '', selected: true }, 'New session'));
|
|
579
|
+
for (const s of r.sessions) sel.append(h('option', { value: s.id, selected: s.id === (state.conversationId === 'new' ? null : r.conversationId) }, `${s.title} · ${s.turns} turn${s.turns === 1 ? '' : 's'} · ${fmtRel(s.lastAt)}`));
|
|
580
|
+
if (state.conversationId !== 'new' && r.sessions.length) sel.append(h('option', { value: '' }, 'New session'));
|
|
581
|
+
},
|
|
582
|
+
async loadHistory(stream, ctx, sel, request) {
|
|
583
|
+
try {
|
|
584
|
+
const r = await api(`/api/projects/${P()}/chat?conversation=${encodeURIComponent(state.conversationId ?? '')}&context=${encodeURIComponent(JSON.stringify(ctx ?? null))}`);
|
|
585
|
+
if (request.token !== conversation._loading || request.key !== conversation.loadKey() || request.ui !== conversation._ui || sel !== request.ui.sessionSel) return null;
|
|
586
|
+
conversation.fillSessions(sel, r);
|
|
587
|
+
conversation.setTelemetry(r.telemetry);
|
|
588
|
+
conversation._branching = r.branchingSupported;
|
|
589
|
+
if (state.conversationId !== 'new') state.conversationId = r.conversationId;
|
|
590
|
+
if (r.existing) append(stream, h('div', { class: 'msg keeper existing' }, h('small', { class: 'faint', title: r.existing.at ? fmtTime(r.existing.at) : 'Written before this conversation, not generated now' }, r.existing.at ? `Written earlier · ${fmtRel(r.existing.at)}` : 'Written earlier'), conversation.answer(r.existing.text), r.existing.sourceIds?.length ? h('div', { class: 'cites' }, ...r.existing.sourceIds.slice(0, 8).map((sid) => h('button', { class: 'cite', title: 'Open this source', onClick: () => openSource(sid) }, sid.slice(0, 10)))) : null));
|
|
591
|
+
const turns = state.conversationId === 'new' ? [] : r.turns;
|
|
592
|
+
for (const t of turns) {
|
|
593
|
+
conversation.appendMessage(stream, { role: 'user', text: t.question, context: t.context });
|
|
594
|
+
for (const f of t.followUps) conversation.appendMessage(stream, { role: 'user', text: f.text, mid: true });
|
|
595
|
+
if (t.answer) conversation.appendMessage(stream, { role: 'keeper', id: t.jobId, text: t.answer, steps: t.steps, status: t.status, kind: t.kind, requestBasis: t.requestBasis, canBranch: t.canBranch, savedResults: t.savedResults, result: t.result });
|
|
596
|
+
else conversation.appendMessage(stream, { role: 'system', text: t.status === 'Running' ? 'The Keeper is answering…' : t.error ? `${t.status}: ${t.error}` : t.status === 'Queued' ? 'Queued.' : t.status });
|
|
597
|
+
conversation._lastJobId = t.jobId;
|
|
598
|
+
}
|
|
599
|
+
return conversation.loadKey();
|
|
600
|
+
} catch (e) {
|
|
601
|
+
if (request.token !== conversation._loading || request.key !== conversation.loadKey() || request.ui !== conversation._ui) return null;
|
|
602
|
+
append(stream, h('div', { class: 'msg system' }, e.message));
|
|
603
|
+
return conversation.loadKey();
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
/** An answer, drawn as Markdown with its `[src_…]` citations as chips that open the source (Spec §6.8). */
|
|
607
|
+
answer(text) { return fillMarkdown(h('div', { class: 'text md' }), text || '', { onCite: openCited }); },
|
|
608
|
+
/** While an answer streams it is drawn again at most once a frame, so the marks never show and a long answer does not stall. */
|
|
609
|
+
paint(el, text) {
|
|
610
|
+
el._md = text;
|
|
611
|
+
if (el._queued) return;
|
|
612
|
+
el._queued = true;
|
|
613
|
+
requestAnimationFrame(() => {
|
|
614
|
+
el._queued = false;
|
|
615
|
+
if (!el.isConnected) return;
|
|
616
|
+
const stream = el.closest('.keeper-stream');
|
|
617
|
+
const atEnd = stream ? stream.scrollHeight - stream.scrollTop - stream.clientHeight < 24 : false;
|
|
618
|
+
fillMarkdown(el, el._md, { onCite: openCited });
|
|
619
|
+
if (atEnd) stream.scrollTop = stream.scrollHeight;
|
|
620
|
+
});
|
|
621
|
+
},
|
|
622
|
+
/** An investigation result (§5.2): what to adjust, why, where it affects; the current description or discussable options. */
|
|
623
|
+
resultBlock(r) {
|
|
624
|
+
const cites = (ids) => (ids || []).map((sid) => h('button', { class: 'cite', title: 'Open this source', onClick: () => openCited(sid) }, sid.slice(0, 10)));
|
|
625
|
+
const nodeBtn = (id) => h('button', { class: 'text-btn', onClick: () => select({ kind: 'node', id, label: id }) }, id);
|
|
626
|
+
return h('div', { class: 'result' },
|
|
627
|
+
h('div', {}, h('b', {}, 'What to adjust: '), r.adjust), h('div', {}, h('b', {}, 'Why: '), r.why),
|
|
628
|
+
r.affected?.length ? h('div', {}, h('b', {}, 'Affects: '), h('button', { class: 'text-btn', onClick: () => highlightOnGraph(r.affected.map((a) => a.id)) }, 'Show on graph'), h('ul', {}, ...r.affected.map((a) => h('li', {}, nodeBtn(a.id), ' — ', a.reason, ' ', ...cites(a.sourceIds))))) : null,
|
|
629
|
+
r.decided ? h('div', {}, h('span', { class: 'tag green' }, 'Already decided'), r.currentDescription ? h('div', {}, h('b', {}, 'Now in force: '), r.currentDescription) : null, r.oldWording?.length ? h('div', {}, h('b', {}, 'Still on the old wording:'), h('ul', {}, ...r.oldWording.map((o) => h('li', {}, ...cites([o.sourceId]), ' ', o.wording)))) : null, r.referencesToUpdate?.length ? h('div', {}, h('b', {}, 'References to update: '), r.referencesToUpdate.join(', ')) : null)
|
|
630
|
+
: h('div', {}, h('span', { class: 'tag blue' }, 'Direction not decided'), ...(r.options || []).map((o) => h('div', { class: 'option' }, h('b', {}, o.title), ' ', h('span', { class: 'tag blue' }, 'Proposed'), h('div', { class: 'muted' }, 'Effects: ', o.effects), h('div', { class: 'muted' }, 'Work and results that change: ', o.workToChange), h('div', { class: 'muted' }, 'Cost: ', o.cost), h('div', { class: 'muted' }, 'Reusable: ', o.reusable)))));
|
|
631
|
+
},
|
|
632
|
+
appendMessage(stream, m) {
|
|
633
|
+
const atEnd = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 24;
|
|
634
|
+
if (m.role === 'user') append(stream, h('div', { class: `msg user${m.mid ? ' mid' : ''}` }, m.mid ? h('small', { class: 'faint' }, 'mid-course · ') : null, m.text, m.context?.label ? h('small', { class: 'faint' }, ` · on ${m.context.label}`) : null));
|
|
635
|
+
else if (m.role === 'keeper') {
|
|
636
|
+
const steps = Array.isArray(m.steps) ? m.steps : [];
|
|
637
|
+
append(stream, h('div', { class: 'msg keeper', dataset: { id: m.id } },
|
|
638
|
+
m.kind === 'Your request' ? h('div', {}, h('span', { class: 'tag amber' }, 'Your request'), m.requestBasis ? h('small', { class: 'faint' }, ` ${m.requestBasis.label}`) : null) : null,
|
|
639
|
+
conversation.answer(m.text),
|
|
640
|
+
m.result ? conversation.resultBlock(m.result) : null,
|
|
641
|
+
m.status && m.status !== 'Done' ? h('small', { class: 'faint' }, m.status) : null,
|
|
642
|
+
steps.length ? h('details', { class: 'fold' }, h('summary', {}, `How the Keeper investigated (${steps.length} steps)`), h('div', { class: 'content' }, ...steps.map((s) => h('div', { class: `mono${s.isError ? ' red' : ''}` }, `${s.tool} ${s.target}${s.summary ? ' — ' + s.summary : ''}`)))) : h('small', { class: 'faint' }, 'Answered from the assets; no tool was used.'),
|
|
643
|
+
m.savedResults?.length ? h('div', { class: 'row wrap' }, h('small', { class: 'faint' }, 'Saved:'), ...m.savedResults.slice(0, 6).map((r) => h('span', { class: 'tag' }, r.label))) : null,
|
|
644
|
+
h('div', { class: 'row wrap' }, state.keeperContext?.kind === 'node' ? h('button', { class: 'text-btn', onClick: () => showOnGraph(state.keeperContext) }, 'Show on graph') : null, m.canBranch && conversation._branching ? h('button', { class: 'text-btn', title: 'Continue from this point in a new branch of the same session', onClick: async () => { const r = await api(`/api/projects/${P()}/chat/branch`, { method: 'POST', body: { conversationId: state.conversationId, jobId: m.id } }); toast(r.ok ? 'Branched: the next message continues from here' : `Branch unavailable: ${r.reason}`); } }, 'Branch here') : null)));
|
|
645
|
+
} else append(stream, h('div', { class: 'msg system' }, m.text));
|
|
646
|
+
if (atEnd || m.role === 'user') stream.scrollTop = stream.scrollHeight;
|
|
647
|
+
},
|
|
648
|
+
onEvent(event) {
|
|
649
|
+
const stream = conversation._ui?.stream;
|
|
650
|
+
if (!stream) return;
|
|
651
|
+
const d = event.data || {};
|
|
652
|
+
if (event.type === 'chat' && d.kind === 'delta') {
|
|
653
|
+
const x = d.data || {};
|
|
654
|
+
let el = stream.querySelector(`[data-id="${CSS.escape(String(x.messageId))}"]`);
|
|
655
|
+
if (!el) { el = h('div', { class: 'msg keeper streaming', dataset: { id: x.messageId } }, h('div', { class: 'text md' })); append(stream, el); }
|
|
656
|
+
el._raw = (el._raw ?? '') + (x.text ?? '');
|
|
657
|
+
conversation.paint(el.querySelector('.text'), el._raw);
|
|
658
|
+
} else if (event.type === 'keeper' && d.kind === 'done') {
|
|
659
|
+
const x = d.data || {};
|
|
660
|
+
if (!x.conversation) return;
|
|
661
|
+
const el = x.messageId ? stream.querySelector(`[data-id="${CSS.escape(String(x.messageId))}"]`) : null;
|
|
662
|
+
if (el) el.remove();
|
|
663
|
+
const t = { role: 'keeper', id: d.jobId, text: x.resultText || '', steps: x.steps, status: x.status, kind: x.kind, requestBasis: x.requestBasis, savedResults: x.savedResults, canBranch: Boolean(x.leafId), result: x.result };
|
|
664
|
+
if (x.resultText) conversation.appendMessage(stream, t);
|
|
665
|
+
else if (x.error || x.status !== 'Done') conversation.appendMessage(stream, { role: 'system', text: x.error ? `${x.status}: ${x.error}` : x.status });
|
|
666
|
+
conversation._lastJobId = d.jobId;
|
|
667
|
+
void conversation.refreshTelemetry();
|
|
668
|
+
} else if (event.type === 'keeper') void conversation.refreshTelemetry();
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// ── Project graph (Spec §6.2, §6.3) ──────────────────────────────────────
|
|
673
|
+
// Stars mark what is new or changed (owner 2026-09-17): since the last visit, or within the last one to three days.
|
|
674
|
+
const STAR_KEY = 'pk.graph.stars';
|
|
675
|
+
function starMode() { try { return localStorage.getItem(STAR_KEY) === 'days' ? 'days' : 'visit'; } catch { return 'visit'; } }
|
|
676
|
+
function setStarMode(mode) { try { localStorage.setItem(STAR_KEY, mode); } catch { /* per-viewer convenience only */ } }
|
|
677
|
+
let graphCtl = null;
|
|
678
|
+
let stripEl = null;
|
|
679
|
+
let legendEl = null;
|
|
680
|
+
let compareEl = null;
|
|
681
|
+
const compareState = { from: '', to: 'now', index: 0, all: false };
|
|
682
|
+
|
|
683
|
+
const DIFF_KINDS = ['Added', 'Content changed', 'Regrouped', 'Relinked', 'Removed'];
|
|
684
|
+
|
|
685
|
+
function closeCompare() {
|
|
686
|
+
if (compareEl?.isConnected) compareEl.remove();
|
|
687
|
+
compareEl = null;
|
|
688
|
+
graphCtl?.setCompare(null);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* `Compare` (D45): two saved versions side by side. `Before` and `After` are the pictures themselves; `Delta`
|
|
693
|
+
* draws only the objects that differ, so a project of this size does not hide the difference among everything that
|
|
694
|
+
* stayed the same. The bar walks the differences one at a time, which is how the owner asked to read them.
|
|
695
|
+
*/
|
|
696
|
+
async function openCompare(wrap) {
|
|
697
|
+
let data;
|
|
698
|
+
try { data = await api(`/api/projects/${P()}/compare?from=${encodeURIComponent(compareState.from)}&to=${encodeURIComponent(compareState.to)}`); }
|
|
699
|
+
catch (e) { toast(e.message); return; }
|
|
700
|
+
const versions = data.versions ?? [];
|
|
701
|
+
if (data.error) { toast(data.error); }
|
|
702
|
+
compareState.from = data.from?.id ?? compareState.from;
|
|
703
|
+
compareState.index = Math.min(compareState.index, Math.max(0, (data.items?.length ?? 1) - 1));
|
|
704
|
+
const items = data.items ?? [];
|
|
705
|
+
const label = (v) => `${fmtTime(v.at)} · ${v.reason}`;
|
|
706
|
+
const pick = (which, value) => h('select', { class: 'input small', onChange: (e) => { compareState[which] = e.target.value; compareState.index = 0; void openCompare(wrap); } },
|
|
707
|
+
...(which === 'to' ? [h('option', { value: 'now', selected: value === 'now' }, 'Now')] : []),
|
|
708
|
+
...versions.slice().reverse().map((v) => h('option', { value: v.id, selected: v.id === value }, label(v))));
|
|
709
|
+
const side = (name) => h('button', { class: `btn small${compareState.side === name ? ' active' : ''}`, onClick: () => { compareState.side = name; graphCtl?.setCompare({ ...compareState, items }); paint(); } }, name);
|
|
710
|
+
const current = items[compareState.index];
|
|
711
|
+
const step = (by) => { compareState.index = Math.max(0, Math.min(items.length - 1, compareState.index + by)); graphCtl?.setCompare({ ...compareState, items }); paint(); };
|
|
712
|
+
const bar = h('div', { class: 'compare-bar' });
|
|
713
|
+
const paint = () => {
|
|
714
|
+
clear(bar);
|
|
715
|
+
const it = items[compareState.index];
|
|
716
|
+
append(bar,
|
|
717
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'From'), pick('from', compareState.from), h('small', {}, 'to'), pick('to', compareState.to),
|
|
718
|
+
side('Before'), side('Delta'), side('After')),
|
|
719
|
+
h('div', { class: 'row wrap' }, ...DIFF_KINDS.map((k) => h('span', { class: `tag${(data.counts?.[k] ?? 0) ? ' amber' : ''}` }, `${k} ${data.counts?.[k] ?? 0}`))),
|
|
720
|
+
items.length
|
|
721
|
+
? h('div', { class: 'row wrap' },
|
|
722
|
+
h('button', { class: 'btn small', disabled: compareState.index <= 0, onClick: () => step(-1) }, '‹'),
|
|
723
|
+
h('small', {}, `${compareState.index + 1} / ${items.length}`),
|
|
724
|
+
h('button', { class: 'btn small', disabled: compareState.index >= items.length - 1, onClick: () => step(1) }, '›'),
|
|
725
|
+
it ? h('span', {}, h('span', { class: 'tag' }, it.kind), ' ', h('button', { class: 'text-btn', onClick: () => select({ kind: 'node', id: it.id, label: it.label }) }, it.label), it.detail ? h('span', { class: 'muted' }, ` — ${it.detail}`) : null) : null)
|
|
726
|
+
: h('small', { class: 'faint' }, data.error ?? 'Nothing differs between these two versions.'));
|
|
727
|
+
};
|
|
728
|
+
compareState.side = compareState.side ?? 'Delta';
|
|
729
|
+
paint();
|
|
730
|
+
if (compareEl?.isConnected) compareEl.replaceWith(bar); else wrap.prepend(bar);
|
|
731
|
+
compareEl = bar;
|
|
732
|
+
graphCtl?.setCompare({ ...compareState, items });
|
|
733
|
+
}
|
|
734
|
+
const NODE_SEL = (n) => ({ kind: 'node', id: n.id, label: n.label, category: n.category });
|
|
735
|
+
|
|
736
|
+
function overviewUrl() {
|
|
737
|
+
return `/api/projects/${P()}/overview?since=${encodeURIComponent(state.lastVisit ?? '')}&selection=${encodeURIComponent(state.selection?.kind === 'node' || state.selection?.kind === 'relation' ? state.selection.id : '')}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── The bottom strip (Spec §6.2; CKC-09 AC-38, AC-1): one-line indexes and one full-width reading sheet ─────────
|
|
741
|
+
/**
|
|
742
|
+
* Every row of the three columns is the same four slots — mark · one sentence · object · time (strip-rows.js says how
|
|
743
|
+
* they are taken from /overview). A press on a row opens it where it is: the whole text, how it came about, what it
|
|
744
|
+
* rests on and what can be done about it, nothing less than the two-line items and the popover behind them held. One
|
|
745
|
+
* row is open at a time, in a sheet across the whole strip, so the columns stay useful as indexes. A row
|
|
746
|
+
* that is itself an object (work that moved on) opens that object's popover instead (§6.4).
|
|
747
|
+
*
|
|
748
|
+
* The strip is rebuilt from every overview the page fetches and swapped in column by column only where it differs, so
|
|
749
|
+
* nothing blinks while the Keeper works; which row is open, how far each column is scrolled and where focus is are
|
|
750
|
+
* kept. What an open row shows is kept too (`stripData`), so a rebuilt strip draws it at once and unchanged, and it
|
|
751
|
+
* is read again after an asset event and redrawn only if it differs.
|
|
752
|
+
*/
|
|
753
|
+
let stripOpen = null; // { col, key }: the one row shown in the full-width reading sheet
|
|
754
|
+
const stripData = new Map(); // row key → what its expansion is drawn from (a note's details, a change record)
|
|
755
|
+
let stripOv = null; // the overview the strip was last built from
|
|
756
|
+
const STRIP_COLS = { attention: 'Notes (attention)', changes: 'Recent changes', since: 'Since last visit' };
|
|
757
|
+
|
|
758
|
+
function strip(ov) {
|
|
759
|
+
stripOv = ov;
|
|
760
|
+
syncAttentionTargets();
|
|
761
|
+
const cols = ['attention', 'changes', ...(ov.sinceLastVisit ? ['since'] : [])];
|
|
762
|
+
if (stripOpen && (!cols.includes(stripOpen.col) || !stripRows(stripOpen.col).some((r) => r.row.key === stripOpen.key))) stripOpen = null;
|
|
763
|
+
return h('div', { class: `strip${ov.sinceLastVisit ? ' three' : ' two'}`, id: 'drawer-strip' }, ...cols.map(stripSection));
|
|
764
|
+
}
|
|
765
|
+
/** The rows of one column, each with the /overview item it was made from. */
|
|
766
|
+
function stripRows(col) {
|
|
767
|
+
const ov = stripOv;
|
|
768
|
+
// The whole-project part only: an attention note on an object is marked ❓ on it (Spec §6.2; D100).
|
|
769
|
+
if (col === 'attention') return drawerAttention(ov).map((item) => ({ row: attentionRow(item), item }));
|
|
770
|
+
if (col === 'changes') return ov.recentChanges.map((item) => ({ row: changeRow(item), item }));
|
|
771
|
+
const s = ov.sinceLastVisit;
|
|
772
|
+
const items = [...(s.rounds ?? []), ...s.threads, ...s.changes, ...s.notes];
|
|
773
|
+
return sinceRows(s).map((row, i) => ({ row, item: items[i] }));
|
|
774
|
+
}
|
|
775
|
+
function stripSection(col) {
|
|
776
|
+
const ov = stripOv;
|
|
777
|
+
const rows = stripRows(col);
|
|
778
|
+
let head;
|
|
779
|
+
let none = '';
|
|
780
|
+
if (col === 'attention') {
|
|
781
|
+
// The header says why this is fewer than the project's notes, counting notes only (D50; CKC-09 AC-1, Spec §6.2):
|
|
782
|
+
// scope questions, finished requests and Follow up results are listed here too, but counted on their own. The
|
|
783
|
+
// header itself is one line, like the other columns'; the two lines and the full sentences open in a card on
|
|
784
|
+
// hover or focus of the header (owner 2026-09-22; styles.css .strip-why).
|
|
785
|
+
const a = attentionHeader(ov, onObjectNotes(ov).length);
|
|
786
|
+
// How many more wait on objects, marked ❓ there, stays in sight in the header; pressed, the Graph and the List show
|
|
787
|
+
// only those objects (CKC-09 AC-38).
|
|
788
|
+
head = h('header', { tabindex: '0' }, h('div', { class: 'strip-title' }, h('span', {}, STRIP_COLS[col]), h('span', { class: 'strip-counts' }, a.onObjects ? attentionCountButton(a.onObjects) : null, h('small', {}, a.count))),
|
|
789
|
+
h('div', { class: 'strip-why', role: 'tooltip' }, a.why ? h('span', {}, a.why) : null, a.also ? h('span', {}, a.also) : null, h('small', {}, a.hint)));
|
|
790
|
+
none = ov.relookDone ? 'Nothing waits for you.' : 'No notes yet — the first product re-look has not finished.';
|
|
791
|
+
} else if (col === 'changes') {
|
|
792
|
+
head = h('header', { title: 'The last ten changes of product meaning or state, the newest at the bottom. The Change log is the whole history, oldest to newest.' }, h('div', { class: 'strip-title' }, h('span', {}, STRIP_COLS[col]), h('small', {}, rows.length || '')));
|
|
793
|
+
none = 'No change history has been established for this project.';
|
|
794
|
+
} else {
|
|
795
|
+
const s = ov.sinceLastVisit;
|
|
796
|
+
const said = `${fmtRel(s.since)} · ${s.jobs} Keeper jobs${s.failed ? ` · ${s.failed} failed` : ''}`;
|
|
797
|
+
const rounds = (s.rounds ?? []).length;
|
|
798
|
+
head = h('header', { title: `What moved since you last opened this project (${fmtTime(s.since)}): ${rounds ? `${rounds} Follow up round${rounds === 1 ? '' : 's'}, each summed up in its row; ` : ''}${s.jobs} Keeper jobs finished${s.failed ? `, ${s.failed} failed` : ''}` }, h('div', { class: 'strip-title' }, h('span', {}, STRIP_COLS[col]), h('small', {}, said)));
|
|
799
|
+
}
|
|
800
|
+
return h('section', { 'data-col': col }, head, h('div', { class: 'items' }, rows.length ? rows.map(({ row, item }) => stripRow(col, row, item)) : h('div', { class: 'none' }, none)));
|
|
801
|
+
}
|
|
802
|
+
function stripRow(col, row, item) {
|
|
803
|
+
const open = !row.opens && stripOpen?.col === col && stripOpen.key === row.key;
|
|
804
|
+
const picked = Boolean(state.selection) && state.selection.id === item.id;
|
|
805
|
+
const line = h('div', { class: `item${picked ? ' sel' : ''}`, 'data-note': row.noteId, 'aria-expanded': row.opens ? null : String(open), ...pressable(() => pressStripRow(col, row, item)) },
|
|
806
|
+
h('span', { class: 'row-marks' }, ...row.marks.map((m) => h('span', { class: `mk mk-${m.tone}`, title: m.title, role: 'img', 'aria-label': m.title }, m.glyph))),
|
|
807
|
+
// The hover has the whole row: in a narrow column the object and the time give way to the sentence (styles.css).
|
|
808
|
+
h('span', { class: 'row-text', title: [row.text, row.object.title, row.time ? fmtTime(row.time) : ''].filter(Boolean).join('\n') }, row.text),
|
|
809
|
+
row.object.text ? h('span', { class: 'row-object', title: row.object.title }, row.object.text) : null,
|
|
810
|
+
row.time ? h('time', { class: 'row-time', datetime: row.time, title: fmtTime(row.time) }, fmtRel(row.time)) : null);
|
|
811
|
+
return h('div', { class: `strip-row${open ? ' open' : ''}`, 'data-row': row.key }, line);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** A press on a row: an object opens its popover; anything else opens in place, and closes what was open in its column. */
|
|
815
|
+
function pressStripRow(col, row, item) {
|
|
816
|
+
if (row.opens) { select(row.opens); return; }
|
|
817
|
+
const opening = stripOpen?.col !== col || stripOpen.key !== row.key;
|
|
818
|
+
stripOpen = opening ? { col, key: row.key } : null;
|
|
819
|
+
// The note that is open is the one the owner is on: the top bar names it and `Ask Keeper` is about it, as before.
|
|
820
|
+
if (opening && row.noteId) select({ kind: 'note', id: row.noteId, label: row.text }, { popover: false });
|
|
821
|
+
syncStripRowStates();
|
|
822
|
+
syncStripSheet({ opening });
|
|
823
|
+
// At the same time the objects it is about are found on the graph (Spec §6.2); closing it takes the marks away.
|
|
824
|
+
locateOnGraph(opening ? stripObjects(row, item) : []);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function openStripEntry() {
|
|
828
|
+
if (!stripOpen) return null;
|
|
829
|
+
const found = stripRows(stripOpen.col).find((x) => x.row.key === stripOpen.key);
|
|
830
|
+
return found ? { ...found, col: stripOpen.col } : null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function syncStripRowStates() {
|
|
834
|
+
if (!stripEl) return;
|
|
835
|
+
for (const el of stripEl.querySelectorAll('.strip-row')) {
|
|
836
|
+
const section = el.closest('section[data-col]');
|
|
837
|
+
const open = Boolean(stripOpen && section?.dataset.col === stripOpen.col && el.dataset.row === stripOpen.key);
|
|
838
|
+
el.classList.toggle('open', open);
|
|
839
|
+
el.querySelector('.item')?.setAttribute('aria-expanded', String(open));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function stripSheet(entry) {
|
|
844
|
+
const { col, row, item } = entry;
|
|
845
|
+
const title = [row.text, row.object?.text, row.time ? fmtTime(row.time) : ''].filter(Boolean).join(' · ');
|
|
846
|
+
return h('aside', { class: 'strip-sheet', 'data-row': row.key, 'data-col': col, 'aria-label': title, tabindex: '-1' },
|
|
847
|
+
h('div', { class: 'strip-sheet-head' },
|
|
848
|
+
h('div', { class: 'strip-sheet-heading' },
|
|
849
|
+
h('span', { class: 'row-marks' }, ...row.marks.map((m) => h('span', { class: `mk mk-${m.tone}`, title: m.title, role: 'img', 'aria-label': m.title }, m.glyph))),
|
|
850
|
+
h('strong', { title }, row.text),
|
|
851
|
+
row.object?.text ? h('small', { title: row.object.title }, row.object.text) : null),
|
|
852
|
+
h('button', { class: 'btn small strip-sheet-close', 'aria-label': 'Close reading panel', title: 'Close', onClick: () => closeStripSheet({ focus: true }) }, 'Close')),
|
|
853
|
+
h('div', { class: 'row-more strip-sheet-body' }, ...stripExpansion(col, row, item)));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Keep the sheet stable across polling. If its content is unchanged, its DOM and reading position stay untouched. */
|
|
857
|
+
function syncStripSheet({ opening = false } = {}) {
|
|
858
|
+
if (!stripEl?.isConnected) return;
|
|
859
|
+
const current = stripEl.querySelector(':scope > .strip-sheet');
|
|
860
|
+
const entry = openStripEntry();
|
|
861
|
+
stripEl.classList.toggle('has-sheet', Boolean(entry));
|
|
862
|
+
if (!entry) { current?.remove(); return; }
|
|
863
|
+
const next = stripSheet(entry);
|
|
864
|
+
if (current && current.dataset.row === next.dataset.row && visHtml(current.outerHTML) === visHtml(next.outerHTML)) return;
|
|
865
|
+
const top = current?.querySelector('.strip-sheet-body')?.scrollTop ?? 0;
|
|
866
|
+
if (current) current.replaceWith(next); else stripEl.append(next);
|
|
867
|
+
next.querySelector('.strip-sheet-body').scrollTop = top;
|
|
868
|
+
if (opening && !current) {
|
|
869
|
+
next.classList.add('opening');
|
|
870
|
+
const settled = () => next.classList.remove('opening');
|
|
871
|
+
next.addEventListener('animationend', settled, { once: true });
|
|
872
|
+
setTimeout(settled, 300); // reduced motion has no animationend
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function closeStripSheet({ focus = false } = {}) {
|
|
877
|
+
if (!stripOpen) return false;
|
|
878
|
+
const closing = stripOpen;
|
|
879
|
+
stripOpen = null;
|
|
880
|
+
stripEl?.querySelector(':scope > .strip-sheet')?.remove();
|
|
881
|
+
stripEl?.classList.remove('has-sheet');
|
|
882
|
+
syncStripRowStates();
|
|
883
|
+
locateOnGraph([]);
|
|
884
|
+
if (focus) stripEl?.querySelector(`section[data-col="${CSS.escape(closing.col)}"] [data-row="${CSS.escape(closing.key)}"] .item`)?.focus({ preventScroll: true });
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
/** The objects a row is about: what a note hangs on (a relation by its two ends), what a change reached and did not remove. */
|
|
888
|
+
function stripObjects(row, item) {
|
|
889
|
+
if (row.key.startsWith('change:')) { const c = changeOf(item); return c ? (c.affectsLabels ?? []).filter((a) => !a.removed).map((a) => a.id) : []; }
|
|
890
|
+
const relations = graph._data?.relations ?? [];
|
|
891
|
+
return (item.object?.objects ?? []).flatMap((o) => { if (o.kind !== 'relation') return [o.id]; const r = relations.find((x) => x.id === o.id); return r ? [r.from, r.to] : []; });
|
|
892
|
+
}
|
|
893
|
+
/** Find objects on the graph without taking the view over: they are marked, and brought into view only when none of them is in sight. */
|
|
894
|
+
let located = 0;
|
|
895
|
+
function locateOnGraph(ids) {
|
|
896
|
+
const token = ++located;
|
|
897
|
+
if (!onGraphNow()) return;
|
|
898
|
+
if (!ids.length) { graphCtl.highlight([]); return; }
|
|
899
|
+
const inSight = (id) => { const r = graphCtl.rectOf(id); return Boolean(r) && r.right - r.left > 1 && r.bottom - r.top > 1; };
|
|
900
|
+
if (!ids.some(inSight)) graphCtl.reveal(ids[0]); // folded away or out of the window: opened, and brought to the middle
|
|
901
|
+
graphCtl.highlight(ids);
|
|
902
|
+
// `reveal` takes its own flash away after a moment; the mark stays for as long as the row is open.
|
|
903
|
+
setTimeout(() => { if (token === located && onGraphNow()) graphCtl.highlight(ids); }, 1600);
|
|
904
|
+
}
|
|
905
|
+
/** The full record of a change row: Recent changes carries it; one of `Since last visit` is looked up there, or read. */
|
|
906
|
+
const changeOf = (item) => (item.propagationSummary ? item : stripOv?.recentChanges.find((c) => c.id === item.id) ?? stripData.get(`change:${item.id}`) ?? null);
|
|
907
|
+
|
|
908
|
+
/** What an open row holds. Read from the assets where the row itself does not carry it, and kept for the next rebuild. */
|
|
909
|
+
function stripExpansion(col, row, item) {
|
|
910
|
+
if (row.noteId) {
|
|
911
|
+
const x = stripData.get(row.key);
|
|
912
|
+
if (!x) { void loadStripRow(col, row.key, () => api(`/api/projects/${P()}/notes/${encodeURIComponent(row.noteId)}`), { reveal: true }); return [h('div', { class: 'faint' }, 'Loading…')]; }
|
|
913
|
+
return noteExpansion(col, row, item, x);
|
|
914
|
+
}
|
|
915
|
+
if (row.key.startsWith('change:')) {
|
|
916
|
+
const c = changeOf(item);
|
|
917
|
+
if (!c) { void loadStripRow(col, row.key, async () => (await api(`/api/projects/${P()}/changes`)).changes.find((k) => k.id === item.id) ?? { id: item.id, title: item.title, effect: item.effect, at: item.at, affectsLabels: [], propagationSummary: {} }, { reveal: true }); return [h('div', { class: 'faint' }, 'Loading…')]; }
|
|
918
|
+
return changeExpansion(c);
|
|
919
|
+
}
|
|
920
|
+
// A Follow up result with news: what the round made new, by kind, each where it is and a press away (CKC-07 AC-27).
|
|
921
|
+
if (item.kind === 'round' && item.news) return roundNewsExpansion(item);
|
|
922
|
+
// A scope question, a finished request, a Follow up result from before rounds counted their news: what the second line
|
|
923
|
+
// said, and the way to the rest.
|
|
924
|
+
const go = item.kind === 'scope-question' ? ['Answer in Project scope', () => navigate(state.projectId, 'scope')] : item.kind === 'round' ? ['Open the result', () => openRound(item.id)] : ['Open Keeper activity', () => openActivity()];
|
|
925
|
+
return [h('div', { class: 'more-title' }, item.label), item.detail ? h('p', { class: 'more-text' }, item.kind === 'scope-question' ? `Why it matters: ${item.detail}` : item.detail) : null,
|
|
926
|
+
h('div', { class: 'row wrap more-actions' }, h('button', { class: 'btn small', onClick: go[1] }, go[0]))];
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ── A Follow up result's news (Spec §3.8, §6.2; D79; CKC-07 AC-27, CKC-24 AC-15) ─────────────────────────────
|
|
930
|
+
/** The five kinds of news in the order the contract names them, each a fixed slot the round's count fills. */
|
|
931
|
+
const NEWS_KINDS = [
|
|
932
|
+
['breakpoints', 'Breakpoints newly lit'], ['sendbacks', 'Send-backs new or moved'], ['sixThings', 'Newly among the six things'],
|
|
933
|
+
['patches', 'Semantic patches confirmed'], ['notes', 'Notes written or updated'],
|
|
934
|
+
];
|
|
935
|
+
/** What a press on an entry opens: the jumps the workbench has. */
|
|
936
|
+
const NEWS_GO = {
|
|
937
|
+
process: (go) => `Show ${go.label} in the process view`, code: (go) => `Show the territory ${go.label} in Code`,
|
|
938
|
+
note: (go) => `Open the note “${go.label}”`, patch: (go) => `Open the semantic patch ${go.label}`,
|
|
939
|
+
};
|
|
940
|
+
function goNews(go) {
|
|
941
|
+
if (go.to === 'note') { select({ kind: 'note', id: go.id, label: go.label }); return; }
|
|
942
|
+
if (go.to === 'patch') { void kProcess.openPatch(go.id); return; }
|
|
943
|
+
// An object in the process view (the Project graph, from Code too), or a territory in Code: the way a breakpoint's
|
|
944
|
+
// or a send-back's `Show on object` goes (k-process.js).
|
|
945
|
+
void kProcess.locateTarget(go.id, go.to === 'code');
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* The news of a round by kind, a kind only when it has something: each entry says what it is, where it is (its
|
|
949
|
+
* position), and what changed for it this round; a press on it goes there. `leave` closes what it is drawn in first.
|
|
950
|
+
*/
|
|
951
|
+
function newsBlock(news, leave) {
|
|
952
|
+
return h('div', { class: 'round-news' }, ...NEWS_KINDS.filter(([kind]) => news[kind]?.length).map(([kind, title]) => h('section', { class: 'news-kind', 'data-news': kind },
|
|
953
|
+
h('h4', {}, `${title} (${news[kind].length})`),
|
|
954
|
+
h('ul', { class: 'news-list' }, ...news[kind].map((i) => h('li', { 'data-news-id': i.id, 'data-go': i.go?.to ?? '' },
|
|
955
|
+
i.go ? h('button', { class: 'text-btn news-go', title: NEWS_GO[i.go.to]?.(i.go) ?? i.position, onClick: () => { leave?.(); goNews(i.go); } }, i.label) : h('span', { class: 'news-label' }, i.label),
|
|
956
|
+
h('span', { class: 'news-pos' }, i.position),
|
|
957
|
+
// What changed for it this round: a stage or a version as a tag; a breakpoint's is the sentence of why it is lit.
|
|
958
|
+
i.detail ? kind === 'breakpoints' ? h('span', { class: 'news-why' }, i.detail) : h('span', { class: 'tag news-detail' }, i.detail) : null))))));
|
|
959
|
+
}
|
|
960
|
+
function roundNewsExpansion(item) {
|
|
961
|
+
const news = item.news;
|
|
962
|
+
const name = item.roundName ?? null;
|
|
963
|
+
return [
|
|
964
|
+
h('div', { class: 'more-title' }, item.label),
|
|
965
|
+
newsBlock(news, () => closeStripSheet()),
|
|
966
|
+
// The objects its judgements left on the old understanding (§5.5): counted here, named in the result.
|
|
967
|
+
item.behind ? h('p', { class: 'more-text round-behind' }, `${item.behind} object${item.behind === 1 ? '' : 's'} still on the old understanding${item.unassigned ? `, ${item.unassigned} with no holder` : ''}: the round's result names them.`) : null,
|
|
968
|
+
news.complete === false ? h('p', { class: 'more-text news-incomplete' }, 'This round began before rounds kept where things stood at their start: the send-backs it moved, what it newly tagged among the six things and the patches it confirmed are not counted.') : null,
|
|
969
|
+
// The foot stays below the list in the reading sheet (styles.css .strip-sheet-body>.more-foot), as a note's does.
|
|
970
|
+
h('div', { class: 'more-foot' }, h('div', { class: 'row wrap more-actions' }, h('button', { class: 'btn small', onClick: () => openRound(item.id, { name, news }) }, 'Open the result'))),
|
|
971
|
+
];
|
|
972
|
+
}
|
|
973
|
+
async function loadStripRow(col, key, read, { reveal = false } = {}) {
|
|
974
|
+
let data;
|
|
975
|
+
try { data = await read(); } catch (e) { data = { error: e.message }; }
|
|
976
|
+
if (JSON.stringify(stripData.get(key)) === JSON.stringify(data)) return;
|
|
977
|
+
stripData.set(key, data);
|
|
978
|
+
if (stripOpen?.col === col && stripOpen.key === key) syncStripSheet();
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* A note, opened in place: everything its popover page holds (details.parts is the one description of a note's
|
|
982
|
+
* details) — the whole title and preview, what it asks and its state, how it came about with the changes it is about,
|
|
983
|
+
* the owner's choice when it asks for one, the objects it hangs on, the ways to answer, the walk through a change,
|
|
984
|
+
* and `Details` for the whole note. The `On` line is `parts.on`, the same one the popover and the full details draw.
|
|
985
|
+
*/
|
|
986
|
+
function noteExpansion(col, row, _item, x) {
|
|
987
|
+
if (x.error) return [h('div', { class: 'faint' }, x.error)];
|
|
988
|
+
const sel = { kind: 'note', id: x.id, label: row.text };
|
|
989
|
+
const leave = () => closeStripSheet();
|
|
990
|
+
const parts = details.parts(sel, x, { surface: 'strip', leave, note: (n) => { leave(); select(n); }, object: (o) => { leave(); select(o); }, redraw: () => loadStripRow(col, row.key, () => api(`/api/projects/${P()}/notes/${encodeURIComponent(x.id)}`)) });
|
|
991
|
+
return [
|
|
992
|
+
h('div', { class: 'more-title' }, parts.title),
|
|
993
|
+
h('div', { class: 'row wrap more-state' }, h('small', { class: 'faint' }, parts.sub), ...parts.tags.childNodes),
|
|
994
|
+
parts.sentence ? h('p', { class: 'more-text' }, parts.sentence) : null,
|
|
995
|
+
...parts.extras,
|
|
996
|
+
parts.on,
|
|
997
|
+
// Keep the response actions and Details in one sticky foot. If Details alone is sticky it can cover the action
|
|
998
|
+
// row above it while the strip is near the bottom of the window, leaving visible buttons that cannot receive a
|
|
999
|
+
// real mouse event.
|
|
1000
|
+
h('div', { class: 'more-foot' },
|
|
1001
|
+
h('div', { class: 'row wrap more-actions' }, ...parts.actions),
|
|
1002
|
+
h('button', { class: 'btn small primary', title: 'The whole note, in a larger window', onClick: () => details.full(sel) }, 'Details')),
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* A change, opened in place: the piece of work and when, each of its net changes by effect and title (an older record:
|
|
1007
|
+
* its own effect, title and summary), what it removed from the project's current version — struck through, the only
|
|
1008
|
+
* place that still appears (Spec §2.1, §6.3; CKC-09 AC-32) — how far it has been followed, the objects it reached,
|
|
1009
|
+
* `Show affected`, and the way to the same record in the Change log.
|
|
1010
|
+
*/
|
|
1011
|
+
function changeExpansion(c) {
|
|
1012
|
+
const items = c.work ? c.items ?? [] : [];
|
|
1013
|
+
const reached = c.affectsLabels ?? [];
|
|
1014
|
+
return [
|
|
1015
|
+
h('div', { class: 'more-title' }, c.work ? c.work.label : c.title),
|
|
1016
|
+
h('div', { class: 'row wrap more-state' }, c.work ? h('span', { class: 'tag' }, c.work.kind) : effectTag(c.effect), h('small', { class: 'faint' }, fmtTime(c.at)), c.work?.openEnded ? h('small', { class: 'faint' }, 'still going when the round began') : null,
|
|
1017
|
+
c.by?.identity === 'Decision' ? h('span', { class: 'tag amber' }, 'Owner decision') : c.by?.author?.name ? h('span', { class: 'tag' }, c.by.author.name) : null),
|
|
1018
|
+
items.length ? h('div', { class: 'more-items' }, ...items.map((i) => h('div', {}, effectTag(i.effect), ' ', i.title))) : !c.work && c.summary ? h('p', { class: 'more-text' }, c.summary) : null,
|
|
1019
|
+
c.removed?.length ? h('div', { class: 'removed-line' }, 'Removed: ', ...c.removed.flatMap((x, i) => [i ? ', ' : null, h('s', { title: REMOVED_TITLE }, x.label)])) : null,
|
|
1020
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'Propagation'), h('span', { class: 'muted' }, propagationText(c.propagationSummary))),
|
|
1021
|
+
reached.length ? h('div', { class: 'row wrap more-objects' }, h('small', {}, 'Affects'), ...reached.map((a) => (a.removed ? h('span', { class: 'muted' }, namedObject(a)) : h('button', { class: 'text-btn', title: 'Open this object', onClick: () => select({ kind: 'node', id: a.id, label: a.label }) }, a.label)))) : null,
|
|
1022
|
+
h('div', { class: 'row wrap more-actions' },
|
|
1023
|
+
h('button', { class: 'btn small', title: 'Bring the objects this change reached into view on the graph, and mark them', onClick: () => { closeStripSheet(); showAffected(c); } }, 'Show affected'),
|
|
1024
|
+
h('button', { class: 'btn small', title: 'The same record in the Change log, with its sources and what it said before and after', onClick: () => { closeStripSheet(); navigate(state.projectId, 'changes', encodeURIComponent(c.id)); } }, 'Open in Change log')),
|
|
1025
|
+
];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** One column drawn again where it stands: how far it is scrolled is kept, and focus goes to the row that was pressed. */
|
|
1029
|
+
function redrawStripColumn(col, { focusKey = null, revealKey = focusKey } = {}) {
|
|
1030
|
+
const current = stripEl?.querySelector(`:scope > section[data-col="${col}"]`);
|
|
1031
|
+
if (!current || !stripOv) return;
|
|
1032
|
+
const focus = focusKey ? { key: focusKey, at: -1 } : focusInStrip(current);
|
|
1033
|
+
const next = stripSection(col);
|
|
1034
|
+
const top = current.querySelector('.items').scrollTop;
|
|
1035
|
+
current.replaceWith(next);
|
|
1036
|
+
fitStripToDock();
|
|
1037
|
+
const list = next.querySelector('.items');
|
|
1038
|
+
list.scrollTop = top;
|
|
1039
|
+
restoreStripFocus(next, focus);
|
|
1040
|
+
// The row that was pressed stays in sight: all of it when the column is tall enough, else from its own line down.
|
|
1041
|
+
const rowEl = revealKey ? next.querySelector(`[data-row="${CSS.escape(revealKey)}"]`) : null;
|
|
1042
|
+
if (rowEl) {
|
|
1043
|
+
const r = rowEl.getBoundingClientRect(), l = list.getBoundingClientRect();
|
|
1044
|
+
if (r.top < l.top || r.height > l.height) list.scrollTop += r.top - l.top;
|
|
1045
|
+
else if (r.bottom > l.bottom) list.scrollTop += r.bottom - l.bottom;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const STRIP_FOCUSABLE = 'button:not([disabled]), [tabindex="0"]';
|
|
1049
|
+
/** Where focus is inside a column: which row, and which of its buttons (−1: the row's own line). */
|
|
1050
|
+
function focusInStrip(section) {
|
|
1051
|
+
const a = document.activeElement;
|
|
1052
|
+
const rowEl = a && section.contains(a) ? a.closest('[data-row]') : null;
|
|
1053
|
+
return rowEl ? { key: rowEl.dataset.row, at: a.classList.contains('item') ? -1 : [...rowEl.querySelectorAll(STRIP_FOCUSABLE)].indexOf(a) } : null;
|
|
1054
|
+
}
|
|
1055
|
+
function restoreStripFocus(section, focus) {
|
|
1056
|
+
if (!focus) return;
|
|
1057
|
+
const rowEl = section.querySelector(`[data-row="${CSS.escape(focus.key)}"]`);
|
|
1058
|
+
const to = rowEl ? (focus.at >= 0 ? rowEl.querySelectorAll(STRIP_FOCUSABLE)[focus.at] : null) ?? rowEl.querySelector('.item') : null;
|
|
1059
|
+
to?.focus({ preventScroll: true });
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* A strip built from a new overview takes the place of the one on the page, column by column and only where it differs
|
|
1063
|
+
* (relative clocks aside): an unchanged column is not touched, a changed one keeps its scroll position and its focus.
|
|
1064
|
+
*/
|
|
1065
|
+
function swapStrip(next) {
|
|
1066
|
+
const current = stripEl;
|
|
1067
|
+
if (!current?.isConnected) { stripEl = next; return next; }
|
|
1068
|
+
// `over-dock` is put on a column after it is drawn (fitStripToDock); it is not a difference in what the column says.
|
|
1069
|
+
const norm = (el) => { const c = el.cloneNode(true); c.classList.remove('over-dock'); if (!c.classList.length) c.removeAttribute('class'); return visHtml(c.outerHTML); };
|
|
1070
|
+
if (current.className !== next.className) current.className = next.className; // two columns, or three
|
|
1071
|
+
const have = new Map([...current.querySelectorAll(':scope > section[data-col]')].map((c) => [c.dataset.col, c]));
|
|
1072
|
+
[...next.querySelectorAll(':scope > section[data-col]')].forEach((want, i) => {
|
|
1073
|
+
const old = have.get(want.dataset.col);
|
|
1074
|
+
have.delete(want.dataset.col);
|
|
1075
|
+
// A column that is already there is never taken out and put back: that would lose how far it is scrolled.
|
|
1076
|
+
if (!old) { current.insertBefore(want, current.children[i] ?? null); return; } // `Since last visit` has come
|
|
1077
|
+
if (norm(old) === norm(want)) return;
|
|
1078
|
+
const top = old.querySelector('.items')?.scrollTop ?? 0;
|
|
1079
|
+
const focus = focusInStrip(old);
|
|
1080
|
+
old.replaceWith(want);
|
|
1081
|
+
const items = want.querySelector('.items');
|
|
1082
|
+
if (items) items.scrollTop = top;
|
|
1083
|
+
restoreStripFocus(want, focus);
|
|
1084
|
+
});
|
|
1085
|
+
for (const gone of have.values()) gone.remove();
|
|
1086
|
+
fitStripToDock();
|
|
1087
|
+
syncStripRowStates();
|
|
1088
|
+
syncStripSheet();
|
|
1089
|
+
paintDrawerBar();
|
|
1090
|
+
return stripEl;
|
|
1091
|
+
}
|
|
1092
|
+
/** After an asset event: what the open rows show is read again, and a row is drawn again only if it differs. */
|
|
1093
|
+
function refreshOpenStripRows() {
|
|
1094
|
+
if (!stripOpen) return;
|
|
1095
|
+
const { col, key } = stripOpen;
|
|
1096
|
+
if (key.startsWith('note:')) void loadStripRow(col, key, () => api(`/api/projects/${P()}/notes/${encodeURIComponent(key.slice(5))}`));
|
|
1097
|
+
else if (key.startsWith('change:') && stripData.has(key)) void loadStripRow(col, key, async () => (await api(`/api/projects/${P()}/changes`)).changes.find((k) => k.id === key.slice(7)) ?? stripData.get(key));
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* The pill at the bottom right lies over the strip's corner (Spec §6.1: it never lies on text). No column is kept empty
|
|
1101
|
+
* for it: the strip takes the whole width, and only a column the pill actually stands over ends its list above the
|
|
1102
|
+
* pill — usually the last one, two of them when the main view is very narrow. Asked again whenever the strip is drawn
|
|
1103
|
+
* or the main view changes size (the Keeper docking moves the pill). Such a column also keeps the strip tall enough
|
|
1104
|
+
* that its header stays clear of the pill.
|
|
1105
|
+
*/
|
|
1106
|
+
function fitStripToDock() {
|
|
1107
|
+
if (!stripEl?.isConnected) return;
|
|
1108
|
+
const p = document.querySelector('#keeper-dock .dock-pill')?.getBoundingClientRect();
|
|
1109
|
+
let need = 0;
|
|
1110
|
+
for (const s of stripEl.querySelectorAll(':scope > section[data-col]')) {
|
|
1111
|
+
const r = s.getBoundingClientRect();
|
|
1112
|
+
const under = Boolean(p && p.width) && r.left < p.right + 2 && p.left - 2 < r.right && r.top < p.bottom && p.top < r.bottom;
|
|
1113
|
+
s.classList.toggle('over-dock', under);
|
|
1114
|
+
if (under) need = Math.max(need, Math.ceil(s.querySelector('header').getBoundingClientRect().height + (r.bottom - p.top) + 8 + STRIP_SIZE.step));
|
|
1115
|
+
}
|
|
1116
|
+
stripEl.style.minHeight = need > STRIP_SIZE.min ? `${need}px` : '';
|
|
1117
|
+
}
|
|
1118
|
+
let stripWatch = null;
|
|
1119
|
+
function watchStripDock(main) {
|
|
1120
|
+
if (!('ResizeObserver' in window) || stripWatch?.main === main) return;
|
|
1121
|
+
stripWatch?.observer.disconnect();
|
|
1122
|
+
const observer = new ResizeObserver(() => fitStripToDock());
|
|
1123
|
+
observer.observe(main);
|
|
1124
|
+
stripWatch = { main, observer };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The strip's height once the drawer is pulled up (Spec §6.2 "拉起之后……可以拖高"): the owner drags its top edge — or,
|
|
1129
|
+
* with focus on the edge, presses the arrow keys; Home and End go to the limits and a double-click back to the default.
|
|
1130
|
+
* The height is this browser's (a per-viewer convenience, like the Keeper panel's width), between what shows a row or
|
|
1131
|
+
* two and what leaves the main panel above it enough to be used. The graph follows by itself: it watches its own
|
|
1132
|
+
* container (graph.js onResize).
|
|
1133
|
+
*/
|
|
1134
|
+
const STRIP_KEY = 'pk.strip.height';
|
|
1135
|
+
const STRIP_SIZE = Object.freeze({ initial: 260, min: 120, leaveAbove: 240, step: 27 });
|
|
1136
|
+
function rememberedStripHeight() { try { const v = Number(localStorage.getItem(STRIP_KEY)); return Number.isFinite(v) && v > 0 ? v : STRIP_SIZE.initial; } catch { return STRIP_SIZE.initial; } }
|
|
1137
|
+
/** The room the drawer and the main panel share: the main panel keeps `leaveAbove` of it. */
|
|
1138
|
+
function stripRoom() {
|
|
1139
|
+
const panel = document.getElementById('main-panel');
|
|
1140
|
+
const open = stripEl?.isConnected && drawerOpen ? stripEl.getBoundingClientRect().height : 0;
|
|
1141
|
+
return (panel?.clientHeight || 748) + open;
|
|
1142
|
+
}
|
|
1143
|
+
function setStripHeight(px, { remember = true, handle = document.querySelector('#drawer .strip-resizer') } = {}) {
|
|
1144
|
+
const max = Math.max(STRIP_SIZE.min, stripRoom() - STRIP_SIZE.leaveAbove);
|
|
1145
|
+
const height = Math.round(Math.min(max, Math.max(STRIP_SIZE.min, px)));
|
|
1146
|
+
document.documentElement.style.setProperty('--pk-strip-h', `${height}px`);
|
|
1147
|
+
if (handle) { handle.setAttribute('aria-valuenow', String(height)); handle.setAttribute('aria-valuemin', String(STRIP_SIZE.min)); handle.setAttribute('aria-valuemax', String(max)); }
|
|
1148
|
+
if (remember) { try { localStorage.setItem(STRIP_KEY, String(height)); } catch { /* a convenience only */ } }
|
|
1149
|
+
return height;
|
|
1150
|
+
}
|
|
1151
|
+
function stripResizer() {
|
|
1152
|
+
const el = h('div', { class: 'strip-resizer', role: 'separator', 'aria-orientation': 'horizontal', 'aria-label': 'Height of the drawer', tabindex: 0, title: 'Drag to make the drawer taller or lower (or use the arrow keys; double-click for the default height)' });
|
|
1153
|
+
const now = () => stripEl?.getBoundingClientRect().height ?? rememberedStripHeight();
|
|
1154
|
+
let dragging = null;
|
|
1155
|
+
el.addEventListener('pointerdown', (e) => { dragging = e.pointerId; el.setPointerCapture(e.pointerId); el.classList.add('sizing'); e.preventDefault(); });
|
|
1156
|
+
el.addEventListener('pointermove', (e) => { if (dragging === e.pointerId && stripEl) setStripHeight(stripEl.getBoundingClientRect().bottom - e.clientY, { remember: false, handle: el }); });
|
|
1157
|
+
const done = (e) => { if (dragging !== e.pointerId) return; dragging = null; el.classList.remove('sizing'); setStripHeight(now(), { handle: el }); };
|
|
1158
|
+
el.addEventListener('pointerup', done);
|
|
1159
|
+
el.addEventListener('pointercancel', done);
|
|
1160
|
+
el.addEventListener('dblclick', () => setStripHeight(STRIP_SIZE.initial, { handle: el }));
|
|
1161
|
+
el.addEventListener('keydown', (e) => {
|
|
1162
|
+
const to = e.key === 'ArrowUp' ? now() + STRIP_SIZE.step : e.key === 'ArrowDown' ? now() - STRIP_SIZE.step : e.key === 'Home' ? Infinity : e.key === 'End' ? 0 : null;
|
|
1163
|
+
if (to === null) return;
|
|
1164
|
+
e.preventDefault();
|
|
1165
|
+
setStripHeight(to, { handle: el });
|
|
1166
|
+
});
|
|
1167
|
+
return el;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ── The drawer (Spec §6.1, §6.2; D100; CKC-09 AC-38, AC-45) ──────────────────────────────────────────────────────
|
|
1171
|
+
/**
|
|
1172
|
+
* The strip lives in the drawer, a panel of its own under the main panel. By default it is one line that says how many
|
|
1173
|
+
* rows `Notes (attention)` and `Recent changes` have, whether there is a `Since last visit`, and how many attention
|
|
1174
|
+
* notes are marked ❓ on objects; a press on the line pulls it up to the columns (as tall as the owner last dragged
|
|
1175
|
+
* it), a second press puts it away. It is one line again on every visit (平时只占一行): only while the page is open is
|
|
1176
|
+
* "pulled up" kept, across Graph, List and Code.
|
|
1177
|
+
*/
|
|
1178
|
+
let drawerOpen = false;
|
|
1179
|
+
const drawerEl = () => document.getElementById('drawer');
|
|
1180
|
+
/**
|
|
1181
|
+
* The notes marked ❓ on objects, for the drawer's count. One rule with the marks the Graph and the List draw (CE): a current
|
|
1182
|
+
* note still in `Notes (attention)` (graph-view.ts `inAttention`) that hangs on objects, not on the whole project. The marks
|
|
1183
|
+
* come from the graph payload (`noteAttention` on each object, a path's note on its top object); the count here is of
|
|
1184
|
+
* notes, from the overview, so a note on two objects counts once.
|
|
1185
|
+
*/
|
|
1186
|
+
const onObjectNotes = (ov) => objectAttentionNotes(ov);
|
|
1187
|
+
/** Whether the reading sheet over the drawer is open (app.js: Escape closes it first). */
|
|
1188
|
+
export function stripSheetOpen() { return Boolean(stripOpen) && Boolean(stripEl?.isConnected); }
|
|
1189
|
+
/** Another view than the Project graph: the drawer is put away, and only keeps the band beside the pill. */
|
|
1190
|
+
export function hideDrawer() {
|
|
1191
|
+
const d = drawerEl();
|
|
1192
|
+
if (!d) return;
|
|
1193
|
+
closeStripSheet();
|
|
1194
|
+
d.replaceChildren();
|
|
1195
|
+
d.classList.add('empty');
|
|
1196
|
+
d.classList.remove('open');
|
|
1197
|
+
stripEl = null;
|
|
1198
|
+
}
|
|
1199
|
+
function toggleDrawer(open = !drawerOpen) {
|
|
1200
|
+
drawerOpen = open;
|
|
1201
|
+
if (!open) closeStripSheet();
|
|
1202
|
+
drawerEl()?.classList.toggle('open', open);
|
|
1203
|
+
paintDrawerBar();
|
|
1204
|
+
if (open) setStripHeight(rememberedStripHeight(), { remember: false });
|
|
1205
|
+
fitStripToDock();
|
|
1206
|
+
popover.reposition({ measure: true });
|
|
1207
|
+
}
|
|
1208
|
+
/** The ❓ count: how many attention notes wait on objects; pressed, the Graph and the List show only those objects. */
|
|
1209
|
+
function attentionCountButton(n) {
|
|
1210
|
+
const on = Boolean(graphFilters().attention);
|
|
1211
|
+
return h('button', {
|
|
1212
|
+
class: `attn-count${on ? ' active' : ''}`, 'aria-pressed': String(on),
|
|
1213
|
+
title: on ? 'Showing only the objects with an attention note (❓). Press to see everything again.' : `${n} attention note${n === 1 ? '' : 's'} ${n === 1 ? 'is' : 'are'} marked ❓ on ${n === 1 ? 'its object' : 'their objects'}, not listed here. Press to see only those objects in the Graph and the List.`,
|
|
1214
|
+
onClick: (e) => { e.stopPropagation(); setAttentionFilter(!graphFilters().attention); },
|
|
1215
|
+
}, h('span', { class: 'attn-mark', 'aria-hidden': 'true' }, '❓'), h('b', {}, String(n)), ' on objects');
|
|
1216
|
+
}
|
|
1217
|
+
function drawerBar(ov) {
|
|
1218
|
+
const s = drawerSummary(ov, onObjectNotes(ov));
|
|
1219
|
+
const part = (name, n) => h('span', { class: 'drawer-part' }, h('span', { class: 'dp-name' }, name), ' ', h('b', {}, String(n)));
|
|
1220
|
+
const sep = () => h('span', { class: 'drawer-sep', 'aria-hidden': 'true' }, '·');
|
|
1221
|
+
return h('div', { class: 'drawer-bar' },
|
|
1222
|
+
h('button', { class: 'drawer-toggle', 'aria-expanded': String(drawerOpen), 'aria-controls': 'drawer-strip', title: drawerOpen ? 'Put the notes and changes away' : 'Pull up the notes and changes', onClick: () => toggleDrawer() },
|
|
1223
|
+
h('span', { class: 'drawer-caret', 'aria-hidden': 'true' }, drawerOpen ? '▾' : '▴'),
|
|
1224
|
+
part('Notes (attention)', s.attention), sep(), part('Recent changes', s.changes),
|
|
1225
|
+
s.since ? [sep(), h('span', { class: 'drawer-part since' }, 'Since last visit')] : null),
|
|
1226
|
+
s.onObjects ? attentionCountButton(s.onObjects) : null);
|
|
1227
|
+
}
|
|
1228
|
+
function paintDrawerBar() {
|
|
1229
|
+
const bar = drawerEl()?.querySelector(':scope > .drawer-bar');
|
|
1230
|
+
if (!bar || !stripOv) return;
|
|
1231
|
+
const next = drawerBar(stripOv);
|
|
1232
|
+
if (visHtml(bar.outerHTML) === visHtml(next.outerHTML)) return;
|
|
1233
|
+
const a = document.activeElement;
|
|
1234
|
+
const focused = bar.contains(a) ? (a.classList.contains('attn-count') ? '.attn-count' : '.drawer-toggle') : null;
|
|
1235
|
+
bar.replaceWith(next);
|
|
1236
|
+
if (focused) next.querySelector(focused)?.focus({ preventScroll: true });
|
|
1237
|
+
}
|
|
1238
|
+
/** The strip in the drawer, under the Graph, the List and Code alike, with the edge it is dragged by. */
|
|
1239
|
+
function mountStrip(main, ov) {
|
|
1240
|
+
const d = drawerEl();
|
|
1241
|
+
if (!d) return;
|
|
1242
|
+
const handle = stripResizer();
|
|
1243
|
+
stripEl = strip(ov);
|
|
1244
|
+
d.replaceChildren(handle, drawerBar(ov), stripEl);
|
|
1245
|
+
d.classList.remove('empty');
|
|
1246
|
+
d.classList.toggle('open', drawerOpen);
|
|
1247
|
+
setStripHeight(rememberedStripHeight(), { remember: false, handle });
|
|
1248
|
+
fitStripToDock();
|
|
1249
|
+
syncStripSheet();
|
|
1250
|
+
watchStripDock(main);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The ❓ count pressed (Spec §6.2, §6.3's filter; CKC-09 AC-38): the Graph and the List see only the objects the
|
|
1255
|
+
* attention notes hang on, with what they need to be understood. It is the `❓ Waiting on you` filter (graph-tools.js),
|
|
1256
|
+
* so the Filter button counts it and `Clear filters` puts it out of force too. The ids follow the overview: a note
|
|
1257
|
+
* answered leaves, and its object with it.
|
|
1258
|
+
*/
|
|
1259
|
+
function syncAttentionTargets() {
|
|
1260
|
+
const f = graphFilters();
|
|
1261
|
+
const ids = stripOv ? attentionTargets(onObjectNotes(stripOv), graph._data?.relations ?? []) : [];
|
|
1262
|
+
const same = JSON.stringify(ids) === JSON.stringify(f.attentionIds ?? []);
|
|
1263
|
+
f.attentionIds = ids;
|
|
1264
|
+
if (!same && f.attention) graphCtl?.setFilters(f);
|
|
1265
|
+
}
|
|
1266
|
+
function setAttentionFilter(on) {
|
|
1267
|
+
const f = graphFilters();
|
|
1268
|
+
f.attention = Boolean(on);
|
|
1269
|
+
syncAttentionTargets();
|
|
1270
|
+
graphCtl?.setFilters(f);
|
|
1271
|
+
if (state.graphMode === 'list') void refreshList();
|
|
1272
|
+
controlFilterSync?.();
|
|
1273
|
+
paintDrawerBar();
|
|
1274
|
+
if (stripOv && stripEl?.isConnected) redrawStripColumn('attention');
|
|
1275
|
+
}
|
|
1276
|
+
const askTag = (ask) => ask ? h('span', { class: `tag ${ask === 'For your decision' ? 'amber' : ask === 'Worth discussing' ? 'blue' : ''}` }, ask) : null;
|
|
1277
|
+
/** How a note came about (Spec §4.1): one of four, so the owner can tell which reminders a change follow-up brought. */
|
|
1278
|
+
const NOTE_ORIGINS = ['Product re-look', 'Change follow-up', 'Investigation', 'Owner question'];
|
|
1279
|
+
const originTag = (cameFrom) => cameFrom?.kind ? h('span', { class: 'tag origin', title: `Came from: ${cameFrom.kind}${cameFrom.changes?.length ? ` — ${cameFrom.changes.map((c) => c.title).join('; ')}` : ''}` }, cameFrom.kind) : null;
|
|
1280
|
+
const effectTag = (e) => h('span', { class: `tag ${e === 'Approved' || e === 'Completed' || e === 'Added' ? 'green' : e === 'Replaced' || e === 'Abandoned' ? 'red' : e === 'Deferred' ? 'amber' : ''}` }, e);
|
|
1281
|
+
const propagationText = (s) => { const parts = Object.entries(s || {}).map(([k, v]) => `${v} ${k.toLowerCase()}`); return parts.length ? parts.join(' · ') : 'no downstream entries'; };
|
|
1282
|
+
/**
|
|
1283
|
+
* `Show affected` (D48): bring the objects this change touched into view and mark them. The scale does not change
|
|
1284
|
+
* and nothing is dimmed; whether each of them followed the change is the Keeper's judgement, reported in the
|
|
1285
|
+
* change's own details and, when the owner has to act, as a note.
|
|
1286
|
+
*/
|
|
1287
|
+
function showAffected(c) {
|
|
1288
|
+
const named = c.affectsLabels ?? (c.affects ?? []).map((id) => ({ id }));
|
|
1289
|
+
const ids = named.filter((a) => !a.removed).map((a) => a.id);
|
|
1290
|
+
if (state.view !== 'graph') { navigate(state.projectId, 'graph', `change/${encodeURIComponent(c.id)}`); return; }
|
|
1291
|
+
if (state.graphMode === 'list') { toast('Switch to Graph to see the affected objects'); return; }
|
|
1292
|
+
const allRemoved = named.length > 0 && named.every((a) => a.removed);
|
|
1293
|
+
if (graphCtl) { const n = graphCtl.showAffected(ids); toast(n ? `${n} affected object${n === 1 ? '' : 's'} highlighted` : allRemoved ? 'What it affected was removed from the project’s current version, so it is not on the graph' : 'None of the affected objects is on the graph'); }
|
|
1294
|
+
else navigate(state.projectId, 'graph', `change/${encodeURIComponent(c.id)}`);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* The legend lists only what the picture shows right now, with counts, on one line; `More` opens what each means
|
|
1299
|
+
* (D53, the WorkflowKeeper rule: only what would be misread without it, where the misreading would change what you
|
|
1300
|
+
* do). Kinds written on the objects and relation types written on the lines are not in it.
|
|
1301
|
+
*/
|
|
1302
|
+
// The icons take the graph's palette (graph-palette.js), so they follow the theme with the picture they explain.
|
|
1303
|
+
const LEGEND_ICON = {
|
|
1304
|
+
doc: (c, p) => `<svg width="16" height="12"><polygon points="1,1 11,1 15,4 15,11 1,11" fill="${p.nodeBg}" stroke="${c}" stroke-width="1.5"/></svg>`,
|
|
1305
|
+
hex: (c, p) => `<svg width="18" height="12"><polygon points="4,1 14,1 17,6 14,11 4,11 1,6" fill="${p.nodeBg}" stroke="${c}" stroke-width="1.5"/></svg>`,
|
|
1306
|
+
folder: (c, p) => `<svg width="18" height="13"><path d="M1,11 V3 Q1,1 3,1 H7 L9,3 H15 Q17,3 17,5 V10 Q17,12 15,12 H3 Q1,12 1,11 Z" fill="${p.nodeBg}" stroke="${c}" stroke-width="1.4"/></svg>`,
|
|
1307
|
+
ellipse: (c, p) => `<svg width="18" height="12"><ellipse cx="9" cy="6" rx="8" ry="5" fill="${p.nodeBg}" stroke="${c}" stroke-width="1.4"/></svg>`,
|
|
1308
|
+
line: (c, p) => `<svg width="20" height="8"><line x1="1" y1="4" x2="19" y2="4" stroke="${p.edgeOther}" stroke-width="2"/></svg>`,
|
|
1309
|
+
dotted: (c, p) => `<svg width="20" height="8"><line x1="1" y1="4" x2="19" y2="4" stroke="${p.edgeOther}" stroke-width="2" stroke-dasharray="2 3"/></svg>`,
|
|
1310
|
+
// A work item's copy in another module it serves: its hexagon faded, with a dashed frame (the only dashed frame).
|
|
1311
|
+
dashed: (c, p) => `<svg width="18" height="12" opacity="0.5"><polygon points="4,1 14,1 17,6 14,11 4,11 1,6" fill="${p.nodeBg}" stroke="${c}" stroke-width="1.5" stroke-dasharray="3 2"/></svg>`,
|
|
1312
|
+
// An inferred object: the word on it.
|
|
1313
|
+
inferred: (c, p) => `<svg width="34" height="12"><rect x="0.5" y="0.5" width="33" height="11" rx="3" fill="${p.chipBg}" stroke="${p.edgeOther}"/><text x="17" y="9" font-size="7.5" text-anchor="middle" fill="${p.nodeMuted}">Inferred</text></svg>`,
|
|
1314
|
+
questioned: (c, p) => `<svg width="20" height="8"><line x1="1" y1="4" x2="19" y2="4" stroke="${p.questioned}" stroke-width="2"/></svg>`,
|
|
1315
|
+
count: () => '<span class="lg-count">↗n</span>',
|
|
1316
|
+
stack: (c, p) => `<svg width="22" height="13"><rect x="4.5" y="0.5" width="16" height="9" rx="2" fill="${p.groupBack}" stroke="${p.groupBackBorder}"/><rect x="1" y="3" width="16" height="9" rx="2" fill="${p.groupBg}" stroke="${p.groupBorder}"/></svg>`,
|
|
1317
|
+
band: (c) => `<svg width="24" height="12"><rect x="1" y="2" width="22" height="8" rx="3" fill="${c}" fill-opacity="0.15" stroke="${c}" stroke-opacity="0.6"/></svg>`,
|
|
1318
|
+
ring: (c) => `<svg width="24" height="12"><rect x="1" y="1" width="22" height="10" rx="5" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-width="1.4"/></svg>`,
|
|
1319
|
+
};
|
|
1320
|
+
function renderLegend(el, items, expanded) {
|
|
1321
|
+
const shown = items.filter((i, k) => !i.sep || (k > 0 && k < items.length - 1 && !items[k - 1].sep));
|
|
1322
|
+
const icon = (i) => {
|
|
1323
|
+
const s = h('span', { class: 'lg-icon' });
|
|
1324
|
+
if (i.icon === 'mark') { s.className = `lg-mark mark-${i.mark.key}`; s.textContent = i.mark.glyph; return s; }
|
|
1325
|
+
s.innerHTML = LEGEND_ICON[i.icon]?.(i.color, currentPalette()) ?? '';
|
|
1326
|
+
return s;
|
|
1327
|
+
};
|
|
1328
|
+
const next = h('div', { class: `legend${expanded ? ' expanded' : ''}` },
|
|
1329
|
+
h('div', { class: 'lg-items' }, ...shown.map((i) => i.sep ? h('span', { class: 'sep' }) : h('span', { class: 'lg-item', title: i.desc }, icon(i), h('span', {}, `${i.label}${i.count ? ` (${i.count})` : ''}`), h('span', { class: 'lg-desc' }, ` — ${i.desc}`)))),
|
|
1330
|
+
h('button', { class: 'text-btn lg-more', onClick: (e) => { const box = e.currentTarget.parentElement; box.classList.toggle('expanded'); legendOpen = box.classList.contains('expanded'); e.currentTarget.textContent = legendOpen ? 'Less' : 'More'; } }, expanded ? 'Less' : 'More'));
|
|
1331
|
+
return swap(el, next);
|
|
1332
|
+
}
|
|
1333
|
+
let legendOpen = false;
|
|
1334
|
+
// D55: a folder opens the same row's Observed reality cell in List, scrolled into view and picked out.
|
|
1335
|
+
function openFolderInList(key) {
|
|
1336
|
+
state.graphMode = 'list';
|
|
1337
|
+
state.listTarget = `obs-${key ?? 'project-wide'}`;
|
|
1338
|
+
renderBody();
|
|
1339
|
+
}
|
|
1340
|
+
/** After arriving in List from elsewhere (Spec §6.3): scroll the target row or cell into view and pick it out. */
|
|
1341
|
+
function revealInList() {
|
|
1342
|
+
const id = state.listTarget ?? (state.selection?.kind === 'node' ? `row-${state.selection.id}` : null);
|
|
1343
|
+
state.listTarget = null;
|
|
1344
|
+
if (!id) return;
|
|
1345
|
+
const el = document.getElementById(id);
|
|
1346
|
+
if (!el) return;
|
|
1347
|
+
openListAround(el);
|
|
1348
|
+
// A cell can be taller than the window: its top comes into view. A row goes to the middle.
|
|
1349
|
+
el.scrollIntoView({ block: id.startsWith('obs-') ? 'start' : 'center' });
|
|
1350
|
+
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
|
|
1351
|
+
setTimeout(() => el.classList.remove('flash'), 1800);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ── The List's blocks open and close (E153; ui/list-fold.js keeps what the owner opened) ───────────────────────
|
|
1355
|
+
const listFold = createListFold();
|
|
1356
|
+
let listBlockKeys = []; // the blocks the List draws now: what `Expand all` opens
|
|
1357
|
+
const foldBodyId = (key) => `fold-body-${key}`;
|
|
1358
|
+
/** Open or fold one block where it stands — nothing is rebuilt — and remember it for this browser. */
|
|
1359
|
+
function setListBlock(block, open) {
|
|
1360
|
+
block.dataset.open = String(open);
|
|
1361
|
+
block.querySelector(':scope > [data-fold-head]')?.setAttribute('aria-expanded', String(open));
|
|
1362
|
+
const body = block.querySelector(':scope > [data-fold-body]');
|
|
1363
|
+
if (body) body.hidden = !open;
|
|
1364
|
+
listFold.set(state.projectId, block.dataset.fold, open);
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* A block's head: pressed anywhere but on something of its own to press (its name, `Intent & basis`), it opens or
|
|
1368
|
+
* folds the block. Tab reaches it; Enter or Space presses it; `aria-expanded` says which way it stands.
|
|
1369
|
+
*/
|
|
1370
|
+
function foldHead(key, open) {
|
|
1371
|
+
const press = (e) => { const block = e.currentTarget.closest('[data-fold]'); if (block) setListBlock(block, block.dataset.open !== 'true'); };
|
|
1372
|
+
return {
|
|
1373
|
+
'data-fold-head': '', role: 'button', tabindex: 0, 'aria-expanded': String(open), 'aria-controls': foldBodyId(key),
|
|
1374
|
+
onClick: (e) => {
|
|
1375
|
+
if (e.target.closest('button, a, input, select, textarea, details')) return;
|
|
1376
|
+
const picked = getSelection?.(); // words of the head just dragged over to copy: not a press
|
|
1377
|
+
if (picked && !picked.isCollapsed && e.currentTarget.contains(picked.anchorNode)) return;
|
|
1378
|
+
press(e);
|
|
1379
|
+
},
|
|
1380
|
+
onKeydown: (e) => { if (e.target !== e.currentTarget || (e.key !== 'Enter' && e.key !== ' ')) return; e.preventDefault(); press(e); },
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Before a row or a cell is scrolled to (a `row-<id>` link, `Show in List` from the Graph, an observed-reality folder):
|
|
1385
|
+
* every block and fold that holds it is opened. What the target itself heads — its own block, its own process — stays
|
|
1386
|
+
* as it is.
|
|
1387
|
+
*/
|
|
1388
|
+
function openListAround(el) {
|
|
1389
|
+
for (let p = el.parentElement; p && !p.classList.contains('list-wrap'); p = p.parentElement) {
|
|
1390
|
+
if (p.tagName === 'DETAILS') { if (!p.open && !p.querySelector(':scope > summary')?.contains(el)) p.open = true; }
|
|
1391
|
+
else if (p.dataset.fold && p.dataset.open !== 'true' && !p.querySelector(':scope > [data-fold-head]')?.contains(el)) setListBlock(p, true);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** In List a click only opens the details (CKC-09 AC-31): the table is not rebuilt, scrolled or rewrapped. */
|
|
1396
|
+
function markListSelection(selection) {
|
|
1397
|
+
document.querySelectorAll('.list-wrap .sel').forEach((el) => el.classList.remove('sel'));
|
|
1398
|
+
if (!selection?.id) return;
|
|
1399
|
+
document.querySelectorAll(`.list-wrap [data-node="${CSS.escape(selection.id)}"]`).forEach((el) => el.classList.add('sel'));
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ── The control row (Spec §6.1 "顶上的控件只占一行", §6.3 "控件放在哪"; CKC-09 AC-37) ────────────────────────────
|
|
1403
|
+
const SCALES = ['Overview', 'Work', 'Compare'];
|
|
1404
|
+
let controlFilterSync = null;
|
|
1405
|
+
const graphFilters = () => state.filters.graph ?? (state.filters.graph = clearFilters({}));
|
|
1406
|
+
const toList = () => { closeStripSheet(); state.graphMode = 'list'; state.listTarget = state.selection?.kind === 'node' ? `row-${state.selection.id}` : null; renderBody(); };
|
|
1407
|
+
const toGraph = () => { closeStripSheet(); state.graphMode = 'graph'; renderBody(); };
|
|
1408
|
+
const toCode = () => { closeStripSheet(); state.graphMode = 'code'; renderBody(); };
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* One row above the graph and above the List, the same in both and in the same place: the scale, `Graph` / `List` /
|
|
1412
|
+
* `Code` (§6.17), `Filter`, and — over the graph — the readability mark. Everything else the old four rows held is one
|
|
1413
|
+
* press away: the filters and what is shown in the `Filter` panel, the graph's own operations on the graph's corner
|
|
1414
|
+
* (graph.render). `scales` is the Graph's live switch; the List shows it set and out of use, so nothing in the row
|
|
1415
|
+
* changes place. `Expand all` and `Fold all` ride here too — one fold rule for both (CKC-24); over the List they open and
|
|
1416
|
+
* fold its blocks as well (E153).
|
|
1417
|
+
*/
|
|
1418
|
+
function controlRow({ mode, scales = null, focusPath = null }) {
|
|
1419
|
+
const inGraph = mode === 'graph';
|
|
1420
|
+
const scaleSwitch = scales ?? h('div', { class: 'segmented', title: 'The scales are views of the Graph' }, ...SCALES.map((s) => h('button', { class: state.scale === s ? 'active' : '', disabled: true }, s)));
|
|
1421
|
+
const modeBtn = (id, label) => h('button', mode === id ? { class: 'active', 'aria-pressed': 'true' } : { 'aria-pressed': 'false', onClick: id === 'graph' ? toGraph : id === 'list' ? toList : toCode }, label);
|
|
1422
|
+
const modeSwitch = h('div', { class: 'segmented' }, modeBtn('graph', 'Graph'), modeBtn('list', 'List'), modeBtn('code', 'Code'));
|
|
1423
|
+
// The one fold rule of the process view, offered in both views (CKC-24 AC-11, AC-12).
|
|
1424
|
+
const expandAll = mode !== 'code' ? kProcess.foldAllButtons(() => { if (state.graphMode === 'list') void refreshList(); else graphCtl?.refresh(); },
|
|
1425
|
+
mode === 'list' ? { setAll: (on) => listFold.setAll(state.projectId, listBlockKeys, on) } : null) : null;
|
|
1426
|
+
|
|
1427
|
+
const filterBtn = h('button', { class: 'btn small filter-btn', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onClick: () => flyout.toggle({ button: filterBtn, label: 'Filter', className: 'filter-panel', render: () => filterPanel(mode, () => { syncFilter(); paintDrawerBar(); flyout.refresh(filterBtn); }) }) });
|
|
1428
|
+
// The number is the filters in force: choices that can take objects off the picture (graph-tools.js). None of them
|
|
1429
|
+
// acts on the List, so there the button says `Filter` whatever was chosen for the Graph.
|
|
1430
|
+
const syncFilter = () => {
|
|
1431
|
+
const on = inGraph ? [...activeFilters(graphFilters()), ...kProcess.activeProcessFilters(graphFilters())] : [];
|
|
1432
|
+
filterBtn.textContent = filterLabel(on.length);
|
|
1433
|
+
filterBtn.classList.toggle('active', on.length > 0);
|
|
1434
|
+
filterBtn.title = on.length ? `In force — ${on.map((x) => (x.value === 'on' ? x.label : `${x.label}: ${x.value}`)).join(' · ')}` : inGraph ? 'Filter the picture, and choose what is shown' : 'Choose what is shown';
|
|
1435
|
+
};
|
|
1436
|
+
syncFilter();
|
|
1437
|
+
controlFilterSync = syncFilter; // the ❓ count in the drawer sets a filter too, and the button says so
|
|
1438
|
+
|
|
1439
|
+
let mark = null;
|
|
1440
|
+
if (inGraph) {
|
|
1441
|
+
let report = null;
|
|
1442
|
+
let seen = '';
|
|
1443
|
+
const btn = h('button', { class: 'read-mark', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onClick: () => flyout.toggle({ button: btn, label: 'Readability of the picture', className: 'read-panel', render: () => readabilityPanel(report, { focusPath }) }) });
|
|
1444
|
+
const set = (r) => {
|
|
1445
|
+
report = r;
|
|
1446
|
+
const m = readabilityMark(r);
|
|
1447
|
+
const sig = JSON.stringify((r?.issues ?? []).map((i) => [i.kind, i.count, i.text, i.ids]));
|
|
1448
|
+
if (btn.textContent !== m.text) btn.textContent = m.text;
|
|
1449
|
+
btn.title = m.title;
|
|
1450
|
+
btn.dataset.count = String(m.count);
|
|
1451
|
+
btn.classList.toggle('alert', m.count > 0);
|
|
1452
|
+
if (sig !== seen) { seen = sig; flyout.refresh(btn); } // an open panel follows the check; an unchanged one is left alone
|
|
1453
|
+
};
|
|
1454
|
+
set(null);
|
|
1455
|
+
mark = { btn, set };
|
|
1456
|
+
}
|
|
1457
|
+
return { el: h('div', { class: 'graph-tools' }, scaleSwitch, modeSwitch, expandAll, filterBtn, mark?.btn ?? null), filter: { btn: filterBtn, sync: syncFilter }, mark };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* The `Filter` panel: the filters (what can take objects off the picture), then what is shown — replaced and deferred
|
|
1462
|
+
* objects, and which period the stars mark, with the sentence that says what they mark now. `Clear filters` puts every
|
|
1463
|
+
* filter out of force in one press and leaves what is shown alone. The List has no filters; it shares what is shown.
|
|
1464
|
+
*/
|
|
1465
|
+
function filterPanel(mode, changed) {
|
|
1466
|
+
const g = graph._data ?? { categories: [], nodes: [], counts: {} };
|
|
1467
|
+
const f = graphFilters();
|
|
1468
|
+
const inGraph = mode === 'graph';
|
|
1469
|
+
const apply = () => { graphCtl?.setFilters(f); changed(); };
|
|
1470
|
+
const OPTIONS = {
|
|
1471
|
+
category: g.categories.filter((c) => !NOT_DRAWN.has(c)), validity: ['Current', 'Proposed', 'Deferred', 'Replaced', 'Abandoned'], progress: ['Planned', 'In progress', 'Done', 'On hold'],
|
|
1472
|
+
acceptance: g.nodes.some((n) => n.acceptance) ? ['Accepted', 'Not yet accepted'] : null, assessment: ['Holds', 'Questioned', 'Not assessed'],
|
|
1473
|
+
};
|
|
1474
|
+
const choice = (x) => h('div', { class: 'field' }, h('label', { for: `filter-${x.key}` }, x.label),
|
|
1475
|
+
h('select', { id: `filter-${x.key}`, class: 'input small', onChange: (e) => { f[x.key] = e.target.value; apply(); } }, h('option', { value: '' }, 'all'), ...OPTIONS[x.key].map((o) => h('option', { value: o, selected: f[x.key] === o }, o))));
|
|
1476
|
+
const tick = (x) => h('label', { class: 'chk' }, h('input', { type: 'checkbox', checked: f[x.key] || null, onChange: (e) => { f[x.key] = e.target.checked; apply(); } }), x.label);
|
|
1477
|
+
const redraw = () => (inGraph ? null : refreshList());
|
|
1478
|
+
const replaced = g.counts?.replacedOrDeferred ?? 0;
|
|
1479
|
+
const showReplaced = h('label', { class: 'chk' }, h('input', { type: 'checkbox', checked: state.filters.showReplaced || null, onChange: (e) => { state.filters.showReplaced = e.target.checked; graphCtl?.setShowReplaced(e.target.checked); redraw(); changed(); } }), `Show replaced & deferred${replaced ? ` (${replaced})` : ''}`);
|
|
1480
|
+
const stars = h('div', { class: 'segmented', title: 'Stars mark new or changed objects' }, ...[['visit', '★ Since last visit'], ['days', '★ Last days']].map(([m, label]) => h('button', { class: starMode() === m ? 'active' : '', 'aria-pressed': String(starMode() === m), onClick: () => { setStarMode(m); graphCtl?.setStars(m, state.lastVisit); redraw(); changed(); } }, label)));
|
|
1481
|
+
const starNote = inGraph ? graph._starNote ?? '' : starredIds(g.nodes, starMode(), state.lastVisit).note;
|
|
1482
|
+
// The process filters (CKC-24 AC-16), offered when the process view has data: a breakpoint's kind, a send-back's
|
|
1483
|
+
// stage, one of the six things. They are not in graph-tools.js's FILTERS (that list is pinned by its test); the
|
|
1484
|
+
// button counts them via kProcess.activeProcessFilters, and Clear puts them out of force here.
|
|
1485
|
+
const procView = kProcess.current();
|
|
1486
|
+
const procSelects = inGraph && procView ? (() => {
|
|
1487
|
+
const pchoice = (key, label, options) => h('div', { class: 'field' }, h('label', { for: `filter-${key}` }, label),
|
|
1488
|
+
h('select', { id: `filter-${key}`, class: 'input small', onChange: (e) => { f[key] = e.target.value; apply(); } }, h('option', { value: '' }, 'all'), ...options.map(([v, l]) => h('option', { value: v, selected: f[key] === v }, l))));
|
|
1489
|
+
const kinds = [...new Set(procView.breakpoints.map((b) => b.kind))].sort();
|
|
1490
|
+
const stages = [...new Set(procView.sendBacks.map((s) => s.stage))].sort();
|
|
1491
|
+
const things = (procView.sixThings ?? []).map((t) => [String(t.thing), `${t.thing} · ${kProcess.SIX_THING[t.thing] ?? t.thing}`]);
|
|
1492
|
+
return h('div', { class: 'filter-grid' },
|
|
1493
|
+
kinds.length ? pchoice('bpKind', 'Breakpoint kind', kinds.map((k) => [k, k])) : null,
|
|
1494
|
+
stages.length ? pchoice('sbStage', 'Send-back stage', stages.map((s) => [s, s])) : null,
|
|
1495
|
+
things.length ? pchoice('sixThing', 'One of the six things', things) : null);
|
|
1496
|
+
})() : null;
|
|
1497
|
+
return [
|
|
1498
|
+
inGraph ? h('div', { class: 'filter-grid' }, ...FILTERS.filter((x) => !x.tick && OPTIONS[x.key]).map(choice)) : null,
|
|
1499
|
+
inGraph ? h('div', { class: 'row wrap filter-ticks' }, ...FILTERS.filter((x) => x.tick).map(tick)) : null,
|
|
1500
|
+
procSelects,
|
|
1501
|
+
h('div', { class: 'filter-show' }, h('h4', {}, 'Show'), showReplaced, stars, starNote ? h('div', { class: 'faint star-note' }, starNote) : null),
|
|
1502
|
+
inGraph ? h('div', { class: 'flyout-foot' }, h('button', { class: 'btn small', disabled: activeFilters(f).length === 0 && kProcess.activeProcessFilters(f).length === 0, title: 'Put every filter out of force; what is shown stays as it is', onClick: () => { clearFilters(f); f.bpKind = f.sbStage = f.sixThing = ''; apply(); } }, 'Clear filters'))
|
|
1503
|
+
: h('div', { class: 'faint' }, 'Filters act on the Graph.'),
|
|
1504
|
+
];
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const graph = {
|
|
1508
|
+
closeSheet(options) { return closeStripSheet(options); },
|
|
1509
|
+
async render(main) {
|
|
1510
|
+
const rest = state.routeRest || '';
|
|
1511
|
+
const m = /^(change|sel)\/(?:(\w+)\/)?(.+)$/.exec(rest);
|
|
1512
|
+
if (m) {
|
|
1513
|
+
const id = decodeURIComponent(m[3]);
|
|
1514
|
+
if (m[1] === 'change') state.pendingAffected = id;
|
|
1515
|
+
else { state.selection = { kind: m[2] || 'node', id, label: id }; state.pendingPopover = state.pendingPopover ?? {}; if (state.graphMode === 'list') state.listTarget = `row-${id}`; }
|
|
1516
|
+
state.routeRest = '';
|
|
1517
|
+
}
|
|
1518
|
+
const [g, ov] = await Promise.all([api(`/api/projects/${P()}/graph`), api(overviewUrl())]);
|
|
1519
|
+
// The process view (CKC-24) loads beside the graph; null when its endpoint is not built yet.
|
|
1520
|
+
await kProcess.load(state.projectId);
|
|
1521
|
+
if (kProcess.current()) kProcess.indexNodes(g.nodes);
|
|
1522
|
+
if (state.selection?.kind === 'node') { const n = g.nodes.find((x) => x.id === state.selection.id); if (n) state.selection = NODE_SEL(n); }
|
|
1523
|
+
if (state.graphMode === 'code') {
|
|
1524
|
+
graphCtl?.destroy(); graphCtl = null;
|
|
1525
|
+
await renderCode(main, ov);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (g.nodes.length === 0) {
|
|
1529
|
+
graphCtl?.destroy(); graphCtl = null;
|
|
1530
|
+
// §6.14: the empty graph says why it is empty. A project not organized yet — just added, or cleared — leads to the
|
|
1531
|
+
// Takeover page, where the owner chooses a depth and presses Start; nothing runs before that (D105).
|
|
1532
|
+
const notConnected = activity.status === 'Not connected';
|
|
1533
|
+
const notOrganized = state.project?.coverage?.state === 'Not organized yet';
|
|
1534
|
+
append(main, h('div', { class: 'graph-wrap' }, h('div', { class: 'graph-empty' }, emptyState('The project graph starts here',
|
|
1535
|
+
notOrganized
|
|
1536
|
+
? 'This project has not been organized yet. The Keeper does nothing until you choose a depth and press Start on the Takeover page; the graph then fills in as the first usable picture forms.'
|
|
1537
|
+
: notConnected
|
|
1538
|
+
? 'The takeover has started, but the Keeper is not connected to a model, so nothing has been organized yet. Set a key and a model in Keeper → Model provider. Project scope shows what will be read.'
|
|
1539
|
+
: 'The Keeper is drawing the project boundary and reading its material. Established parts appear here as they are formed; nothing waits for the whole project to be read.',
|
|
1540
|
+
h('div', { class: 'row' }, notOrganized ? h('button', { class: 'btn primary', id: 'graph-to-takeover', onClick: () => navigate(state.projectId, 'keeper', 'takeover') }, 'Open the Takeover page') : notConnected ? h('button', { class: 'btn primary', onClick: () => navigate(state.projectId, 'keeper') }, 'Open Keeper') : null, notOrganized ? null : h('button', { class: 'btn', onClick: () => navigate(state.projectId, 'scope') }, 'Open Project scope'))))));
|
|
1541
|
+
mountStrip(main, ov);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (state.graphMode === 'list') {
|
|
1545
|
+
graphCtl?.destroy(); graphCtl = null;
|
|
1546
|
+
await renderList(main, g, ov);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const wrap = h('div', { class: 'graph-wrap' });
|
|
1550
|
+
const cyEl = h('div', { id: 'cy' });
|
|
1551
|
+
graph._data = g;
|
|
1552
|
+
const syncScale = () => { scales.querySelectorAll('button').forEach((b) => { b.classList.toggle('active', b.textContent === state.scale); if (scaleNeedsSelection[b.textContent]) b.disabled = !state.selection; }); syncFocus(); };
|
|
1553
|
+
const scaleNeedsSelection = { Work: 'Select a work item first' };
|
|
1554
|
+
const scales = h('div', { class: 'segmented' }, ...SCALES.map((s) => h('button', {
|
|
1555
|
+
class: state.scale === s ? 'active' : '', disabled: Boolean(scaleNeedsSelection[s]) && !state.selection,
|
|
1556
|
+
title: scaleNeedsSelection[s] ?? '',
|
|
1557
|
+
onClick: () => { state.scale = s; graphCtl.setScale(s); syncScale(); if (s === 'Compare') void openCompare(wrap); else closeCompare(); },
|
|
1558
|
+
}, s)));
|
|
1559
|
+
// Focus path is a toggle: the same button clears the focus and returns to the overview.
|
|
1560
|
+
const focusOn = () => state.scale === 'Work' && Boolean(state.selection) && graphCtl?.view.focusId === state.selection.id;
|
|
1561
|
+
const focusBtn = h('button', { class: 'btn small', title: 'Show only the path through the selected object; click again to clear', disabled: !state.selection, onClick: () => {
|
|
1562
|
+
if (!state.selection) return;
|
|
1563
|
+
if (focusOn()) { state.scale = 'Overview'; graphCtl.setScale('Overview'); } else { state.scale = 'Work'; graphCtl.setScale('Work', { focusId: state.selection.id }); }
|
|
1564
|
+
syncScale();
|
|
1565
|
+
} }, 'Focus path');
|
|
1566
|
+
const syncFocus = () => { const on = focusOn(); focusBtn.textContent = on ? 'Clear focus' : 'Focus path'; focusBtn.classList.toggle('active', on); };
|
|
1567
|
+
const upBtn = h('button', { class: 'btn small', title: 'Draw what the selected object serves and rests on', disabled: !state.selection, onClick: () => state.selection && graphCtl.expand(state.selection.id, 'up') }, 'Expand upstream');
|
|
1568
|
+
const downBtn = h('button', { class: 'btn small', title: 'Draw the work, results and checks under the selected object', disabled: !state.selection, onClick: () => state.selection && graphCtl.expand(state.selection.id, 'down') }, 'Expand downstream');
|
|
1569
|
+
const fitBtn = h('button', { class: 'btn small', title: 'The whole picture, as far as its names stay readable', onClick: () => graphCtl.fit() }, 'Fit');
|
|
1570
|
+
// D54: the links between areas and between work items show as a count beside each object, or all drawn.
|
|
1571
|
+
const links = h('div', { class: 'segmented', title: 'Other links (between areas, or between work items): a count beside each object and drawn while you point at it or select it, or all drawn' }, ...[['counts', '↗ Link counts'], ['lines', 'All links']].map(([m, label]) => h('button', { class: (state.graphLinks ?? 'counts') === m ? 'active' : '', onClick: (e) => {
|
|
1572
|
+
state.graphLinks = m; links.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === e.currentTarget)); graphCtl.setLinks(m);
|
|
1573
|
+
} }, label)));
|
|
1574
|
+
// What is done to the picture itself floats over its top right corner (Spec §6.3 "控件放在哪"): it takes no row, and
|
|
1575
|
+
// a view the graph chooses keeps the band under it clear of objects (`fitPadTop`, graph-fit.js).
|
|
1576
|
+
const float = h('div', { class: 'graph-float', role: 'toolbar', 'aria-label': 'Graph operations' }, focusBtn, upBtn, downBtn, fitBtn, links);
|
|
1577
|
+
const stage = h('div', { class: 'graph-stage' }, cyEl, float);
|
|
1578
|
+
// Focus path on one object, from the readability panel: the same as selecting it and pressing Focus path.
|
|
1579
|
+
const focusPath = (id) => { select({ kind: 'node', id, label: graphCtl.labelOf(id) }); state.scale = 'Work'; graphCtl.setScale('Work', { focusId: id }); syncScale(); };
|
|
1580
|
+
const row = controlRow({ mode: 'graph', scales, focusPath });
|
|
1581
|
+
legendEl = h('div', { class: 'legend' });
|
|
1582
|
+
append(wrap, stage, legendEl);
|
|
1583
|
+
setTopTools(row.el);
|
|
1584
|
+
append(main, wrap);
|
|
1585
|
+
mountStrip(main, ov);
|
|
1586
|
+
graphCtl?.destroy();
|
|
1587
|
+
// A tap on the graph picks the object where it is: the graph does not move, the popover opens beside it, and it
|
|
1588
|
+
// follows the object while the graph is panned, zoomed or made narrower by the docked Keeper (Spec §6.4).
|
|
1589
|
+
graphCtl = createGraph(cyEl, {
|
|
1590
|
+
onSelect: (s) => select(s, { origin: 'graph' }), onViewport: () => popover.reposition(),
|
|
1591
|
+
onStars: (r) => { graph._starNote = r.note; flyout.refresh(row.filter.btn); },
|
|
1592
|
+
onReadability: (r) => row.mark.set(r),
|
|
1593
|
+
onLegend: (items) => { legendEl = renderLegend(legendEl, items, legendOpen); }, onFolder: openFolderInList,
|
|
1594
|
+
// A send-back ellipse or arrow on the graph opens the send-back beside it (CKC-24 §1.18).
|
|
1595
|
+
onProcNode: (sbId, elId) => { if (sbId) kProcess.openSendBackPopover(sbId, () => graphCtl?.rectOf(elId) ?? null); },
|
|
1596
|
+
// The band under the floating buttons, which a view of the graph's own choosing keeps clear of objects.
|
|
1597
|
+
fitPadTop: () => (float.offsetHeight ? float.offsetTop + float.offsetHeight + 6 : 0),
|
|
1598
|
+
});
|
|
1599
|
+
cyEl.dataset.fitPadTop = String(float.offsetHeight ? float.offsetTop + float.offsetHeight + 6 : 0);
|
|
1600
|
+
graphCtl.view.scale = state.scale;
|
|
1601
|
+
graphCtl.view.starMode = starMode();
|
|
1602
|
+
graphCtl.view.lastVisit = state.lastVisit;
|
|
1603
|
+
graphCtl.view.selectionId = state.selection?.id ?? null;
|
|
1604
|
+
graphCtl.view.showReplaced = Boolean(state.filters.showReplaced);
|
|
1605
|
+
graphCtl.view.links = state.graphLinks ?? 'counts';
|
|
1606
|
+
Object.assign(graphCtl.view.filters, graphFilters());
|
|
1607
|
+
if (state.scale === 'Work' && state.selection) graphCtl.view.focusId = state.selection.id;
|
|
1608
|
+
graphCtl.update(g);
|
|
1609
|
+
if (state.selection?.kind === 'node') graphCtl.reveal(state.selection.id);
|
|
1610
|
+
else if (state.selection?.kind === 'relation') graphCtl.setSelection(state.selection.id);
|
|
1611
|
+
// Sent here to see particular objects (a note's mount, what an investigation affects): they are brought into view.
|
|
1612
|
+
if (state.pendingShow?.length) {
|
|
1613
|
+
const ids = state.pendingShow;
|
|
1614
|
+
const tell = state.pendingShowTell;
|
|
1615
|
+
setTimeout(() => { const n = graphCtl?.showAffected(ids) ?? 0; if (tell && ids.length && !n) toast('None of the affected objects is on the graph'); }, 60);
|
|
1616
|
+
}
|
|
1617
|
+
state.pendingShow = null;
|
|
1618
|
+
state.pendingShowTell = false;
|
|
1619
|
+
graph._needSelection = [focusBtn, upBtn, downBtn];
|
|
1620
|
+
graph._syncFocus = syncFocus;
|
|
1621
|
+
graph._syncScale = syncScale;
|
|
1622
|
+
// The readability panel offers the List when the picture is too big to read at once (CKC-09 AC-39).
|
|
1623
|
+
graph._openList = toList;
|
|
1624
|
+
if (state.resultFocus) graph.focusResult();
|
|
1625
|
+
// Arriving from a change link (`Show affected` in another view): highlight what it touched once the graph is up.
|
|
1626
|
+
if (state.pendingAffected) {
|
|
1627
|
+
const c = g.changes?.find((x) => x.id === state.pendingAffected);
|
|
1628
|
+
state.pendingAffected = null;
|
|
1629
|
+
if (c) setTimeout(() => showAffected(c), 60);
|
|
1630
|
+
}
|
|
1631
|
+
syncFocus();
|
|
1632
|
+
},
|
|
1633
|
+
onSelect(selection, _state, opts = {}) {
|
|
1634
|
+
if (!opts.keepMarks) graphCtl?.clearMarks();
|
|
1635
|
+
if (state.graphMode === 'list') {
|
|
1636
|
+
markListSelection(selection);
|
|
1637
|
+
// Arriving at an object from search or a link does move the List to it; a click inside the List does not.
|
|
1638
|
+
if (opts.reveal && selection?.id) { state.listTarget = `row-${selection.id}`; revealInList(); }
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (!graphCtl) return;
|
|
1642
|
+
graphCtl.setSelection(selection && selection.kind !== 'note' ? selection.id : null);
|
|
1643
|
+
// Picked on the graph itself, the object is where the owner is looking and nothing moves; picked anywhere else
|
|
1644
|
+
// (the outline, the strip, search, a link) it is brought to the middle so it can be seen (CKC-09 AC-34).
|
|
1645
|
+
if (selection?.kind === 'node' && opts.origin !== 'graph') graphCtl.reveal(selection.id);
|
|
1646
|
+
graph._needSelection?.forEach((b) => { b.disabled = !selection || selection.kind === 'note'; });
|
|
1647
|
+
graph._syncFocus?.();
|
|
1648
|
+
graph._syncScale?.();
|
|
1649
|
+
api(overviewUrl()).then((ov) => swapStrip(strip(ov))).catch(() => {});
|
|
1650
|
+
},
|
|
1651
|
+
async onAssets() {
|
|
1652
|
+
const [g, ov] = await Promise.all([api(`/api/projects/${P()}/graph`), api(overviewUrl())]);
|
|
1653
|
+
await kProcess.reload(state.projectId);
|
|
1654
|
+
if (kProcess.current()) kProcess.indexNodes(g.nodes);
|
|
1655
|
+
if (state.graphMode === 'code') { await renderCode(document.querySelector('#main'), ov); return; }
|
|
1656
|
+
if (state.graphMode === 'list' && g.nodes.length > 0) { await refreshList(g, ov); refreshOpenStripRows(); return; }
|
|
1657
|
+
if (!graphCtl || g.nodes.length === 0) { renderBody(); return; }
|
|
1658
|
+
graph._data = g;
|
|
1659
|
+
graphCtl.update(g);
|
|
1660
|
+
swapStrip(strip(ov));
|
|
1661
|
+
refreshOpenStripRows();
|
|
1662
|
+
},
|
|
1663
|
+
highlight(ids) { graphCtl?.highlight(ids); },
|
|
1664
|
+
clearMarks() { graphCtl?.clearMarks(); },
|
|
1665
|
+
/** The theme changed: the graph reads its palette again and repaints in place; nothing is fetched or laid out again. */
|
|
1666
|
+
repaint() { graphCtl?.repaint(); },
|
|
1667
|
+
/** Where the selected object is on the graph right now, for the popover to stand beside; null when it is not drawn. */
|
|
1668
|
+
anchorRect(selection) {
|
|
1669
|
+
if (!selection || selection.kind === 'note' || !onGraphNow() || !graphCtl.cy.container()?.isConnected) return null;
|
|
1670
|
+
return graphCtl.rectOf(selection.id, selection.at ?? null);
|
|
1671
|
+
},
|
|
1672
|
+
/** The first of these objects that is drawn: where a note's or an undrawn relation's popover stands on the graph. */
|
|
1673
|
+
hintRect(ids = []) {
|
|
1674
|
+
if (!onGraphNow() || !graphCtl.cy.container()?.isConnected) return null;
|
|
1675
|
+
for (const id of ids) { const r = graphCtl.rectOf(id); if (r) return r; }
|
|
1676
|
+
return null;
|
|
1677
|
+
},
|
|
1678
|
+
/** A result, review or test found by search: its work item's Work scale, with the result picked out (Spec §6.12). */
|
|
1679
|
+
focusResult() {
|
|
1680
|
+
const id = state.resultFocus;
|
|
1681
|
+
state.resultFocus = null;
|
|
1682
|
+
const n = graphCtl?.view.data?.nodes.find((x) => x.id === id);
|
|
1683
|
+
if (!graphCtl || !n) return;
|
|
1684
|
+
if (n.parentId) { state.scale = 'Work'; graphCtl.setScale('Work', { focusId: n.parentId }); graph._syncScale?.(); }
|
|
1685
|
+
graphCtl.setSelection(id);
|
|
1686
|
+
graphCtl.reveal(id);
|
|
1687
|
+
},
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
// ── List view: by module (D100; Spec §6.3 `List`; CKC-09 AC-15, CKC-24 AC-20) ─────────────────────────────────
|
|
1691
|
+
// The owner's drawn structure: on top the owner's words, the product and the goals; then the earlier generations rolled,
|
|
1692
|
+
// one line each; then one block per area — its name with `Intent & basis`, one sentence of the effect it is for, its
|
|
1693
|
+
// requirements, designs and decisions folded, then `Work & plan` · `Observed reality`, a row per work item — and last the
|
|
1694
|
+
// ringed cross-cutting block. Places come from the same model as the Graph (ui/placement.js), so the two agree.
|
|
1695
|
+
// Every part opens and closes at its head and starts folded (E153; ui/list-fold.js): the top plate to one line with the
|
|
1696
|
+
// product's name, a generation to its row, a block to its head card — its name, the effect it is for, its counts, and
|
|
1697
|
+
// what is still open inside it (D75) — so the whole List is about one screen and the owner opens the module they want.
|
|
1698
|
+
/** A ❓ in the List, the same mark as on the Graph: lit while a note on it needs you, quiet otherwise (D100). */
|
|
1699
|
+
const noteMark = (attention, notes, title) => (attention || notes ? h('span', { class: `q-mark ${attention ? 'ask' : 'note'}`, title: title ?? (attention ? `${attention} note${attention === 1 ? '' : 's'} on it still need${attention === 1 ? 's' : ''} you` : `${notes} note${notes === 1 ? '' : 's'} on it`) }, attention ? `❓${attention > 1 ? ` ${attention}` : ''}` : '❓') : null);
|
|
1700
|
+
function buildList(g, ov) {
|
|
1701
|
+
const showReplaced = Boolean(state.filters.showReplaced);
|
|
1702
|
+
const vis = (n) => !['Replaced', 'Deferred', 'Abandoned'].includes(n.validity) || showReplaced || n.recentChange;
|
|
1703
|
+
const M = placementOf(g, { showReplaced });
|
|
1704
|
+
const byId = M.byId;
|
|
1705
|
+
const areas = M.areas;
|
|
1706
|
+
const starred = starredIds(g.nodes, starMode(), state.lastVisit).ids;
|
|
1707
|
+
const star = (n) => (starred.has(n.id) ? h('span', { class: 'star', title: 'New or changed' }, '★ ') : null);
|
|
1708
|
+
// The drawer's ❓ count pressed (CF, views.js setAttentionFilter; Spec §6.2): only the objects its attention notes hang
|
|
1709
|
+
// on — each in its module's block, with the block's head so it can be read. Every other filter acts on the Graph only.
|
|
1710
|
+
const only = graphFilters().attention ? new Set(graphFilters().attentionIds ?? []) : null;
|
|
1711
|
+
const keep = (id) => !only || only.has(id);
|
|
1712
|
+
// One count for the List's blocks and the graph's folders (D55): the same objects, the same numbers.
|
|
1713
|
+
const cells = observedCells(g, showReplaced, M);
|
|
1714
|
+
const sel = (n) => (state.selection?.id === n.id ? ' sel' : '');
|
|
1715
|
+
const nodeBtn = (n, text = n.label) => h('span', { id: `row-${n.id}` }, star(n), h('button', { class: `text-btn${sel(n)}`, 'data-node': n.id, title: text !== n.label ? n.label : null, onClick: () => select(NODE_SEL(n)) }, n.validity === 'Replaced' || n.validity === 'Abandoned' ? h('s', {}, text) : text, n.updatePending ? ' ⟳' : ''), noteMark(n.noteAttention, n.noteCount));
|
|
1716
|
+
const procView = kProcess.current();
|
|
1717
|
+
// Document-like objects name their current version (CKC-24 AC-4); a project that keeps only the latest says so.
|
|
1718
|
+
const docTag = (id) => {
|
|
1719
|
+
const d = procView?.docs?.[id];
|
|
1720
|
+
if (!d) return null;
|
|
1721
|
+
return h('span', { class: 'faint', title: d.historyFrom ? `Under version control only from ${d.historyFrom} — earlier versions cannot be shown` : `${d.versions} version${d.versions === 1 ? '' : 's'}` },
|
|
1722
|
+
` · ${d.current ? `current: ${d.current}` : 'no current version recorded'}${d.historyFrom ? ` (history from ${d.historyFrom})` : ''}`);
|
|
1723
|
+
};
|
|
1724
|
+
const planName = (id) => (id === NO_PLAN ? 'Not in a plan' : cleanName(byId.get(id)?.label ?? id).split(' · ')[0]);
|
|
1725
|
+
const planTag = (n) => { const p = M.place.get(n.id); if (!p) return null; const also = M.also.get(n.id)?.plans ?? []; return h('span', { class: `tag plan-tag${p.band === NO_PLAN ? ' none' : ''}`, title: p.band === NO_PLAN ? 'Not in a plan yet: no record read so far places it in a plan' : `Plan: ${cleanName(byId.get(p.band)?.label ?? '')}${also.length ? ` · also listed in ${also.map(planName).join(', ')}` : ''}` }, planName(p.band), also.length ? ` +${also.length}` : ''); };
|
|
1726
|
+
// `Basis`: where it rests, in its popover (sources, how it got here, its relations with their basis).
|
|
1727
|
+
const basisBtn = (n) => h('button', { class: 'text-btn basis-btn', title: `${n.basis ?? 'Explicit'}: open what it rests on — its sources, how it got here and its relations`, onClick: (e) => { e.stopPropagation(); select(NODE_SEL(n)); } }, n.basis === 'Inferred' ? 'Basis · Inferred' : 'Basis');
|
|
1728
|
+
const notesIn = (ids) => { let attention = 0, notes = 0; for (const id of ids) { const n = byId.get(id); attention += n?.noteAttention ?? 0; notes += n?.noteCount ?? 0; } return { attention, quiet: Math.max(0, notes - attention) }; };
|
|
1729
|
+
const foldMark = (ids) => { const m = notesIn(ids); return noteMark(m.attention, m.attention + m.quiet, m.attention ? `${m.attention} note${m.attention === 1 ? '' : 's'} inside still need${m.attention === 1 ? 's' : ''} you` : `${m.quiet} note${m.quiet === 1 ? '' : 's'} inside`); };
|
|
1730
|
+
const kindCount = (ids) => { const c = {}; for (const id of ids) { const k = byId.get(id)?.category; c[k] = (c[k] ?? 0) + 1; } return INTENT_KINDS.filter((k) => c[k]).map((k) => `${c[k]} ${k === 'Design' ? 'design' : k === 'Decision' ? 'decision' : 'requirement'}${c[k] === 1 ? '' : 's'}`).join(' · '); };
|
|
1731
|
+
// A send-back hanging on a requirement or decision shows in its block, open, not inside the fold (mockup 3.5).
|
|
1732
|
+
const intentFlagsOf = (ids) => (procView ? ids.flatMap((id) => kProcess.intentFlags(id)).map((f) => (f.classList.add('intent-flag'), f)) : []);
|
|
1733
|
+
/** A folded group of requirements, designs and decisions with its counts; expand / collapse (the owner's drawing). */
|
|
1734
|
+
const intentFold = (ids, label, key) => {
|
|
1735
|
+
const list = ids.filter((id) => vis(byId.get(id)) && keep(id));
|
|
1736
|
+
if (!list.length) return null;
|
|
1737
|
+
const open = state.listOpen?.has(key) || list.some((id) => id === state.selection?.id);
|
|
1738
|
+
return h('details', { class: 'ref-fold rdd', open: open || null, onToggle: (e) => { state.listOpen ??= new Set(); if (e.currentTarget.open) state.listOpen.add(key); else state.listOpen.delete(key); } },
|
|
1739
|
+
h('summary', {}, `${label} · ${kindCount(list)}`, ' ', foldMark(list)),
|
|
1740
|
+
...INTENT_KINDS.flatMap((k) => list.filter((id) => byId.get(id).category === k).sort((a, b) => byId.get(a).label.localeCompare(byId.get(b).label, undefined, { numeric: true })).map((id) => {
|
|
1741
|
+
const r = byId.get(id), p = M.place.get(id);
|
|
1742
|
+
// On the whole product (CM; Spec §1.4 "写明为什么"): the Keeper's written reason under its line.
|
|
1743
|
+
return h('div', p?.ring === 'whole' ? { title: `${WHOLE_LABEL}: ${p.why}` } : {}, h('span', { class: 'tag' }, r.category), ' ', nodeBtn(r), docTag(r.id), ' ', validityTag(r.validity),
|
|
1744
|
+
p?.ring === 'multi' ? h('span', { class: 'faint' }, ` · reaches ${p.areas.map((a) => cleanName(byId.get(a)?.label ?? a).split(' · ')[0]).join(', ')}`) : null,
|
|
1745
|
+
p?.ring === 'whole' ? h('div', { class: 'faint whole-why' }, p.why) : null);
|
|
1746
|
+
})));
|
|
1747
|
+
};
|
|
1748
|
+
// The blocks that open and close (E153): folded until the owner opens them. With the ❓ filter on, every block that
|
|
1749
|
+
// is left is drawn open — it is there only for what the filter picked.
|
|
1750
|
+
const pid = state.projectId;
|
|
1751
|
+
const drawn = [];
|
|
1752
|
+
const blockOpen = (key) => { drawn.push(key); return Boolean(only) || listFold.isOpen(pid, key); };
|
|
1753
|
+
/**
|
|
1754
|
+
* What a folded head lights of its inside (D75 「折起来也亮着」; Spec §6.3 List, CKC-09 AC-15): the count of what is
|
|
1755
|
+
* still open — send-backs, lit breakpoints, findings — and the count of the notes inside, lit while any needs the
|
|
1756
|
+
* owner. `works` are the work rows drawn inside, `objects` the other objects it holds, `notes` whatever inside can
|
|
1757
|
+
* carry a note. Nothing open and no note: nothing is drawn.
|
|
1758
|
+
*/
|
|
1759
|
+
const insideMarks = ({ works = [], objects = [], notes = [...works, ...objects] }) => {
|
|
1760
|
+
const open = openInside(procView, { works, objects });
|
|
1761
|
+
const m = notesIn([...new Set(notes)]);
|
|
1762
|
+
const k = m.attention || m.quiet;
|
|
1763
|
+
return [
|
|
1764
|
+
open ? h('span', { class: 'tag red inside-open', title: `Still open inside: ${open.text}` }, `⚠ ${open.count}`) : null,
|
|
1765
|
+
k ? h('span', { class: `q-mark ${m.attention ? 'ask' : 'note'} inside-notes`, title: m.attention ? `${m.attention} note${m.attention === 1 ? '' : 's'} inside still need${m.attention === 1 ? 's' : ''} you${m.quiet ? ` · ${m.quiet} more inside` : ''}` : `${m.quiet} note${m.quiet === 1 ? '' : 's'} inside` }, `❓ ${k}`) : null,
|
|
1766
|
+
];
|
|
1767
|
+
};
|
|
1768
|
+
const shownIds = (ids) => ids.filter((id) => byId.has(id) && vis(byId.get(id)) && keep(id));
|
|
1769
|
+
const procCtx = { nodeBtn, acceptanceTag, validityTag, planTag, basisBtn, nodeOf: (id) => byId.get(id) ?? null, labelOf: (id) => byId.get(id)?.label ?? id, destinationLine, destinationText, foldMarks: foldMark,
|
|
1770
|
+
insideMarks, openGen: (id) => blockOpen(generationKey(id)), genToggled: (id, open) => { if (!only) listFold.set(pid, generationKey(id), open); } };
|
|
1771
|
+
const absorbed = procView ? kProcess.absorbedIds() : new Set();
|
|
1772
|
+
const bandIndex = (id) => M.bandOrder.get(M.place.get(id)?.band) ?? 999;
|
|
1773
|
+
const PROGRESS = { 'In progress': 0, 'Planned': 1, 'On hold': 2, 'Done': 3 };
|
|
1774
|
+
// A work item serving several modules has a row in each (owner, 2026-09-30): solid in its main module, saying where
|
|
1775
|
+
// else it is; dashed in the others, saying where its main one is. The same object: a click selects it, and both rows
|
|
1776
|
+
// light up (markListSelection); only the main row carries the id a link scrolls to.
|
|
1777
|
+
const sharedTag = (n, area) => {
|
|
1778
|
+
const m = sharedMark(M, n.id, area);
|
|
1779
|
+
if (!m) return null;
|
|
1780
|
+
const name = (a) => cleanName(byId.get(a)?.label ?? a);
|
|
1781
|
+
const why = MAIN_BY[m.by] ? ` — ${MAIN_BY[m.by]}` : '';
|
|
1782
|
+
return h('span', { class: `tag shared-tag ${m.copy ? 'copy' : 'main'}`, title: m.copy ? `Also serves this module; its main row is under ${name(m.main)}${why}` : `Its main module${why}; also serves ${m.others.map(name).join(', ')}, listed there dashed` }, m.text);
|
|
1783
|
+
};
|
|
1784
|
+
const copyBtn = (area) => (n) => h('span', { id: `row-${n.id}--in-${area}` }, star(n), h('button', { class: `text-btn${sel(n)}`, 'data-node': n.id, onClick: () => select(NODE_SEL(n)) }, n.label, n.updatePending ? ' ⟳' : ''), noteMark(n.noteAttention, n.noteCount));
|
|
1785
|
+
const workRowIn = (id, area, repeated = null) => {
|
|
1786
|
+
const copy = isCopyIn(M, id, area);
|
|
1787
|
+
// A line the block states once: the row keeps the kind of the step and, when the block states more than one, what
|
|
1788
|
+
// tells them apart (`Planned · e37a24f (Modified)`).
|
|
1789
|
+
const same = (step, text) => (repeated.has(text) ? h('span', { class: 'wr-steps same', title: text }, repeated.prefix ? `${step.kind} · ${text.slice(repeated.prefix.length)}` : step.kind) : null);
|
|
1790
|
+
const row = kProcess.workRow(byId.get(id), { ...procCtx, extraLeft: (n) => sharedTag(n, area), ...(copy ? { nodeBtn: copyBtn(area) } : {}), ...(repeated?.size ? { stepLine: same } : {}) });
|
|
1791
|
+
if (copy) { row.classList.add('shared-copy'); row.dataset.copyIn = area; }
|
|
1792
|
+
return row;
|
|
1793
|
+
};
|
|
1794
|
+
// Rows in the order of the Graph's bands (a plan's rows together), then by progress and name; no plan last.
|
|
1795
|
+
const rowIds = (ids) => ids.filter((id) => vis(byId.get(id)) && keep(id) && !absorbed.has(id))
|
|
1796
|
+
.sort((a, b) => bandIndex(a) - bandIndex(b) || (PROGRESS[byId.get(a).progress] ?? 4) - (PROGRESS[byId.get(b).progress] ?? 4) || byId.get(a).label.localeCompare(byId.get(b).label, undefined, { numeric: true }));
|
|
1797
|
+
const rowsOf = (ids, area = null, repeated = null) => rowIds(ids).map((id) => workRowIn(id, area, repeated));
|
|
1798
|
+
// The one-step line a row shows beside its name, and the ones two rows or more of a block share: each is said once
|
|
1799
|
+
// under the block's column heads, and the rows say only the kind of the step.
|
|
1800
|
+
const repeatedSteps = (ids) => {
|
|
1801
|
+
const count = new Map();
|
|
1802
|
+
for (const id of rowIds(ids)) { const t = kProcess.stepText(id); if (t) count.set(t, (count.get(t) ?? 0) + 1); }
|
|
1803
|
+
const repeated = new Map([...count].filter(([, k]) => k > 1));
|
|
1804
|
+
// What the lines the block states share, up to a space or a slash: the rows say only the rest.
|
|
1805
|
+
const keys = [...repeated.keys()];
|
|
1806
|
+
let prefix = keys.length > 1 ? keys.reduce((a, b) => { let i = 0; while (i < a.length && a[i] === b[i]) i++; return a.slice(0, i); }) : '';
|
|
1807
|
+
prefix = prefix.slice(0, Math.max(prefix.lastIndexOf(' '), prefix.lastIndexOf('/')) + 1);
|
|
1808
|
+
repeated.prefix = prefix;
|
|
1809
|
+
return repeated;
|
|
1810
|
+
};
|
|
1811
|
+
const repeatedNote = (repeated) => (repeated.size ? h('div', { class: 'rows-note' }, ...[...repeated].map(([t, k]) => h('div', {}, h('b', {}, `${k} rows`), ` · ${t}`))) : null);
|
|
1812
|
+
const worksIn = (area) => listRowsOf(M, area).map((r) => r.id);
|
|
1813
|
+
// Observed reality: what the graph's folder for this column holds, with the same count on top (D55, AC-15, AC-30).
|
|
1814
|
+
const realityOf = (key) => {
|
|
1815
|
+
const cell = cells.get(key);
|
|
1816
|
+
const flags = cell ? [[MARKS.flag, cell.flagged], [MARKS.note, cell.noted], [MARKS.pending, cell.pending]].filter(([, k]) => k).map(([m, k]) => h('span', { class: `mark-${m.key}` }, ` ${m.glyph} ${k}`)) : [];
|
|
1817
|
+
return h('details', { id: `obs-${key ?? 'project-wide'}`, class: 'ref-fold obs-fold', open: state.listTarget === `obs-${key ?? 'project-wide'}` || null },
|
|
1818
|
+
h('summary', {}, 'Observed reality · ', cell?.total ? [h('b', {}, String(cell.total)), ` · ${cellSummary(cell)}`, cell.unplaced ? ` · ${cell.unplaced} not linked to any work` : null, ...flags] : 'No result or verification recorded yet'),
|
|
1819
|
+
...(cell?.items ?? []).map((r) => h('div', {}, h('span', { class: 'tag' }, r.category), ' ', nodeBtn(r), r.parentId ? h('span', { class: 'faint' }, ` · ${cleanName(byId.get(r.parentId)?.label ?? '')}`) : h('span', { class: 'faint' }, ' · not linked to any work'))));
|
|
1820
|
+
};
|
|
1821
|
+
const colsHead = () => h('div', { class: 'cols' }, h('span', {}, 'Work & plan'), h('span', {}, 'Observed reality'));
|
|
1822
|
+
// The first sentence: up to a full stop — a Chinese one anywhere, a Latin one only before a space or the end, so
|
|
1823
|
+
// "(v0.4 …" is not cut at "v0." (owner 2026-09-30's screenshot of CK-M1).
|
|
1824
|
+
const firstSentence = (t) => { const s = String(t ?? '').replace(/\*\*|__|`/g, '').split(/(?<=[。!?])|(?<=[.!?])(?=\s)|\n/)[0]?.trim() ?? ''; return s.length > 160 ? `${s.slice(0, 159)}…` : s; };
|
|
1825
|
+
/** A module's code and its name: `CK-M1 · 看懂项目全貌与工作关系` → the kicker and the title of its lane card. */
|
|
1826
|
+
const codeAndName = (label) => { const t = cleanName(label ?? ''); const i = t.indexOf(' · '); return i > 0 && i <= 16 ? [t.slice(0, i), t.slice(i + 3)] : [null, t]; };
|
|
1827
|
+
|
|
1828
|
+
const areaBlock = (a) => {
|
|
1829
|
+
const works = worksIn(a.id);
|
|
1830
|
+
const repeated = repeatedSteps(works);
|
|
1831
|
+
const rows = rowsOf(works, a.id, repeated);
|
|
1832
|
+
const intentIds = M.intent.get(a.id) ?? [];
|
|
1833
|
+
const noPlan = works.filter((id) => M.place.get(id)?.band === NO_PLAN).length;
|
|
1834
|
+
const count = M.areaWork.get(a.id);
|
|
1835
|
+
if (only && !keep(a.id) && !rows.length && !intentIds.some(keep)) return null;
|
|
1836
|
+
const [code, name] = codeAndName(a.label);
|
|
1837
|
+
// A cross-cutting foundation (D101; Spec §6.3 List): its block comes after every module's (M.areas puts it last) and
|
|
1838
|
+
// its head keeps the project's name and says `Cross-cutting foundation` in the kicker.
|
|
1839
|
+
const kicker = a.foundation ? (code ? `${code} · ${FOUNDATION_LABEL}` : FOUNDATION_LABEL) : code ?? 'Module';
|
|
1840
|
+
const key = blockKey(a.id);
|
|
1841
|
+
const open = blockOpen(key);
|
|
1842
|
+
const rowsIn = rowIds(works), intentIn = shownIds(intentIds);
|
|
1843
|
+
// The module's own ❓ stands beside its name; the head counts what is inside.
|
|
1844
|
+
const inside = insideMarks({ works: rowsIn, objects: [a.id, ...intentIn], notes: [...rowsIn, ...intentIn, ...(cells.get(a.id)?.items ?? []).map((r) => r.id)] });
|
|
1845
|
+
return h('section', { class: `ablock${a.foundation ? ' foundation' : ''}${sel(a)}`, id: `block-${a.id}`, 'data-node': a.id, 'data-fold': key, 'data-open': String(open) },
|
|
1846
|
+
// The head is a lane card (owner 2026-09-30, from a design study of hers): the small caps kicker — the module's code and
|
|
1847
|
+
// its counts —, the title, the one-sentence effect, and its intent and basis to open. Pressed, it opens or folds
|
|
1848
|
+
// the block's body (E153); folded, the card is the whole block, on two lines.
|
|
1849
|
+
h('div', { class: 'ab-head lane', ...foldHead(key, open) },
|
|
1850
|
+
h('div', { class: 'lane-top' },
|
|
1851
|
+
h('span', { class: 'fold-caret', 'aria-hidden': 'true' }), h('span', { class: 'lane-kicker', title: a.foundation ? `${FOUNDATION_LABEL}: the base the modules share, not a module users face` : null }, kicker),
|
|
1852
|
+
h('span', { class: 'lane-right' }, inside,
|
|
1853
|
+
h('span', { class: 'lane-count', title: count?.shared ? `${count.shared} of them also serve another module: ${count.main} with their main row here, ${count.shared - count.main} listed here dashed` : '' }, areaWorkLine(count)))),
|
|
1854
|
+
h('h3', { class: 'lane-title' }, nodeBtn(a, name)),
|
|
1855
|
+
h('p', { class: 'effect lane-sub' }, firstSentence(a.summary) || h('span', { class: 'faint' }, 'The effect it is for is not written yet.'), a.basis === 'Inferred' ? [' ', basisTag('Inferred')] : null),
|
|
1856
|
+
h('details', { class: 'intent-basis' }, h('summary', {}, 'Intent & basis'),
|
|
1857
|
+
h('div', { class: 'ib-body' }, a.summary ? h('p', {}, a.summary) : h('p', { class: 'faint' }, 'No description of this area yet.'),
|
|
1858
|
+
h('div', {}, basisTag(a.basis) ?? h('span', { class: 'tag' }, 'Explicit'), ' ', validityTag(a.validity), a.noEstablishedLink ? h('span', { class: 'tag red' }, 'No established link') : null, ' ',
|
|
1859
|
+
h('button', { class: 'btn small', onClick: () => select(NODE_SEL(a)) }, 'Details'))))),
|
|
1860
|
+
h('div', { class: 'ab-body', id: foldBodyId(key), 'data-fold-body': '', hidden: !open },
|
|
1861
|
+
h('div', { class: 'rddrow' }, intentFold(intentIds, 'Requirements, designs & decisions', `intent:${a.id}`) ?? h('span', { class: 'faint' }, 'No requirement, design or decision placed on this area yet'), ...intentFlagsOf([a.id, ...intentIds])),
|
|
1862
|
+
colsHead(),
|
|
1863
|
+
repeatedNote(repeated),
|
|
1864
|
+
...(rows.length ? rows : [h('div', { class: 'faint pad' }, 'No work item placed in this area yet')]),
|
|
1865
|
+
noPlan ? h('div', { class: 'faint pad' }, `${noPlan} of these ${noPlan === 1 ? 'is' : 'are'} not in a plan yet`) : null,
|
|
1866
|
+
realityOf(a.id)));
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
// The ringed cross-cutting block, last: what reaches several areas or none, the work of no area, each plan's own row.
|
|
1870
|
+
const crossBlock = () => {
|
|
1871
|
+
// Work for a whole plan — all of the plan's modules, no single one — is placed, with the Keeper's written reason
|
|
1872
|
+
// (CN, E152; Spec §1.4): listed apart from the work no record places in an area yet.
|
|
1873
|
+
const noArea = listRowsOf(M, null);
|
|
1874
|
+
const wholeWhy = new Map(noArea.filter((r) => r.whole).map((r) => [r.id, r.whole]));
|
|
1875
|
+
const works = noArea.filter((r) => !r.whole).map((r) => r.id);
|
|
1876
|
+
const repeated = repeatedSteps(works);
|
|
1877
|
+
const rows = rowsOf(works, null, repeated);
|
|
1878
|
+
// The Keeper's reason stands beside the tag, on the row's one line; the whole of it is in the hover.
|
|
1879
|
+
const wholeTag = (n) => [h('span', { class: 'tag whole-tag', title: `${WHOLE_PLAN_LABEL}: it serves all of its plan’s modules and no single one` }, WHOLE_PLAN_LABEL.toLowerCase()), wholeWhy.get(n.id) ? h('span', { class: 'wr-why', title: wholeWhy.get(n.id) }, wholeWhy.get(n.id)) : null];
|
|
1880
|
+
// A plan's unit also names the work of its batches; work for the whole plan is listed here in its own right, with why.
|
|
1881
|
+
const wholeIds = [...wholeWhy.keys()].filter((id) => vis(byId.get(id)) && keep(id) && !procView?.works?.[id]?.stepOf)
|
|
1882
|
+
.sort((a, b) => bandIndex(a) - bandIndex(b) || byId.get(a).label.localeCompare(byId.get(b).label, undefined, { numeric: true }));
|
|
1883
|
+
const wholeRows = wholeIds.map((id) => { const row = kProcess.workRow(byId.get(id), { ...procCtx, extraLeft: wholeTag }); row.classList.add('whole-plan'); return row; });
|
|
1884
|
+
const planRows = M.plans.filter((p) => keep(p.id) || (M.exec.get(p.id) ?? []).some(keep)).map((p) => h('div', { class: 'plan-row' },
|
|
1885
|
+
procView?.works?.[p.id] ? kProcess.unitBlock(p, procCtx) : h('div', { class: 'unit' }, h('div', { class: 'row2' }, h('div', {}, h('span', { class: 'tag' }, 'Plan'), ' ', nodeBtn(p), ' ', p.progress ? h('span', { class: 'tag' }, p.progress) : null), h('div', {}, basisBtn(p)))),
|
|
1886
|
+
intentFold(M.exec.get(p.id) ?? [], 'Execution decisions', `exec:${p.id}`) ?? h('div', { class: 'faint pad' }, 'No execution decision placed on this plan yet')));
|
|
1887
|
+
const u = M.unplaced;
|
|
1888
|
+
const open = blockOpen(CROSS_KEY);
|
|
1889
|
+
const ringIds = shownIds([...M.ring.multi, ...M.ring.whole, ...M.ring.product, ...M.ring.none]);
|
|
1890
|
+
const planIds = M.plans.filter((p) => keep(p.id) || (M.exec.get(p.id) ?? []).some(keep)).map((p) => p.id);
|
|
1891
|
+
const execIds = shownIds(planIds.flatMap((id) => M.exec.get(id) ?? []));
|
|
1892
|
+
const workIn = [...wholeIds, ...rowIds(works)];
|
|
1893
|
+
const inside = insideMarks({ works: [...planIds, ...workIn], objects: [...ringIds, ...execIds], notes: [...planIds, ...workIn, ...ringIds, ...execIds, ...(cells.get(null)?.items ?? []).map((r) => r.id)] });
|
|
1894
|
+
const plural = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
1895
|
+
const holds = [ringIds.length + execIds.length ? `${ringIds.length + execIds.length} requirements, designs & decisions` : null, planIds.length ? plural(planIds.length, 'plan') : null, plural(workIn.length, 'work item')].filter(Boolean).join(' · ');
|
|
1896
|
+
return h('section', { class: 'ablock cross', id: 'block-cross', 'data-fold': CROSS_KEY, 'data-open': String(open) },
|
|
1897
|
+
h('div', { class: 'ab-head lane', ...foldHead(CROSS_KEY, open) },
|
|
1898
|
+
h('div', { class: 'lane-top' },
|
|
1899
|
+
h('span', { class: 'fold-caret', 'aria-hidden': 'true' }), h('span', { class: 'lane-kicker' }, 'Not one-to-one with the modules above'),
|
|
1900
|
+
h('span', { class: 'lane-right' }, inside, h('span', { class: 'lane-count' }, holds))),
|
|
1901
|
+
h('h3', { class: 'lane-title' }, 'Cross-cutting')),
|
|
1902
|
+
h('div', { class: 'ab-body', id: foldBodyId(CROSS_KEY), 'data-fold-body': '', hidden: !open },
|
|
1903
|
+
h('div', { class: 'rddrow' }, intentFold(M.ring.multi, 'Reaching several areas', 'ring:multi'), intentFold(M.ring.whole, WHOLE_LABEL, 'ring:whole'), intentFold(M.ring.product, 'Only on the product — not placed yet', 'ring:product'), intentFold(M.ring.none, 'Not placed yet', 'ring:none'), ...intentFlagsOf([...M.ring.multi, ...M.ring.whole, ...M.ring.product, ...M.ring.none])),
|
|
1904
|
+
u.intent ? h('div', { class: 'faint pad' }, `${u.intent} requirement${u.intent === 1 ? '' : 's'}, design${u.intent === 1 ? '' : 's'} or decision${u.intent === 1 ? '' : 's'} not placed yet (the Keeper places each on the area or plan it acts on)`) : null,
|
|
1905
|
+
h('h4', { class: 'sub-h' }, 'Plans'), ...planRows,
|
|
1906
|
+
wholeRows.length ? [h('h4', { class: 'sub-h' }, `Work for a whole plan · ${wholeRows.length}`), colsHead(), ...wholeRows] : null,
|
|
1907
|
+
h('h4', { class: 'sub-h' }, `Work of no area${rows.length ? ` · ${rows.length}` : ''}`),
|
|
1908
|
+
colsHead(),
|
|
1909
|
+
repeatedNote(repeated),
|
|
1910
|
+
...(rows.length ? rows : [h('div', { class: 'faint pad' }, 'Every work item is placed in an area')]),
|
|
1911
|
+
realityOf(null)));
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
const words = g.nodes.filter((n) => n.group === "Owner's words" && vis(n) && keep(n.id)).sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));
|
|
1915
|
+
const wordsFold = words.length ? h('details', { class: 'ref-fold words-fold', open: state.listWordsOpen || words.some((w) => w.id === state.selection?.id) || null, onToggle: (e) => { state.listWordsOpen = e.currentTarget.open; } },
|
|
1916
|
+
h('summary', {}, h('span', { class: 'plate-label' }, "Owner's words"), ' ', h('span', { class: 'plate-count' }, String(words.length)), ' ', foldMark(words.map((w) => w.id))),
|
|
1917
|
+
...words.map((w) => h('div', {}, nodeBtn(w), ' ', validityTag(w.validity)))) : null;
|
|
1918
|
+
const goals = g.nodes.filter((n) => n.category === 'Goal' && vis(n));
|
|
1919
|
+
const products = g.nodes.filter((n) => n.category === 'Product' && vis(n));
|
|
1920
|
+
const marksOf = (x) => (x.noteAttention || x.noteCount ? [' ', noteMark(x.noteAttention, x.noteCount)] : null);
|
|
1921
|
+
const productSlot = (x) => h('button', { class: `plate-slot${sel(x)}`, 'data-node': x.id, onClick: () => select(NODE_SEL(x)) }, x.label, marksOf(x));
|
|
1922
|
+
const goalLine = (x) => h('li', {}, h('button', { class: `goal-line${sel(x)}`, 'data-node': x.id, onClick: () => select(NODE_SEL(x)) }, x.label), marksOf(x));
|
|
1923
|
+
// The top plate (the Sign in card): pressed at its title line it folds to one line — the project, the product's
|
|
1924
|
+
// name, how many owner's words and goals it holds, and what inside is open or carries a note — and opens again.
|
|
1925
|
+
const plateOpen = wordsFold || products.length || goals.length ? blockOpen(PLATE_KEY) : false;
|
|
1926
|
+
const plateInside = [...words, ...products, ...goals].map((x) => x.id);
|
|
1927
|
+
const plate = wordsFold || products.length || goals.length ? h('section', { class: 'list-plate', 'aria-label': 'The owner\u2019s words, the product and its goals', 'data-fold': PLATE_KEY, 'data-open': String(plateOpen) },
|
|
1928
|
+
h('i', { class: 'rivet r1', 'aria-hidden': 'true' }), h('i', { class: 'rivet r2', 'aria-hidden': 'true' }), h('i', { class: 'rivet r3', 'aria-hidden': 'true' }), h('i', { class: 'rivet r4', 'aria-hidden': 'true' }),
|
|
1929
|
+
h('div', { class: 'plate-head', ...foldHead(PLATE_KEY, plateOpen) },
|
|
1930
|
+
h('span', { class: 'fold-caret', 'aria-hidden': 'true' }),
|
|
1931
|
+
h('p', { class: 'plate-title' }, state.project?.name ?? 'The project'),
|
|
1932
|
+
h('span', { class: 'plate-brief', title: products.map((x) => x.label).join(' · ') || null }, products.map((x) => x.label).join(' · ')),
|
|
1933
|
+
h('span', { class: 'plate-holds' }, insideMarks({ objects: plateInside }),
|
|
1934
|
+
h('span', { class: 'plate-holds-text' }, [words.length ? `${words.length} owner\u2019s word${words.length === 1 ? '' : 's'}` : null, goals.length ? `${goals.length} goal${goals.length === 1 ? '' : 's'}` : null].filter(Boolean).join(' · ')))),
|
|
1935
|
+
h('div', { class: 'plate-body', id: foldBodyId(PLATE_KEY), 'data-fold-body': '', hidden: !plateOpen },
|
|
1936
|
+
wordsFold ? h('div', { class: 'plate-group goals' }, wordsFold) : null,
|
|
1937
|
+
products.length ? h('div', { class: 'plate-group plate-product goals' }, h('span', { class: 'plate-label' }, 'Product'), ...products.map((x) => h('div', { class: 'plate-row' }, productSlot(x), docTag(x.id)))) : null,
|
|
1938
|
+
goals.length ? h('div', { class: 'plate-group plate-goals' }, h('span', { class: 'plate-label' }, 'Goals ', h('span', { class: 'plate-count' }, String(goals.length))), h('ol', { class: 'goal-list' }, ...goals.map(goalLine))) : null)) : null;
|
|
1939
|
+
const wrap = h('div', { class: 'list-wrap by-module' },
|
|
1940
|
+
only ? h('div', { class: 'list-only' }, `Showing only the objects whose notes need you (${only.size})`, ' ', h('button', { class: 'text-btn', onClick: () => { setAttentionFilter(false); } }, 'Show everything')) : null,
|
|
1941
|
+
plate,
|
|
1942
|
+
procView || M.generations.length ? kProcess.generationBands(procCtx, M.generations) : null,
|
|
1943
|
+
...areas.map(areaBlock),
|
|
1944
|
+
crossBlock(),
|
|
1945
|
+
areas.length === 0 ? h('div', { class: 'faint', style: { padding: '12px' } }, 'No area established yet.') : null);
|
|
1946
|
+
listBlockKeys = [...new Set(drawn)];
|
|
1947
|
+
return { wrap };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async function renderList(main, g, ov) {
|
|
1951
|
+
graph._data = g;
|
|
1952
|
+
await kProcess.load(state.projectId);
|
|
1953
|
+
if (kProcess.current()) kProcess.indexNodes(g.nodes);
|
|
1954
|
+
const { wrap } = buildList(g, ov);
|
|
1955
|
+
// The same control row as over the graph, outside the table: a refreshed List does not rebuild it (AC-37).
|
|
1956
|
+
setTopTools(controlRow({ mode: 'list' }).el);
|
|
1957
|
+
append(main, wrap);
|
|
1958
|
+
mountStrip(main, ov);
|
|
1959
|
+
if (state.listTarget) requestAnimationFrame(revealInList);
|
|
1960
|
+
}
|
|
1961
|
+
/** New assets or a filter while the List is open: the table is replaced only if it changed, and stays where it was. */
|
|
1962
|
+
async function refreshList(g, ov) {
|
|
1963
|
+
if (!g || !ov) [g, ov] = await Promise.all([api(`/api/projects/${P()}/graph`), api(overviewUrl())]);
|
|
1964
|
+
await kProcess.load(state.projectId);
|
|
1965
|
+
if (kProcess.current()) kProcess.indexNodes(g.nodes);
|
|
1966
|
+
const current = document.querySelector('#main .list-wrap');
|
|
1967
|
+
if (!current) { renderBody(); return; }
|
|
1968
|
+
graph._data = g;
|
|
1969
|
+
const { wrap } = buildList(g, ov);
|
|
1970
|
+
if (wrap.outerHTML !== current.outerHTML) { const top = current.scrollTop; current.replaceWith(wrap); wrap.scrollTop = top; }
|
|
1971
|
+
swapStrip(strip(ov));
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// ── Code (§6.17): the territories of the current version, next to Graph and List ─────────────────────────────
|
|
1975
|
+
/**
|
|
1976
|
+
* The `Code` of the Project graph's control row: AS's `ui/k/code-view.js` draws the territories; this mounts it,
|
|
1977
|
+
* hands it the view and the bridges (to a work's process, a note, a send-back, a file), and says `No data for this
|
|
1978
|
+
* yet` when the endpoint is not built yet.
|
|
1979
|
+
*/
|
|
1980
|
+
async function renderCode(main, ov) {
|
|
1981
|
+
if (!main) return;
|
|
1982
|
+
setTopTools(controlRow({ mode: 'code' }).el);
|
|
1983
|
+
const container = h('div', { class: 'k-code', id: 'k-code' });
|
|
1984
|
+
append(main, container);
|
|
1985
|
+
mountStrip(main, ov);
|
|
1986
|
+
const code = await kData.getCode(state.projectId).catch((e) => ({ error: e }));
|
|
1987
|
+
// 404 — the endpoint is not built yet: `No data for this yet`. Any other answer carries the server's own reason
|
|
1988
|
+
// (e.g. the ledger is not carried by this build): shown as it arrived, nothing invented.
|
|
1989
|
+
if (!code || code.error) { state.codeTarget = null; append(container, h('div', { class: 'empty' }, h('h2', {}, 'Code'), h('p', {}, code?.error ? `Could not load: ${code.error.message}` : kData.noData()))); return; }
|
|
1990
|
+
const ctx = {
|
|
1991
|
+
fetchTerritory: (tid) => kData.getTerritory(state.projectId, tid),
|
|
1992
|
+
openWork: (workId) => { state.graphMode = 'graph'; state.selection = { kind: 'node', id: workId, label: workId }; state.pendingPopover = {}; renderBody(); },
|
|
1993
|
+
openNote: (noteId) => select({ kind: 'note', id: noteId, label: noteId }),
|
|
1994
|
+
// The view carries the send-backs its anomalies have (CodeView.sendBacks), so an anomaly reaches its send-back
|
|
1995
|
+
// however the page was opened; the process view is only a second place to look (QC AY B6).
|
|
1996
|
+
getSendBack: (id) => code.sendBacks?.[id] ?? (kProcess.current()?.sendBacks ?? []).find((s) => s.id === id) ?? null,
|
|
1997
|
+
openSendBack: (sb, anchorEl) => kProcess.openSendBackPopover(sb, () => { if (!anchorEl?.isConnected) return null; const r = anchorEl.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; }),
|
|
1998
|
+
copy: (text) => kProcess.copyText(text),
|
|
1999
|
+
// A file of the current version, found by the ledger's fileRefs and read as the ledger read it (§6.17; QC AY B13).
|
|
2000
|
+
openFile: (repo, path) => openCodeFile(repo, path),
|
|
2001
|
+
};
|
|
2002
|
+
renderCodeView(container, code, ctx);
|
|
2003
|
+
// Arrived from a work's `Code it changed`: its territory is brought into view, marked, and its files opened.
|
|
2004
|
+
const target = state.codeTarget;
|
|
2005
|
+
state.codeTarget = null;
|
|
2006
|
+
if (target) {
|
|
2007
|
+
const row = container.querySelector(`tr[data-terr="${CSS.escape(target)}"]`);
|
|
2008
|
+
if (!row) { toast('That territory is not in the current version’s Code'); return; }
|
|
2009
|
+
if (!container.querySelector(`tr.kv-drill[data-tid="${CSS.escape(target)}"]`)) row.querySelector('.kv-tname')?.click();
|
|
2010
|
+
row.scrollIntoView({ block: 'center' });
|
|
2011
|
+
row.classList.add('flash', 'kv-target');
|
|
2012
|
+
setTimeout(() => row.classList.remove('flash'), 1800);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** A file of the current version in the reading dialog (§6.4 原文阅读): where it is, the version read, its lines numbered. */
|
|
2017
|
+
async function openCodeFile(repo, path, from = 1) {
|
|
2018
|
+
let f;
|
|
2019
|
+
try { f = await kData.getCodeFile(state.projectId, { repo, path, from }); } catch (e) { toast(`${path}: ${e.message}`); return; }
|
|
2020
|
+
const quote = h('div', { class: 'source-quote' }, ...f.text.split('\n').map((line, i) => h('div', { class: 'src-line' }, h('span', { class: 'no' }, f.fromLine + i), h('span', { class: 'tx' }, line))));
|
|
2021
|
+
openDialog(path.split('/').pop() ?? path, [
|
|
2022
|
+
h('dl', { class: 'kv' },
|
|
2023
|
+
h('dt', {}, 'Kind'), h('dd', {}, `code${f.lang ? ` · ${f.lang}` : ''}${f.generated ? ' · generated' : ''}`),
|
|
2024
|
+
h('dt', {}, 'Location'), h('dd', { class: 'mono' }, `${f.repo} › ${f.path}`),
|
|
2025
|
+
h('dt', {}, 'Version'), h('dd', {}, f.commit ? h('span', { class: 'mono' }, f.commit) : '—', f.occurred ? ` · ${kProcess.fmtOccurred(f.occurred)}` : '', ' — the current version the ledger read'),
|
|
2026
|
+
h('dt', {}, 'Lines'), h('dd', {}, f.fromLine === 1 && f.toLine >= f.lines ? `${f.lines}` : `${f.fromLine}–${f.toLine} of ${f.lines}`)),
|
|
2027
|
+
quote,
|
|
2028
|
+
f.nextFromLine ? h('div', { class: 'dialog-foot' }, h('button', { class: 'btn small', onClick: () => openCodeFile(repo, path, f.nextFromLine) }, `Lines ${f.nextFromLine}–…`)) : null,
|
|
2029
|
+
]);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// ── Changes (§6.5) ───────────────────────────────────────────────────────
|
|
2033
|
+
// ── Readability (D46, CKC-09 AC-26/27, AC-37) ────────────────────────────
|
|
2034
|
+
/**
|
|
2035
|
+
* What the graph's own check found, said plainly: where the picture is hard to read, which objects, and a way to look
|
|
2036
|
+
* at it that is readable (focus a path, the whole picture anyway, the List, one object brought into view). Nothing is
|
|
2037
|
+
* removed from the picture to make it look tidier. The control row carries the mark and its number (graph-tools.js);
|
|
2038
|
+
* this is the panel behind the mark, floating over the graph, so reading it costs the picture no height.
|
|
2039
|
+
*/
|
|
2040
|
+
function readabilityPanel(report, { focusPath }) {
|
|
2041
|
+
const m = readabilityMark(report);
|
|
2042
|
+
const name = (id) => graphCtl?.labelOf(id) ?? id;
|
|
2043
|
+
const objBtn = (id) => h('button', { class: 'text-btn', title: 'Bring it into view', onClick: () => bringIntoView(id) }, name(id));
|
|
2044
|
+
// What changes the whole picture puts the panel away, so the picture asked for can be seen.
|
|
2045
|
+
const focusBtn = (id) => h('button', { class: 'btn small', title: 'Show only the path through this object', onClick: () => { flyout.close(); focusPath(id); } }, 'Focus path');
|
|
2046
|
+
const part = (i) => {
|
|
2047
|
+
if (i.kind === 'overlap') return h('div', { class: 'read-issue' }, h('b', {}, i.text), ...i.pairs.slice(0, 6).map(([a, b]) => h('div', { class: 'row wrap' }, objBtn(a), h('span', { class: 'faint' }, 'and'), objBtn(b), focusBtn(a))), i.pairs.length > 6 ? h('small', { class: 'faint' }, `and ${i.pairs.length - 6} more`) : null);
|
|
2048
|
+
if (i.kind === 'behind') return h('div', { class: 'read-issue' }, h('b', {}, i.text), h('small', { class: 'faint' }, 'The worst ones; Focus path shows a line with only what it connects:'), ...i.worst.slice(0, 5).map((w) => h('div', { class: 'row wrap' }, objBtn(w.source), h('span', { class: 'faint' }, '→'), objBtn(w.target), h('small', { class: 'faint' }, `behind ${w.hidden.length}`), focusBtn(w.source))));
|
|
2049
|
+
// The graph does not choose a view smaller than its names can be read (CKC-09 AC-39); the owner may: `Show all anyway`
|
|
2050
|
+
// is the whole picture at whatever size that takes, and `Fit` brings the readable view back. Or see less at once.
|
|
2051
|
+
if (i.kind === 'size') return h('div', { class: 'read-issue' }, h('b', {}, i.text), h('div', { class: 'row wrap' }, h('button', { class: 'btn small', title: 'The whole picture in the window, however small its words get; Fit brings the readable size back', onClick: () => { flyout.close(); graphCtl?.fitAll(); } }, 'Show all anyway'), h('button', { class: 'btn small', onClick: () => { flyout.close(); graph._openList?.(); } }, 'Open List'), h('small', { class: 'faint' }, 'or fold what you opened (double-click the area or work item again), or select an area or work item and use Focus path')));
|
|
2052
|
+
if (i.kind === 'bundle') return h('div', { class: 'read-issue' }, h('b', {}, i.text), ...i.hubs.slice(0, 5).map((x) => h('div', { class: 'row wrap' }, objBtn(x.id), h('small', { class: 'faint' }, `${x.degree} lines`), focusBtn(x.id))));
|
|
2053
|
+
return null;
|
|
2054
|
+
};
|
|
2055
|
+
return [
|
|
2056
|
+
h('div', { class: 'read-head' }, h('span', { class: `read-flag${m.count ? ' alert' : ''}` }, '◐'),
|
|
2057
|
+
h('b', {}, m.count ? `Hard to read in ${m.count} place${m.count === 1 ? '' : 's'}` : 'Nothing is hard to read'),
|
|
2058
|
+
...m.kinds.map((k) => h('span', { class: 'tag' }, k.kind === 'size' ? k.label : `${k.label} ${k.places}`))),
|
|
2059
|
+
h('small', { class: 'faint' }, m.count ? 'Nothing is hidden to tidy it.' : 'Checked each time the picture is drawn: objects on top of each other, lines behind objects, a picture too big to read at once, bunched lines.'),
|
|
2060
|
+
m.count ? h('div', { class: 'read-body' }, ...report.issues.map(part)) : null,
|
|
2061
|
+
];
|
|
2062
|
+
}
|
|
2063
|
+
/** An object named in the panel is brought to the middle of the graph — and, the panel floating there, out from under it. */
|
|
2064
|
+
function bringIntoView(id) {
|
|
2065
|
+
if (!graphCtl) return;
|
|
2066
|
+
graphCtl.reveal(id);
|
|
2067
|
+
const r = graphCtl.rectOf(id);
|
|
2068
|
+
if (!r || flyout.el.hidden) return;
|
|
2069
|
+
const p = flyout.el.getBoundingClientRect(), box = graphCtl.cy.container().getBoundingClientRect();
|
|
2070
|
+
if (!(r.left < p.right && p.left < r.right && r.top < p.bottom && p.top < r.bottom)) return;
|
|
2071
|
+
const toRight = p.right + 12 - r.left, toLeft = p.left - 12 - r.right;
|
|
2072
|
+
const dx = r.right + toRight <= box.right - 8 ? toRight : r.left + toLeft >= box.left + 8 ? toLeft : 0;
|
|
2073
|
+
if (dx) graphCtl.cy.panBy({ x: dx, y: 0 });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// ── Walkthrough of a change follow-up (Spec §4.2, D47 supplement) ──────────
|
|
2077
|
+
/**
|
|
2078
|
+
* A note from a change follow-up opens into a walkthrough: what the change altered, which objects it reached, and for
|
|
2079
|
+
* each one whether it followed, one place at a time, back and forth. Everything shown is read from the assets — the
|
|
2080
|
+
* change record, its propagation entries with their sources or reasons, and the objects' names — nothing is written
|
|
2081
|
+
* for the occasion, and no legend is needed. Offered only from the note and from Notes (attention).
|
|
2082
|
+
*/
|
|
2083
|
+
const PROP_ORDER = ['Still on old understanding', 'Updated', 'Reusable as is', 'Not yet checked'];
|
|
2084
|
+
const propTag = (st) => h('span', { class: `tag ${st === 'Still on old understanding' ? 'amber' : st === 'Updated' ? 'green' : st === 'Reusable as is' ? 'blue' : ''}` }, st);
|
|
2085
|
+
|
|
2086
|
+
export async function openWalkthrough(noteId) {
|
|
2087
|
+
const [n, c, g] = await Promise.all([
|
|
2088
|
+
api(`/api/projects/${P()}/notes/${encodeURIComponent(noteId)}`),
|
|
2089
|
+
api(`/api/projects/${P()}/changes`),
|
|
2090
|
+
api(`/api/projects/${P()}/graph`),
|
|
2091
|
+
]);
|
|
2092
|
+
const ids = (n.cameFrom?.changes ?? []).map((x) => x.id);
|
|
2093
|
+
const rows = ids.map((id) => c.changes.find((x) => x.id === id)).filter(Boolean);
|
|
2094
|
+
if (!rows.length) { toast('The change this note came from is no longer in the Change log'); return; }
|
|
2095
|
+
const nodes = new Map(g.nodes.map((x) => [x.id, x]));
|
|
2096
|
+
const mount = new Set(n.mount?.ids ?? []);
|
|
2097
|
+
const dialog = document.querySelector('#dialog');
|
|
2098
|
+
const body = h('div', { class: 'walk-root' });
|
|
2099
|
+
let change = 0; let cur = 0; let steps = [];
|
|
2100
|
+
|
|
2101
|
+
const stepsOf = (row) => row.propagation
|
|
2102
|
+
.map((e) => ({ ...e, label: nodes.get(e.nodeId)?.label ?? row.affectsLabels.find((a) => a.id === e.nodeId)?.label ?? e.nodeId, category: nodes.get(e.nodeId)?.category ?? '' }))
|
|
2103
|
+
.sort((a, b) => (Number(mount.has(b.nodeId)) - Number(mount.has(a.nodeId))) || PROP_ORDER.indexOf(a.state) - PROP_ORDER.indexOf(b.state));
|
|
2104
|
+
|
|
2105
|
+
const draw = () => {
|
|
2106
|
+
const row = rows[change];
|
|
2107
|
+
steps = stepsOf(row);
|
|
2108
|
+
cur = Math.max(0, Math.min(steps.length - 1, cur));
|
|
2109
|
+
const counts = PROP_ORDER.map((st) => [st, steps.filter((e) => e.state === st).length]).filter(([, k]) => k);
|
|
2110
|
+
const step = steps[cur];
|
|
2111
|
+
const spine = h('div', { class: 'walk-spine' }, ...steps.map((e, i) => h('button', { class: `walk-item${i === cur ? ' on' : ''}`, onClick: () => { cur = i; draw(); } },
|
|
2112
|
+
h('div', { class: 'row wrap' }, propTag(e.state), mount.has(e.nodeId) ? h('span', { class: 'tag origin' }, 'this note') : null),
|
|
2113
|
+
h('div', { class: 'walk-name' }, e.label),
|
|
2114
|
+
e.sourceOrReason ? h('div', { class: 'walk-why' }, e.sourceOrReason) : null)));
|
|
2115
|
+
body.replaceChildren(...[
|
|
2116
|
+
rows.length > 1 ? h('div', { class: 'row wrap' }, h('small', {}, `This note is about ${rows.length} changes`), ...rows.map((r, i) => h('button', { class: `btn small${i === change ? ' primary' : ''}`, onClick: () => { change = i; cur = 0; draw(); } }, r.title.slice(0, 40)))) : null,
|
|
2117
|
+
h('div', {}, h('h3', { class: 'walk-title' }, row.title), h('div', { class: 'row wrap' }, h('small', {}, fmtTime(row.at)), effectTag(row.effect), row.by?.identity === 'Decision' ? h('span', { class: 'tag amber' }, 'Owner decision') : row.by?.author?.name ? h('span', { class: 'tag' }, row.by.author.name) : null, ...row.sources.map((x) => h('button', { class: 'text-btn', onClick: () => openSource(x.id) }, x.title)))),
|
|
2118
|
+
row.before || row.after ? h('div', { class: 'diff' }, h('div', { class: 'old' }, h('small', {}, 'Before'), h('del', {}, row.before || '—')), h('div', { class: 'new' }, h('small', {}, 'After'), row.after || '—')) : null,
|
|
2119
|
+
row.summary ? h('p', { class: 'muted' }, row.summary) : null,
|
|
2120
|
+
h('div', { class: 'row wrap' }, ...counts.map(([st, k]) => h('span', {}, propTag(st), ` ${k}`))),
|
|
2121
|
+
h('div', { class: 'row spread walk-bar' },
|
|
2122
|
+
h('b', {}, `Place ${cur + 1} / ${steps.length}`),
|
|
2123
|
+
h('div', { class: 'row' }, h('button', { class: 'btn small', disabled: cur === 0 || null, onClick: () => { cur--; draw(); } }, '← Previous'), h('button', { class: 'btn small', disabled: cur >= steps.length - 1 || null, onClick: () => { cur++; draw(); } }, 'Next →'))),
|
|
2124
|
+
h('div', { class: 'walk' }, spine,
|
|
2125
|
+
step ? h('div', { class: 'walk-step' },
|
|
2126
|
+
h('div', { class: 'row wrap' }, propTag(step.state), step.category ? h('span', { class: 'tag' }, step.category) : null),
|
|
2127
|
+
h('h3', {}, step.label),
|
|
2128
|
+
step.sourceOrReason ? h('p', {}, step.sourceOrReason) : h('p', { class: 'faint' }, step.state === 'Not yet checked' ? 'Not judged yet; the next Follow up will judge it.' : 'No source or reason was recorded.'),
|
|
2129
|
+
step.state === 'Not yet checked' ? null : h('small', { class: 'faint' }, `Judged ${fmtTime(step.updatedAt)}`),
|
|
2130
|
+
nodes.has(step.nodeId) ? h('div', {}, h('button', { class: 'btn small', onClick: () => { dialog.close(); select({ kind: 'node', id: step.nodeId, label: step.label }); } }, 'Open this object')) : null) : h('div', { class: 'faint' }, 'This change reached nothing downstream.')),
|
|
2131
|
+
].filter(Boolean));
|
|
2132
|
+
body.querySelector('.walk-item.on')?.scrollIntoView({ block: 'nearest' });
|
|
2133
|
+
};
|
|
2134
|
+
const keys = (e) => {
|
|
2135
|
+
if (!body.isConnected) return; // another dialog layer is on top of the walkthrough
|
|
2136
|
+
if (e.key === 'ArrowRight' && cur < steps.length - 1) { cur++; draw(); e.preventDefault(); }
|
|
2137
|
+
else if (e.key === 'ArrowLeft' && cur > 0) { cur--; draw(); e.preventDefault(); }
|
|
2138
|
+
};
|
|
2139
|
+
openDialog(`Walkthrough · ${n.versions[n.versions.length - 1].title}`, [body], { onClose: () => { dialog.classList.remove('wide'); dialog.removeEventListener('keydown', keys); } });
|
|
2140
|
+
dialog.classList.add('wide');
|
|
2141
|
+
dialog.addEventListener('keydown', keys);
|
|
2142
|
+
draw();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* A semantic patch as a row of the Change log (§1.17, §6.5; CKC-26 AC-4): when the supersession happened, what no longer
|
|
2147
|
+
* holds (struck through), what replaced it — which opens the patch — who is affected, and what must not pass as current
|
|
2148
|
+
* again. Read from the patch itself, so the log and the patch never say different things.
|
|
2149
|
+
*/
|
|
2150
|
+
function patchEvent(p, target) {
|
|
2151
|
+
return h('div', { class: `tl-event tl-patch${target === p.id || target === p.number ? ' sel' : ''}`, id: `chg-${p.id}`, dataset: { patch: p.id } },
|
|
2152
|
+
h('time', { title: `${p.occurred.basis}${p.occurred.anchor ? ` — ${p.occurred.anchor}` : ''}` }, kProcess.fmtOccurred(p.occurred)),
|
|
2153
|
+
h('div', {}, h('span', { class: 'tag purple' }, 'Semantic patch'), ' ', h('span', { class: 'tag', title: 'Numbered by the Keeper' }, `${p.number} · Keeper's number`), ' ', effectTag('Replaced'), p.partial ? [' ', h('span', { class: 'tag amber', title: 'Only part of the old state is withdrawn; the rest stays current' }, 'Partial')] : null),
|
|
2154
|
+
h('h3', {}, p.title),
|
|
2155
|
+
h('div', { class: 'diff' },
|
|
2156
|
+
h('div', { class: 'old' }, h('small', {}, 'No longer holds'), h('del', {}, p.invalidated)),
|
|
2157
|
+
h('div', { class: 'new' }, h('small', {}, 'Replaced by'), h('button', { class: 'text-btn tl-patch-link', title: 'Open the semantic patch: its four answers and the evidence', onClick: () => kProcess.openPatch(p.id) }, p.replacedBy))),
|
|
2158
|
+
p.affects.length || p.affectsText ? h('div', { class: 'row wrap' }, h('small', {}, 'Affects'), p.affectsText ? h('span', { class: 'muted' }, p.affectsText) : null,
|
|
2159
|
+
...p.affects.map((a) => h('button', { class: 'text-btn', onClick: () => select({ kind: 'node', id: a.id, label: a.label }) }, a.label))) : null,
|
|
2160
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'Must not pass as current'), h('span', { class: 'muted' }, p.mustNotPassAsCurrent)),
|
|
2161
|
+
h('div', { class: 'row' }, h('button', { class: 'btn small', onClick: () => kProcess.openPatch(p.id) }, 'Patch details')));
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const changes = {
|
|
2165
|
+
async render(main) {
|
|
2166
|
+
const [c, allPatches] = await Promise.all([api(`/api/projects/${P()}/changes`), kData.getPatches(state.projectId).catch(() => null)]);
|
|
2167
|
+
// Confirmed patches only: a draft is not yet checked, a rejected one withdrew nothing (§3.3 cross-check).
|
|
2168
|
+
const patches = (allPatches ?? []).filter((p) => p.status === 'Confirmed');
|
|
2169
|
+
const target = state.routeRest ? decodeURIComponent(state.routeRest) : null;
|
|
2170
|
+
append(main, h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Change log'), h('p', { class: 'sub' }, `Oldest to newest · ${c.changes.length} change record${c.changes.length === 1 ? '' : 's'}${patches.length ? ` · ${patches.length} semantic patch${patches.length === 1 ? '' : 'es'}` : ''} · ${c.pending} pending change${c.pending === 1 ? '' : 's'} not yet organized`))));
|
|
2171
|
+
if (c.changes.length === 0 && patches.length === 0) { append(main, emptyState('No change history has been established for this project.', 'Change records are created when material shows a decision, replacement, deferral, abandonment or completion, with the evidence for it.')); return; }
|
|
2172
|
+
const event = (x) => h('div', { class: `tl-event${target === x.id || state.selection?.id === x.id ? ' sel' : ''}`, id: `chg-${x.id}` },
|
|
2173
|
+
h('time', {}, fmtTime(x.at), x.atSource === 'observed' ? h('span', { class: 'faint' }, ' (first observed; the material gives no time)') : null),
|
|
2174
|
+
h('div', {}, x.work ? h('span', { class: 'tag' }, x.work.kind) : [h('span', { class: 'tag' }, x.material), ' ', effectTag(x.effect)], ' ', x.by?.identity === 'Decision' ? h('span', { class: 'tag amber' }, 'Owner decision') : x.by?.author?.name ? h('span', { class: 'tag' }, x.by.author.name) : null),
|
|
2175
|
+
// A piece of work leads with what it was, and its net changes are listed under it (D56); each says what changed
|
|
2176
|
+
// and, where the material gave one, why. A record written before this keeps its single-change shape.
|
|
2177
|
+
x.work ? h('h3', {}, x.work.label, x.work.openEnded ? h('small', { class: 'faint' }, ' · still going when the round began') : null) : h('h3', {}, x.title),
|
|
2178
|
+
x.work ? null : h('p', { class: 'muted' }, x.summary),
|
|
2179
|
+
x.work && (x.items ?? []).length
|
|
2180
|
+
? h('div', { class: 'stack' }, ...x.items.map((i) => h('div', { class: 'item' },
|
|
2181
|
+
h('div', {}, effectTag(i.effect), ' ', h('span', { class: 'tag' }, i.material), ' ', h('b', {}, i.title)),
|
|
2182
|
+
i.summary ? h('div', { class: 'muted' }, i.summary) : null,
|
|
2183
|
+
i.before || i.after ? h('div', { class: 'diff' }, h('div', { class: 'old' }, h('small', {}, 'Before'), h('del', {}, i.before || '—')), h('div', { class: 'new' }, h('small', {}, 'After'), i.after || '—')) : null,
|
|
2184
|
+
i.why ? h('div', { class: 'muted' }, h('small', {}, 'Why'), ' ', i.why) : null,
|
|
2185
|
+
i.affects?.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Affects'), ...i.affects.map((id) => { const a = x.affectsLabels.find((y) => y.id === id) ?? { id, label: id, removed: false }; return h('button', { class: 'text-btn', onClick: () => select({ kind: 'node', id, label: a.label }) }, namedObject(a)); })) : null)))
|
|
2186
|
+
: null,
|
|
2187
|
+
!x.work && (x.before || x.after) ? h('div', { class: 'diff' }, h('div', { class: 'old' }, h('small', {}, 'Before'), h('del', {}, x.before || '—')), h('div', { class: 'new' }, h('small', {}, 'After'), x.after || '—')) : null,
|
|
2188
|
+
(x.notJudged ?? []).length ? h('div', { class: 'row wrap' }, h('small', {}, 'Recorded at the time, not judged'), ...x.notJudged.map((n) => { const a = x.notJudgedLabels?.find((y) => y.id === n.nodeId) ?? x.propagationLabels?.find((y) => y.id === n.nodeId) ?? { id: n.nodeId, label: n.nodeId }; return h('span', { class: 'muted', title: n.reason }, namedObject(a)); })) : null,
|
|
2189
|
+
x.affectsLabels.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Affects'), ...x.affectsLabels.map((a) => h('button', { class: 'text-btn', onClick: () => select({ kind: 'node', id: a.id, label: a.label }) }, namedObject(a)))) : null,
|
|
2190
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'Sources'), ...x.sources.map((s) => h('button', { class: 'text-btn', onClick: () => openSource(s.id) }, s.title))),
|
|
2191
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'Propagation'), h('span', { class: 'muted' }, propagationText(x.propagationSummary)), x.propagation.length ? h('details', { class: 'fold' }, h('summary', {}, 'By state'), h('div', { class: 'content' }, ...['Updated', 'Still on old understanding', 'Reusable as is', 'Not yet checked'].map((st) => { const items = x.propagation.filter((p) => p.state === st); return items.length ? h('div', {}, h('b', {}, st), ...items.map((p) => h('div', { class: 'muted' }, namedObject(x.propagationLabels?.find((a) => a.id === p.nodeId) ?? x.affectsLabels.find((a) => a.id === p.nodeId) ?? { label: p.nodeId }), p.sourceOrReason ? ` — ${p.sourceOrReason}` : ''))) : null; }))) : null),
|
|
2192
|
+
h('div', { class: 'row' }, h('button', { class: 'btn small', onClick: () => showAffected(x) }, 'Show affected')));
|
|
2193
|
+
// Patches and change records on one timeline, oldest to newest by when each happened; a segment takes the patches
|
|
2194
|
+
// that happened from its first record up to the next segment's (the first segment also takes any before it).
|
|
2195
|
+
const ms = (at) => { const t = Date.parse(/^\d{4}-\d\d-\d\d$/.test(at) ? `${at}T00:00:00Z` : at); return Number.isNaN(t) ? 0 : t; };
|
|
2196
|
+
const merged = (records, ps) => [...records.map((x) => ({ at: ms(x.at), node: () => event(x) })), ...ps.map((p) => ({ at: ms(p.occurred.at), node: () => patchEvent(p, target) }))].sort((a, b) => a.at - b.at).map((r) => r.node());
|
|
2197
|
+
const segments = c.segments.length ? c.segments : [{ name: null, sourceId: null, changes: [] }];
|
|
2198
|
+
if (segments.length === 1 && segments[0].name === null) append(main, h('div', { class: 'timeline' }, ...merged(segments[0].changes, patches)));
|
|
2199
|
+
else {
|
|
2200
|
+
const starts = segments.map((s) => Math.min(...s.changes.map((x) => ms(x.at))));
|
|
2201
|
+
const patchesOf = (i) => patches.filter((p) => { const t = ms(p.occurred.at); return (i === 0 || t >= starts[i]) && (i === segments.length - 1 || t < starts[i + 1]); });
|
|
2202
|
+
segments.forEach((s, i) => {
|
|
2203
|
+
const mine = patchesOf(i);
|
|
2204
|
+
const open = i === segments.length - 1 || s.changes.some((x) => x.id === target) || mine.some((p) => p.id === target || p.number === target);
|
|
2205
|
+
append(main, h('details', { class: 'fold', open: open || null }, h('summary', {}, s.name ?? 'Before any named segment', h('small', {}, ` ${s.changes.length}${mine.length ? ` · ${mine.length} patch${mine.length === 1 ? '' : 'es'}` : ''}`), s.sourceId ? h('button', { class: 'text-btn', onClick: (e) => { e.preventDefault(); openSource(s.sourceId); } }, 'source') : null), h('div', { class: 'content' }, h('div', { class: 'timeline' }, ...merged(s.changes, mine)))));
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
// A change record's id, or a patch's id or number (SP-n), scrolls to it.
|
|
2209
|
+
if (target) setTimeout(() => document.getElementById(`chg-${patches.find((p) => p.number === target)?.id ?? target}`)?.scrollIntoView({ block: 'center' }), 50);
|
|
2210
|
+
},
|
|
2211
|
+
/** A pick marks the record it names, if any; the log is not rebuilt under the popover. */
|
|
2212
|
+
onSelect(selection) {
|
|
2213
|
+
document.querySelectorAll('#main .tl-event').forEach((e) => e.classList.toggle('sel', Boolean(selection) && e.id === `chg-${selection.id}`));
|
|
2214
|
+
},
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
// ── Notes (§6.6) ─────────────────────────────────────────────────────────
|
|
2218
|
+
const notes = {
|
|
2219
|
+
async render(main) {
|
|
2220
|
+
const n = await api(`/api/projects/${P()}/notes`);
|
|
2221
|
+
const f = state.filters.notes ?? (state.filters.notes = { ask: '', response: '', origin: '' });
|
|
2222
|
+
const latest = (x) => x.versions[x.versions.length - 1];
|
|
2223
|
+
const current = n.notes.filter((x) => x.status === 'Current').filter((x) => (!f.ask || latest(x).ask === f.ask) && (!f.response || x.ownerResponse === f.response) && (!f.origin || x.cameFrom?.kind === f.origin)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2224
|
+
const history = n.notes.filter((x) => x.status !== 'Current');
|
|
2225
|
+
const mountLabel = (x) => x.mount.kind === 'project' ? 'Whole project' : `${x.mount.kind} · ${x.mount.ids.length} object${x.mount.ids.length === 1 ? '' : 's'}`;
|
|
2226
|
+
const card = (x) => { const v = latest(x); return h('button', { class: `note-card${state.selection?.id === x.id ? ' sel' : ''}`, 'data-note': x.id, onClick: () => select({ kind: 'note', id: x.id, label: v.title }) }, h('span', { class: 'row spread' }, h('b', {}, v.title), askTag(v.ask)), h('span', { class: 'preview' }, v.preview), h('span', { class: 'row wrap' }, originTag(x.cameFrom), h('small', {}, mountLabel(x)), h('small', {}, `updated ${fmtRel(x.updatedAt)}`), x.ownerResponse ? h('span', { class: 'tag blue' }, x.ownerResponse) : null, x.status !== 'Current' ? h('span', { class: 'tag' }, x.status) : null, v.version > 1 ? h('small', {}, `v${v.version}`) : null)); };
|
|
2227
|
+
append(main, h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Notes log'), h('p', { class: 'sub' }, 'Every note the Keeper has written about this project and its parts, newest first; resolved and withdrawn ones are kept below. A note is never a decision; what it asks of you is marked.')),
|
|
2228
|
+
h('div', { class: 'row' }, h('select', { class: 'input', onChange: (e) => { f.ask = e.target.value; renderBody(); } }, h('option', { value: '' }, 'Ask: any'), ...['For your decision', 'Worth discussing', 'For information'].map((a) => h('option', { value: a, selected: f.ask === a }, a))), h('select', { class: 'input', onChange: (e) => { f.response = e.target.value; renderBody(); } }, h('option', { value: '' }, 'Your response: any'), ...['Discussed', 'Decided', 'Delegated', 'No action needed'].map((a) => h('option', { value: a, selected: f.response === a }, a))), h('select', { class: 'input', onChange: (e) => { f.origin = e.target.value; renderBody(); } }, h('option', { value: '' }, 'Came from: any'), ...NOTE_ORIGINS.map((a) => h('option', { value: a, selected: f.origin === a }, a))))));
|
|
2229
|
+
if (n.notes.length === 0) { append(main, emptyState(n.relookDone ? 'No note to show' : 'No notes yet — the first product re-look has not finished.', n.relookDone ? 'The Keeper found nothing worth a separate note; the project-level note is shown on the graph.' : 'Notes are written after the product re-look, from the product reference and the organized facts.')); return; }
|
|
2230
|
+
append(main, h('div', { class: 'stack' }, ...current.map(card)), current.length === 0 ? h('div', { class: 'faint' }, 'No current note matches the filter.') : null,
|
|
2231
|
+
history.length ? h('details', { class: 'fold' }, h('summary', {}, `Resolved and withdrawn (${history.length})`), h('div', { class: 'content stack' }, ...history.map(card))) : null);
|
|
2232
|
+
if (state.selection?.kind === 'note') document.querySelector(`#main .note-card[data-note="${CSS.escape(state.selection.id)}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
2233
|
+
},
|
|
2234
|
+
/** A pick marks the card; the log is not rebuilt, so it stays where it was scrolled and the popover stands beside the card. */
|
|
2235
|
+
onSelect(selection) {
|
|
2236
|
+
document.querySelectorAll('#main .note-card').forEach((c) => c.classList.toggle('sel', selection?.kind === 'note' && c.dataset.note === selection.id));
|
|
2237
|
+
},
|
|
2238
|
+
};
|
|
2239
|
+
|
|
2240
|
+
// ── Authority, carry-out and claims (Spec §1.9, §2.2, §2.4, §6.4; CKC-09 AC-33) ──
|
|
2241
|
+
// The server works out every word below (workbench-content.ts); these only draw it.
|
|
2242
|
+
function authorityBlock(a) {
|
|
2243
|
+
const cls = a.layer === 'Decided without owner' ? 'tag red' : a.layer === 'Owner' ? 'tag amber' : 'tag';
|
|
2244
|
+
return h('div', { class: 'authority' }, h('h4', {}, 'Authority'),
|
|
2245
|
+
h('div', {}, h('span', { class: cls }, a.layer === 'Decided without owner' ? `⚑ ${a.label}` : a.label)),
|
|
2246
|
+
a.detail ? h('div', { class: a.inForce ? 'muted' : 'faint' }, a.detail) : null);
|
|
2247
|
+
}
|
|
2248
|
+
function carryOutBlock(c, go = { object: openObject }) {
|
|
2249
|
+
return h('div', { class: 'carry-out' }, h('h4', {}, 'Carry-out'),
|
|
2250
|
+
c.status ? h('div', {}, h('span', { class: `tag ${c.status === 'Carried out' ? 'green' : c.status === 'Partly carried out' ? 'amber' : ''}` }, c.status), c.at ? h('small', { class: 'faint' }, ` ${fmtTime(c.at)}`) : null) : null,
|
|
2251
|
+
c.remaining ? h('div', { class: 'muted' }, 'Still left: ', c.remaining) : null,
|
|
2252
|
+
c.work.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Work that carries it out'), ...c.work.map((w) => h('button', { class: 'text-btn', title: 'Open this work', onClick: () => go.object({ kind: 'node', id: w.id, label: w.label }) }, namedObject(w), w.progress ? ` · ${w.progress}` : ''))) : null,
|
|
2253
|
+
c.evidence.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Shown by'), ...c.evidence.map((e) => h('button', { class: 'text-btn', onClick: () => openSource(e.id) }, e.title))) : null);
|
|
2254
|
+
}
|
|
2255
|
+
/** A statement with who claimed it and when; an observation made in the code with where (Spec §2.4). */
|
|
2256
|
+
function statementItem(s) {
|
|
2257
|
+
const typeCls = s.type === 'Observed' ? 'green' : s.type === 'Claimed' ? 'blue' : s.type === 'Open' ? 'amber' : 'purple';
|
|
2258
|
+
return h('li', {},
|
|
2259
|
+
h('span', { class: `tag ${typeCls}` }, s.type), ' ', s.text, ' ', ...s.sourceIds.map((sid) => h('button', { class: 'cite', onClick: () => openSource(sid) }, sid.slice(0, 10))),
|
|
2260
|
+
s.reportedBy ? h('div', { class: 'faint' }, s.reportedBy) : null,
|
|
2261
|
+
s.untrusted ? h('div', { class: 'faint' }, h('span', { class: 'tag amber' }, 'Untrusted'), ' ', s.untrusted.summary) : null,
|
|
2262
|
+
...(s.code ?? []).map((c) => h('div', { class: 'faint' }, 'Observed in the code: ', h('button', { class: 'text-btn mono', onClick: () => openSource(c.id) }, c.label))));
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* What was found when the material the claims rest on was checked, the code above all (Spec §2.4): shown once, on that
|
|
2266
|
+
* material, in the check's own words. A claim with no check here stays as it was reported.
|
|
2267
|
+
*/
|
|
2268
|
+
function checksBlock(checks) {
|
|
2269
|
+
if (!checks?.length) return null;
|
|
2270
|
+
const open = (id, isSource) => (isSource ? openSource(id) : openObject({ kind: 'node', id, label: id }));
|
|
2271
|
+
return h('div', {}, h('h4', {}, checks.some((x) => x.code) ? 'Checked against the code' : 'Contradictions'),
|
|
2272
|
+
...checks.map((x) => h('div', {}, h('span', { class: 'tag red' }, x.code ? 'Differs from the code' : 'Contradicted'), ' ', h('button', { class: 'text-btn', title: x.on.label, onClick: () => openSource(x.on.id) }, x.on.title), ': ', x.claim, ' ', h('button', { class: 'text-btn mono', onClick: () => open(x.otherId, x.otherIsSource) }, x.otherLabel))));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// ── Fact record dialog (drill-down, §6.4) ────────────────────────────────
|
|
2276
|
+
export async function openFact(id) {
|
|
2277
|
+
const f = await api(`/api/projects/${P()}/facts/${encodeURIComponent(id)}`);
|
|
2278
|
+
const stmt = statementItem;
|
|
2279
|
+
openDialog(f.title, [
|
|
2280
|
+
h('div', { class: 'row wrap' }, h('small', {}, `As of ${fmtTime(f.asOf)}`), f.pendingSourceIds.length ? h('span', { class: 'tag amber' }, 'Update pending') : null),
|
|
2281
|
+
h('h4', {}, 'About'), h('div', { class: 'row wrap' }, ...f.sources.map((s) => h('button', { class: 'text-btn', onClick: () => openSource(s.id) }, s.title))),
|
|
2282
|
+
h('h4', {}, 'Statements'), h('ul', { class: 'stmts' }, ...f.statements.map(stmt)),
|
|
2283
|
+
checksBlock(f.checks),
|
|
2284
|
+
f.decisions.length ? h('div', {}, h('h4', {}, 'Decisions'), h('ul', {}, ...f.decisions.map((d) => h('li', {}, d.by.identity === 'Decision' ? h('span', { class: 'tag amber' }, 'Owner') : h('span', { class: 'tag' }, d.by.author.name || 'agent'), ' ', d.text, d.documented ? '' : h('span', { class: 'tag' }, 'not in the project’s decision record'), ...d.sourceIds.map((sid) => h('button', { class: 'cite', onClick: () => openSource(sid) }, sid.slice(0, 10))))))) : null,
|
|
2285
|
+
f.changes.length ? h('div', {}, h('h4', {}, 'Changes reported'), h('ul', {}, ...f.changes.map((c) => h('li', {}, c.text)))) : null,
|
|
2286
|
+
f.openQuestions.length ? h('div', {}, h('h4', {}, 'Open questions'), h('ul', {}, ...f.openQuestions.map((q) => h('li', {}, q)))) : null,
|
|
2287
|
+
f.executionFacts.length ? h('div', {}, h('h4', {}, 'Execution and QC facts'), h('ul', { class: 'stmts' }, ...f.executionFacts.map(stmt))) : null,
|
|
2288
|
+
whyFold(f.trace),
|
|
2289
|
+
]);
|
|
2290
|
+
}
|
|
2291
|
+
function whyFold(trace) {
|
|
2292
|
+
return h('details', { class: 'fold' }, h('summary', {}, 'Why the Keeper thinks so'), h('div', { class: 'content' }, trace?.length ? trace.map((t) => h('div', { class: 'muted' }, fmtTime(t.at), ' · ', t.summary, t.jobId ? h('button', { class: 'text-btn', onClick: () => openActivity() }, ' job') : null, t.basisSourceIds?.length ? h('span', {}, ' · ', ...t.basisSourceIds.slice(0, 6).map((sid) => h('button', { class: 'cite', onClick: () => openSource(sid) }, sid.slice(0, 10)))) : null)) : h('div', { class: 'faint' }, 'No trace recorded.')));
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ── Object details (§6.4): the popover beside the object, and the full details behind `Details` ─────────
|
|
2296
|
+
/**
|
|
2297
|
+
* One description of an object's details, drawn in two places (D67). The popover beside the object takes the summary
|
|
2298
|
+
* — name and kind, one sentence, state and marks, the notes on it and on its path, the usual actions. `Details`
|
|
2299
|
+
* draws every part in the dialog. There is no second copy of the content: both are made from `detailParts`, so
|
|
2300
|
+
* whatever the old details column held is in one of the two, one press from the popover at most (CKC-09 AC-35; the
|
|
2301
|
+
* table is in subagent/eo2/details-mapping.md).
|
|
2302
|
+
*/
|
|
2303
|
+
const onGraphNow = () => state.view === 'graph' && state.graphMode !== 'list' && Boolean(graphCtl);
|
|
2304
|
+
const NOT_ON_GRAPH = 'None of the affected objects is on the graph';
|
|
2305
|
+
/** Take the owner to the object on the graph, with its popover beside it; for a note, to what it is mounted on. */
|
|
2306
|
+
function showOnGraph(sel, ids = []) {
|
|
2307
|
+
closeStripSheet();
|
|
2308
|
+
const dialog = document.querySelector('#dialog');
|
|
2309
|
+
if (dialog?.open) dialog.close();
|
|
2310
|
+
const mark = () => {
|
|
2311
|
+
if (!ids.length || !graphCtl) return;
|
|
2312
|
+
const n = graphCtl.showAffected(ids);
|
|
2313
|
+
if (!n) toast(NOT_ON_GRAPH);
|
|
2314
|
+
};
|
|
2315
|
+
if (onGraphNow()) {
|
|
2316
|
+
// Mark after the selection, so picking the note does not clear the mark it just asked for.
|
|
2317
|
+
select(sel, { trigger: null, anchorIds: ids, reveal: true, keepMarks: true });
|
|
2318
|
+
mark();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
state.selection = sel;
|
|
2322
|
+
state.graphMode = 'graph';
|
|
2323
|
+
state.pendingPopover = { anchorIds: ids };
|
|
2324
|
+
state.pendingShow = ids;
|
|
2325
|
+
state.pendingShowTell = ids.length > 0;
|
|
2326
|
+
if (state.view !== 'graph') navigate(state.projectId, 'graph'); else renderBody();
|
|
2327
|
+
}
|
|
2328
|
+
/** Bring some objects into view on the graph without picking any of them. */
|
|
2329
|
+
function highlightOnGraph(ids) {
|
|
2330
|
+
closeStripSheet();
|
|
2331
|
+
if (onGraphNow()) { graphCtl.showAffected(ids); return; }
|
|
2332
|
+
state.graphMode = 'graph';
|
|
2333
|
+
state.pendingShow = ids;
|
|
2334
|
+
state.pendingShowTell = false;
|
|
2335
|
+
if (state.view !== 'graph') navigate(state.projectId, 'graph'); else renderBody();
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* The objects a note hangs on, named from the graph. An id the picture does not have is left out — it is not shown
|
|
2339
|
+
* as the id itself. A note on the whole project hangs on nothing.
|
|
2340
|
+
*/
|
|
2341
|
+
function describedMount(mount) {
|
|
2342
|
+
if (!mount || mount.kind === 'project') return { kind: 'project', objects: [] };
|
|
2343
|
+
const nodes = graph._data?.nodes ?? [];
|
|
2344
|
+
const rels = graph._data?.relations ?? [];
|
|
2345
|
+
const objects = [];
|
|
2346
|
+
for (const id of mount.ids ?? []) {
|
|
2347
|
+
if (mount.kind === 'relation') {
|
|
2348
|
+
const r = rels.find((x) => x.id === id);
|
|
2349
|
+
if (!r) continue;
|
|
2350
|
+
const from = nodes.find((n) => n.id === r.from)?.label;
|
|
2351
|
+
const to = nodes.find((n) => n.id === r.to)?.label;
|
|
2352
|
+
objects.push({ id, kind: 'relation', label: `${r.type}: ${from ?? '…'} → ${to ?? '…'}` });
|
|
2353
|
+
} else {
|
|
2354
|
+
const n = nodes.find((x) => x.id === id);
|
|
2355
|
+
if (!n?.label) continue;
|
|
2356
|
+
objects.push({ id, kind: 'node', label: n.label });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
return { kind: mount.kind, objects };
|
|
2360
|
+
}
|
|
2361
|
+
/** `On` plus the names, as text, and `Show on graph` unless the note hangs on the whole project. */
|
|
2362
|
+
function noteOnLine(n, self) {
|
|
2363
|
+
const mount = n.mount ?? { kind: 'project', ids: [] };
|
|
2364
|
+
if (mount.kind === 'project') return h('div', { class: 'row wrap on-line' }, h('small', {}, 'On'), h('span', { class: 'on-name' }, 'Whole project'));
|
|
2365
|
+
const names = describedMount(mount).objects.flatMap((o, i) => [i ? ', ' : null, h('span', { class: 'on-name' }, o.label)]);
|
|
2366
|
+
return h('div', { class: 'row wrap on-line' }, h('small', {}, 'On'), ...names,
|
|
2367
|
+
h('button', { class: 'btn small', title: 'Go to it on the graph', onClick: () => showOnGraph(self, [...(mount.ids ?? [])]) }, 'Show on graph'));
|
|
2368
|
+
}
|
|
2369
|
+
/** A link to an object: inside the dialog the reading goes on there; on the page the object is picked. */
|
|
2370
|
+
function openObject(sel) {
|
|
2371
|
+
if (document.querySelector('#dialog')?.open) void details.full(sel);
|
|
2372
|
+
else select(sel);
|
|
2373
|
+
}
|
|
2374
|
+
/** A citation that names a source the project does not have says so instead of failing silently. */
|
|
2375
|
+
const openCited = (id) => openSource(id).catch((e) => toast(`${id}: ${e.message}`));
|
|
2376
|
+
|
|
2377
|
+
// ── What an object still lacks, and how it follows the changes that reached it (Spec §2.10, §5.5, §6.4; QC AH #6) ──
|
|
2378
|
+
// The server reads it from the object's latest judgement (graph-view.ts `propagationOf`), as the context pack does.
|
|
2379
|
+
/** A change record, opened where it is read in full — its items, before and after, why: its record in the Change log. */
|
|
2380
|
+
function changeLink(c, title, go) {
|
|
2381
|
+
return h('button', { class: 'text-btn', title: 'Open this change in the Change log', onClick: () => { go.leave?.(); closeStripSheet(); navigate(state.projectId, 'changes', encodeURIComponent(c.id)); } }, title);
|
|
2382
|
+
}
|
|
2383
|
+
/** One lacked item: what the object still has to follow, and the change it comes from — the item of that record, or the record. */
|
|
2384
|
+
function lackLine(l, go) {
|
|
2385
|
+
const c = l.change;
|
|
2386
|
+
return h('div', { class: 'lack', dataset: { change: l.changeId, item: l.itemId } },
|
|
2387
|
+
l.what ? h('div', { class: 'lack-what' }, l.what) : h('div', { class: 'faint' }, 'What it lacks was not written down.'),
|
|
2388
|
+
h('div', { class: 'lack-from' }, h('small', { class: 'faint' }, 'From '),
|
|
2389
|
+
c ? [effectTag(l.item?.effect ?? c.effect), ' ', changeLink(c, l.item?.title ?? c.work ?? c.title, go), h('small', { class: 'faint' }, ` · ${l.item ? `${c.work ?? c.title} · ` : ''}${fmtTime(l.item?.at ?? c.at)}`)]
|
|
2390
|
+
: h('span', { class: 'faint' }, 'a change record no longer in the assets')));
|
|
2391
|
+
}
|
|
2392
|
+
/** The round that judged it, whose result opens from here (§6.9); and that it changed since, when it did (§5.5). */
|
|
2393
|
+
function judgedLine(pv) {
|
|
2394
|
+
if (!pv.round) return null;
|
|
2395
|
+
const round = pv.round.ended
|
|
2396
|
+
? h('button', { class: 'text-btn', title: 'Open this round’s result', onClick: () => openRound(pv.round.id, { name: pv.round.name }) }, pv.round.name)
|
|
2397
|
+
: `${pv.round.name} (still running)`;
|
|
2398
|
+
return h('div', { class: 'faint judged-in' }, 'Judged in ', round, pv.at ? ` · ${fmtRel(pv.at)}` : '',
|
|
2399
|
+
pv.movedSince ? ' · it has changed since: the next Follow up judges it again, with what it still lacks' : '');
|
|
2400
|
+
}
|
|
2401
|
+
/** The popover's block (§6.4): what the object still lacks, item by item, each with the change it comes from. */
|
|
2402
|
+
function lacksBlock(pv, go) {
|
|
2403
|
+
if (!pv?.lacks?.length) return null;
|
|
2404
|
+
return h('div', { class: 'kp-block prop-lacks' }, h('h4', {}, `Still on old understanding (${pv.lacks.length})`), ...pv.lacks.map((l) => lackLine(l, go)), judgedLine(pv));
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* The full details' part (§5.5): the state its latest round gave it — the round, when and why — what it still lacks, the
|
|
2408
|
+
* records that state covers, and the records that reached it since and wait for the next Follow up.
|
|
2409
|
+
*/
|
|
2410
|
+
function propagationBlock(pv, go) {
|
|
2411
|
+
// A record as the Change log heads it: a piece of work by its label, an older record by its title.
|
|
2412
|
+
const record = (c, extra = []) => h('div', { class: 'prop-record', dataset: { change: c.id } }, changeLink(c, c.work ?? c.title, go), h('small', { class: 'faint' }, ` · ${fmtTime(c.at)}`), ...extra);
|
|
2413
|
+
return h('div', { class: 'stack prop-detail' },
|
|
2414
|
+
h('div', { class: 'row wrap' }, propTag(pv.state), pv.round ? null : h('small', { class: 'faint' }, pv.at ? 'judged for each change, before Follow up rounds judged each object once' : 'no Follow up round has judged it yet')),
|
|
2415
|
+
judgedLine(pv),
|
|
2416
|
+
pv.reason ? h('p', { class: 'muted' }, pv.reason) : null,
|
|
2417
|
+
pv.lacks.length ? h('div', { class: 'prop-part prop-lacks' }, h('h4', {}, `Still lacks (${pv.lacks.length})`), ...pv.lacks.map((l) => lackLine(l, go))) : null,
|
|
2418
|
+
pv.covers.length ? h('div', { class: 'prop-part prop-covers' }, h('h4', {}, `Covers ${pv.covers.length} change record${pv.covers.length === 1 ? '' : 's'}`),
|
|
2419
|
+
...pv.covers.map((x) => record(x.change, [h('small', { class: 'faint' }, ` · ${x.items} item${x.items === 1 ? '' : 's'} `), propTag(x.state), x.reason ? h('div', { class: 'muted' }, x.reason) : null]))) : null,
|
|
2420
|
+
pv.waiting.length ? h('div', { class: 'prop-part prop-waiting' }, h('h4', {}, `Waits for the next Follow up (${pv.waiting.length})`), ...pv.waiting.map((c) => record(c))) : null);
|
|
2421
|
+
}
|
|
2422
|
+
const foldKey = (d) => (d.querySelector(':scope > summary')?.textContent ?? '').replace(/\d+/g, '#');
|
|
2423
|
+
|
|
2424
|
+
// ── What an object is, first (owner 2026-09-30, of D1's popover: 「感觉写的很全来龙去脉的……但是他到底是什么,现在是不是
|
|
2425
|
+
// current看不出来」): its validity as a clear tag — Current, Replaced by what, Proposed —, its summary, and where it sits
|
|
2426
|
+
// (a module or a plan, product only, not placed yet), before any history. The places are the Graph's and the List's own
|
|
2427
|
+
// (placement.js), read from the graph the view already holds.
|
|
2428
|
+
let placeCache = { data: null, M: null, projectId: null };
|
|
2429
|
+
async function placementNow() {
|
|
2430
|
+
let data = graph._data;
|
|
2431
|
+
if (!data) data = placeCache.projectId === state.projectId && placeCache.data ? placeCache.data : await api(`/api/projects/${P()}/graph`);
|
|
2432
|
+
if (placeCache.data !== data) placeCache = { data, M: placementOf(data, { showReplaced: true }), projectId: state.projectId };
|
|
2433
|
+
return placeCache.M;
|
|
2434
|
+
}
|
|
2435
|
+
const STATUS = {
|
|
2436
|
+
Current: ['green', 'Current', 'In force now'],
|
|
2437
|
+
Proposed: ['blue', 'Proposed', 'Proposed; not decided yet'],
|
|
2438
|
+
Unjudged: ['', 'Not yet judged', 'Whether it is still in force has not been judged yet'],
|
|
2439
|
+
Deferred: ['amber', 'Deferred', 'Put off; not in force now'],
|
|
2440
|
+
Replaced: ['red', 'Replaced', 'No longer in force: something later replaced it'],
|
|
2441
|
+
Abandoned: ['red', 'Abandoned', 'No longer in force: given up'],
|
|
2442
|
+
Removed: ['red', 'Removed', 'No longer in force: removed'],
|
|
2443
|
+
};
|
|
2444
|
+
/** Its validity, always said (Current too), with what replaced it as a link. */
|
|
2445
|
+
function statusTag(n, M, go) {
|
|
2446
|
+
const [tone, text, why] = STATUS[n.validity] ?? ['', n.validity ?? 'Not yet judged', ''];
|
|
2447
|
+
const by = n.replacedBy && M ? replacementOf(M, n.replacedBy) : null;
|
|
2448
|
+
const byPart = n.replacedBy && n.validity !== 'Current'
|
|
2449
|
+
? [' by ', by ? h('button', { class: 'text-btn', title: `Open ${by.label}`, onClick: (e) => { e.stopPropagation(); go.object(NODE_SEL(by)); } }, by.label) : h('b', {}, n.replacedBy)]
|
|
2450
|
+
: null;
|
|
2451
|
+
return h('span', { class: `tag status-tag ${tone}`, title: why }, text, byPart);
|
|
2452
|
+
}
|
|
2453
|
+
/** Where it sits, one line: the module(s) or the plan as links, or that it is on the product only or not placed yet. */
|
|
2454
|
+
function placeLine(n, M, go) {
|
|
2455
|
+
const w = M ? whereItSits(M, n.id) : null;
|
|
2456
|
+
if (!w) return null;
|
|
2457
|
+
const name = (id) => M.byId.get(id)?.label ?? id;
|
|
2458
|
+
return h('div', { class: `place-line place-${w.kind}` },
|
|
2459
|
+
h('span', { class: 'place-lead' }, w.lead),
|
|
2460
|
+
...w.ids.map((id) => [' ', h('button', { class: 'text-btn', title: 'Open it', onClick: (e) => { e.stopPropagation(); go.object({ kind: 'node', id, label: name(id) }); } }, name(id))]),
|
|
2461
|
+
w.note ? h('span', { class: 'faint' }, ` · ${w.note}`) : null);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
const details = {
|
|
2465
|
+
async load(sel) {
|
|
2466
|
+
if (sel.kind === 'note') return api(`/api/projects/${P()}/notes/${encodeURIComponent(sel.id)}`);
|
|
2467
|
+
if (sel.kind === 'relation') return api(`/api/projects/${P()}/relations/${encodeURIComponent(sel.id)}`);
|
|
2468
|
+
const d = await api(`/api/projects/${P()}/nodes/${encodeURIComponent(sel.id)}`);
|
|
2469
|
+
// Increment K rides along (CKC-24): the lineage, the versions and what is open on the object; null when the
|
|
2470
|
+
// endpoints are not built yet, and the blocks simply do not appear then.
|
|
2471
|
+
d._k = await kProcess.detailsData(sel.id).catch(() => null);
|
|
2472
|
+
// Where it sits, from the same placement the Graph and the List draw.
|
|
2473
|
+
d._place = await placementNow().catch(() => null);
|
|
2474
|
+
return d;
|
|
2475
|
+
},
|
|
2476
|
+
|
|
2477
|
+
/**
|
|
2478
|
+
* The parts of one object's details. `go` says what a press does where the parts are drawn: `go.note` opens a note
|
|
2479
|
+
* of this object, `go.object` another object, `go.redraw` draws this one again after the owner answered.
|
|
2480
|
+
*/
|
|
2481
|
+
parts(sel, x, go) {
|
|
2482
|
+
// A session among the sources opens its draft beside the original (CKC-23 AC-18: 钻取一段会话时点得开).
|
|
2483
|
+
const draftOf = new Map();
|
|
2484
|
+
for (const r of x?._k?.sessions ?? []) if (r.id) for (const sid of r.sourceIds) draftOf.set(sid, r.id);
|
|
2485
|
+
const sourceList = (list) => list.length ? h('div', { class: 'stack' }, ...list.map((s) => h('div', {}, h('button', { class: 'text-btn', onClick: () => openSource(s.id) }, s.title),
|
|
2486
|
+
draftOf.has(s.id) ? [' ', h('button', { class: 'text-btn kd-src-draft', title: 'The draft of this session: the owner’s words verbatim, and what the agents said, as claims', onClick: () => openDraft(draftOf.get(s.id), kProcess.draftCtx()) }, 'Session draft')] : null,
|
|
2487
|
+
h('div', { class: 'faint mono' }, s.label), s.availability ? h('span', { class: 'tag amber' }, s.availability) : null))) : h('div', { class: 'faint' }, 'No source recorded.');
|
|
2488
|
+
const noteCards = (list, none) => list.length ? h('div', { class: 'stack' }, ...list.map((n) => h('button', { class: 'note-card', 'data-note': n.id, title: 'Open this note', onClick: () => go.note({ kind: 'note', id: n.id, label: n.title }) }, h('span', { class: 'row spread' }, h('b', {}, n.title), askTag(n.ask)), h('span', { class: 'preview' }, n.preview)))) : h('div', { class: 'faint' }, none);
|
|
2489
|
+
const ask = (context) => h('button', { class: 'btn small', onClick: () => { go.leave?.(); toggleKeeper(true, context); } }, 'Ask Keeper');
|
|
2490
|
+
const onGraph = (label, ids) => h('button', { class: 'btn small', title: 'Go to it on the graph', onClick: () => showOnGraph(label, ids) }, 'Show on graph');
|
|
2491
|
+
const section = (key, label, node, count = null) => (node ? { key, label, count, node } : null);
|
|
2492
|
+
|
|
2493
|
+
if (sel.kind === 'note') {
|
|
2494
|
+
const n = x;
|
|
2495
|
+
const v = n.versions[n.versions.length - 1];
|
|
2496
|
+
const self = { kind: 'note', id: n.id, label: v.title };
|
|
2497
|
+
const text = (key, title, body) => section(key, title, body ? h('p', {}, body) : null);
|
|
2498
|
+
const respond = async (response) => { try { await api(`/api/projects/${P()}/notes/${encodeURIComponent(n.id)}/response`, { method: 'POST', body: { response } }); toast(`Recorded: ${response.toLowerCase()}`); await go.redraw(); } catch (e) { toast(e.message); } };
|
|
2499
|
+
const canConfirm = n.status === 'Current' && n.ownerResponse !== 'Decided' && n.id !== DEPTH_NOTE_ID;
|
|
2500
|
+
const confirm = async () => {
|
|
2501
|
+
try {
|
|
2502
|
+
const r = await api(`/api/projects/${P()}/notes/${encodeURIComponent(n.id)}/confirm`, { method: 'POST' });
|
|
2503
|
+
state.conversationId = r.conversationId;
|
|
2504
|
+
go.leave?.();
|
|
2505
|
+
toggleKeeper(true, self);
|
|
2506
|
+
toast('Sent to the Keeper');
|
|
2507
|
+
} catch (e) { toast(e.message); }
|
|
2508
|
+
};
|
|
2509
|
+
return {
|
|
2510
|
+
title: v.title, sub: `Keeper note · v${v.version} · ${fmtRel(v.at)}`, label: `Note: ${v.title}`,
|
|
2511
|
+
tags: h('div', { class: 'row wrap' }, askTag(v.ask), h('span', { class: 'tag' }, n.status), n.ownerResponse ? h('span', { class: 'tag blue' }, n.ownerResponse) : null),
|
|
2512
|
+
sentence: v.preview,
|
|
2513
|
+
notes: null,
|
|
2514
|
+
on: noteOnLine(n, self),
|
|
2515
|
+
// How it came about, and what it asks the owner to choose (Spec §4.1, §3.7): part of the note's own page.
|
|
2516
|
+
extras: [
|
|
2517
|
+
n.cameFrom?.kind ? h('div', { class: 'row wrap came-from' }, h('small', {}, 'Came from'), originTag(n.cameFrom), ...(n.cameFrom.changes ?? []).map((c) => h('button', { class: 'text-btn', title: 'Open this change in the Change log', onClick: () => { go.leave?.(); navigate(state.projectId, 'changes', encodeURIComponent(c.id)); } }, c.title))) : null,
|
|
2518
|
+
n.id === DEPTH_NOTE_ID ? h('div', { class: 'stack' }, h('div', { class: 'muted' }, 'The depth is chosen on the Takeover page of the Keeper view, before the takeover starts.'), h('div', {}, h('button', { class: 'btn small', onClick: () => navigate(state.projectId, 'keeper', 'takeover') }, 'Open the Takeover page'))) : null,
|
|
2519
|
+
],
|
|
2520
|
+
actions: [
|
|
2521
|
+
h('button', { class: 'btn small', onClick: () => { go.leave?.(); toggleKeeper(true, self); } }, 'Discuss with Keeper'),
|
|
2522
|
+
canConfirm ? h('button', { class: 'btn small', title: 'Tell the Keeper you confirm this note; it records your confirmation', onClick: confirm }, 'Confirm') : null,
|
|
2523
|
+
n.status === 'Current' && n.ownerResponse !== 'No action needed' ? h('button', { class: 'btn small', title: 'Leaves Notes (attention); the note stays where it is', onClick: () => respond('No action needed') }, 'No action needed') : null,
|
|
2524
|
+
n.cameFrom?.kind === 'Change follow-up' && n.cameFrom.changes?.length ? h('button', { class: 'btn small', onClick: () => openWalkthrough(n.id) }, '▶ Walk through the change') : null,
|
|
2525
|
+
],
|
|
2526
|
+
sections: [
|
|
2527
|
+
text('current-view', 'Current view', v.body.currentView), text('why-it-matters', 'Why it matters', v.body.whyItMatters),
|
|
2528
|
+
// A `For your decision` note's options, each with what follows from it (Spec §4.2; D105; CKC-08 AC-26).
|
|
2529
|
+
section('options', 'Options', v.body.options?.length ? h('ul', { class: 'note-options' }, ...v.body.options.map((o) => h('li', {}, h('b', {}, o.option), ' — ', o.then))) : null, v.body.options?.length ?? null),
|
|
2530
|
+
section('facts', 'Facts', v.body.facts?.length ? h('ul', {}, ...v.body.facts.map((f) => h('li', {}, f.inferred ? h('span', { class: 'tag purple' }, 'Inferred') : null, ' ', f.text, ' ', ...f.sourceIds.map((sid) => h('button', { class: 'cite', onClick: () => openSource(sid) }, sid.slice(0, 10)))))) : null, v.body.facts?.length ?? null),
|
|
2531
|
+
text('other-explanations', 'Other explanations', v.body.otherExplanations), text('keep-or-adjust', 'Keep or adjust', v.body.keepAdjust), text('what-would-settle-it', 'What would settle it', v.body.whatWouldSettleIt),
|
|
2532
|
+
// What an absence claim rests on (CN, E152; Spec §2.12): where the Keeper looked for what came after, and up to when.
|
|
2533
|
+
section('what-was-read', 'What was read', n.looked ? h('div', { class: 'looked' },
|
|
2534
|
+
h('ul', {}, ...n.looked.where.map((w) => h('li', {}, w))),
|
|
2535
|
+
h('div', { class: 'muted' }, `Read up to ${n.looked.upTo}${n.looked.sessionsUpTo ? ` · sessions to ${n.looked.sessionsUpTo}` : ''}`),
|
|
2536
|
+
n.looked.behind ? h('div', {}, h('span', { class: 'tag amber', title: 'When the note was written, the ledger held later material than what was read' }, 'Does not reach the present'), ' ', h('span', { class: 'muted' }, n.looked.behind)) : null) : null, n.looked?.where.length ?? null),
|
|
2537
|
+
section('based-on', 'Based on', n.judgement ? h('details', { class: 'fold' }, h('summary', {}, 'Based on'), h('div', { class: 'content' }, h('div', { class: 'muted' }, `Judged ${fmtTime(n.judgement.at)} · scope ${n.judgement.scope.label}`), h('div', {}, `${n.judgement.inputs.referenceIds.length} reference items · ${n.judgement.inputs.threadIds.length} threads · ${n.judgement.inputs.areaIds.length} areas · ${n.judgement.inputs.relationIds.length} relations · ${n.judgement.inputs.keyEvidenceSourceIds.length} key sources${n.judgement.inputs.conflictingSourceIds.length ? ` · ${n.judgement.inputs.conflictingSourceIds.length} conflicting` : ''}${n.judgement.inputs.roundDocIds?.length ? ` · ${n.judgement.inputs.roundDocIds.length} round documents (the lanes’ reports, the adoption record, the main agent’s handover)` : ''}`), n.judgement.excluded.length ? h('div', { class: 'faint' }, `Excluded: ${n.judgement.excluded.join(', ')}`) : null, n.judgement.inputs.investigations.length ? h('div', {}, ...n.judgement.inputs.investigations.map((i) => h('div', { class: 'muted' }, 'Investigation: ', i.conclusion.slice(0, 200)))) : null)) : null),
|
|
2538
|
+
section('handed-over', 'Handed over', n.delegatedTo ? h('div', { class: 'muted' }, `Handed to ${n.delegatedTo.holder} as a modification request · ${n.delegatedTo.handledAt ? `handled ${fmtRel(n.delegatedTo.handledAt)}` : 'not yet handled (judged from later material)'}`) : null),
|
|
2539
|
+
section('course', 'Course', n.followUps?.length ? h('div', {}, ...n.followUps.map((f) => h('div', { class: 'muted' }, h('span', { class: 'tag' }, f.kind), ' ', fmtTime(f.at), ' · ', f.summary, f.jobId ? h('button', { class: 'text-btn', onClick: () => openActivity() }, ' job') : null))) : null, n.followUps?.length ?? null),
|
|
2540
|
+
section('discussion', 'Discussion', n.discussion.length ? h('div', { class: 'stack' }, ...n.discussion.map((d) => (d.role === 'owner' ? h('div', { class: 'msg user' }, d.text) : h('div', { class: 'msg keeper' }, fillMarkdown(h('div', { class: 'text md' }), d.text, { onCite: openCited }))))) : null, n.discussion.length || null),
|
|
2541
|
+
section('earlier-versions', 'Earlier versions', n.versions.length > 1 ? h('details', { class: 'fold' }, h('summary', {}, `Earlier versions (${n.versions.length - 1})`), h('div', { class: 'content' }, ...n.versions.slice(0, -1).reverse().map((e) => h('div', { class: 'muted' }, `v${e.version} · ${fmtTime(e.at)} · ${e.title} — ${e.reason}`)))) : null, n.versions.length - 1 || null),
|
|
2542
|
+
].filter(Boolean),
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
if (sel.kind === 'relation') {
|
|
2547
|
+
const r = x;
|
|
2548
|
+
const self = { kind: 'relation', id: r.id, label: `${r.type}: ${r.fromLabel} → ${r.toLabel}` };
|
|
2549
|
+
return {
|
|
2550
|
+
title: `${r.fromLabel} → ${r.toLabel}`, sub: `Relation · ${r.type}`, label: `Relation: ${r.fromLabel} → ${r.toLabel}`,
|
|
2551
|
+
tags: h('div', { class: 'row wrap' }, basisTag(r.basis), assessmentTag(r.assessment)),
|
|
2552
|
+
sentence: r.claim || null, sentenceNone: 'No claim recorded',
|
|
2553
|
+
notes: noteCards(r.notes, 'No note on this relation.'), noteCount: r.notes.length,
|
|
2554
|
+
extras: [],
|
|
2555
|
+
actions: [ask(sel), go.surface === 'popover' && onGraphNow() && graphCtl.rectOf(r.id) ? null : onGraph(self)],
|
|
2556
|
+
sections: [
|
|
2557
|
+
section('claim', 'Claim', h('p', {}, r.claim || h('span', { class: 'faint' }, 'No claim recorded'))),
|
|
2558
|
+
section('evidence', 'Evidence', h('div', { class: 'stack' }, r.evidence.factsSoFar ? h('p', {}, r.evidence.factsSoFar) : null, sourceList(r.evidenceSources), r.evidenceFacts.length ? h('div', { class: 'row wrap' }, ...r.evidenceFacts.map((f) => h('button', { class: 'text-btn', onClick: () => openFact(f.id) }, f.title))) : null), r.evidenceSources.length + r.evidenceFacts.length || null),
|
|
2559
|
+
section('assessment', 'Keeper assessment', h('div', {}, assessmentTag(r.assessment), r.assessedAt ? h('small', {}, ` ${fmtRel(r.assessedAt)}`) : h('small', {}, ' Only a product re-look or your correction changes it.'))),
|
|
2560
|
+
section('notes', 'Notes', noteCards(r.notes, 'No note on this relation.'), r.notes.length),
|
|
2561
|
+
section('why', 'Why the Keeper thinks so', whyFold(r.trace)),
|
|
2562
|
+
].filter(Boolean),
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
const d = x;
|
|
2567
|
+
const n = d.node;
|
|
2568
|
+
const self = NODE_SEL(n);
|
|
2569
|
+
const t = d.thread ?? null;
|
|
2570
|
+
const c = d.change ?? null;
|
|
2571
|
+
const isSource = n.category === 'Session' || n.category === 'Result' || n.category === 'Run';
|
|
2572
|
+
return {
|
|
2573
|
+
title: n.label, sub: `${n.category}${n.refKind === 'thread' ? ' · work thread' : ''}`, label: `${n.category}: ${n.label}`,
|
|
2574
|
+
// What it is, first: its validity always said (Current too, with what replaced it); its kind is the line under the
|
|
2575
|
+
// title, so it is not said again here.
|
|
2576
|
+
tags: h('div', { class: 'row wrap' }, statusTag(n, d._place, go), n.progress ? h('span', { class: 'tag' }, n.progress) : null, acceptanceTag(n), basisTag(n.basis), n.attribution ? h('span', { class: 'tag' }, n.attribution.identity, n.attribution.author?.name ? ` · ${n.attribution.author.name}` : n.attribution.author?.kind === 'owner' ? ' · owner' : '') : null, d.updatePending ? h('span', { class: 'tag amber' }, 'Update pending') : null, n.noEstablishedLink ? h('span', { class: 'tag red' }, 'No established link') : null, ...d.marks.map((m) => h('span', { class: 'tag red', title: m.clue }, `⚑ ${m.kind}`))),
|
|
2577
|
+
// Its own summary: a reference item's description, what a piece of work is doing, what a change was (Spec §6.4).
|
|
2578
|
+
sentence: d.reference?.text || d._place?.byId.get(n.id)?.summary || t?.doing || c?.summary || (isSource ? 'This node is a source; its original is under Sources.' : null),
|
|
2579
|
+
// Where it sits: its module or plan, product only, or not placed yet (the Graph's and the List's placement).
|
|
2580
|
+
place: placeLine(n, d._place, go),
|
|
2581
|
+
notes: noteCards(d.notes, 'No note on this object or on its path.'), noteCount: d.notes.length,
|
|
2582
|
+
// What it still lacks, item by item (§5.5; in the full details it is part of `Propagation`), then increment K (§6.4):
|
|
2583
|
+
// what is open on the object, `How it got here`, and the versions of a document-like object.
|
|
2584
|
+
extras: [go.surface === 'full' ? null : lacksBlock(d.propagation, go), ...kProcess.popoverBlocks(d._k, { surface: go.surface })].filter(Boolean),
|
|
2585
|
+
actions: [ask(sel), h('button', { class: 'btn small', onClick: () => { go.leave?.(); navigate(state.projectId, 'context', `sel/${sel.kind}/${encodeURIComponent(sel.id)}`); } }, 'Prepare context'), go.surface === 'popover' && onGraphNow() && graphCtl.rectOf(n.id) ? null : onGraph(self)],
|
|
2586
|
+
sections: [
|
|
2587
|
+
// Its summary stands at the top of the details (what it is, first); here what it rests on in words: the quote, its numbers.
|
|
2588
|
+
section('description', 'Description', d.reference ? h('div', {}, d.reference.quote ? h('blockquote', { class: 'quote' }, d.reference.quote) : null, d.reference.answers ? h('p', { class: 'faint' }, 'In answer to: “', d.reference.answers, '”') : null, d.reference.ids.length ? h('div', {}, ...d.reference.ids.map((i) => h('span', { class: 'tag' }, i))) : null, !d.reference.quote && !d.reference.ids.length ? h('p', { class: 'faint' }, 'No quote or number recorded; its summary is at the top.') : null) : null),
|
|
2589
|
+
section('authority', 'Authority', d.reference && d.authority ? authorityBlock(d.authority) : null),
|
|
2590
|
+
section('carry-out', 'Carry-out', d.reference && d.carryOut ? carryOutBlock(d.carryOut, go) : null, d.carryOut?.work.length || null),
|
|
2591
|
+
section('area', 'Area understanding', d.reference?.category === 'Area' ? h('div', {}, h('h4', {}, 'Area understanding'), d.area ? h('div', {}, h('p', {}, h('b', {}, 'Effect now: '), d.area.effectNow), h('p', {}, h('b', {}, 'Gaps: '), d.area.gaps), h('small', {}, `As of ${fmtTime(d.area.asOf)}`)) : h('div', { class: 'faint' }, 'Not organized yet.'), d.contributions.length ? h('div', { class: 'stack' }, h('h4', {}, 'Contributing work'), ...d.contributions.map((k) => h('div', {}, h('button', { class: 'text-btn', onClick: () => go.object({ kind: 'node', id: k.threadId, label: k.thread?.title ?? k.threadId }) }, k.thread?.title ?? k.threadId), ' ', basisTag(k.basis), h('div', { class: 'muted' }, k.claim)))) : null) : null, d.contributions?.length || null),
|
|
2592
|
+
section('work', 'Work thread', t ? h('div', { class: 'stack' }, h('div', {}, h('h4', {}, 'Doing'), h('p', {}, t.doing)), h('div', {}, h('h4', {}, 'Changed on the way'), h('p', {}, t.changed || '—')), h('div', {}, h('h4', {}, 'Actual results'), h('p', {}, t.results || '—')), h('div', {}, h('h4', {}, 'Unresolved'), h('p', {}, t.unresolved || '—'))) : null),
|
|
2593
|
+
section('serves', 'Claims to serve', t?.servesLabels.length ? h('div', {}, h('h4', {}, 'Claims to serve'), ...t.servesLabels.map((s) => h('div', {}, h('button', { class: 'text-btn', onClick: () => go.object({ kind: 'node', id: s.referenceId, label: s.name }) }, s.name), ' ', basisTag(s.basis), h('div', { class: 'muted' }, s.claim)))) : null, t?.servesLabels.length || null),
|
|
2594
|
+
section('execution', 'Execution and QC facts', t && (t.executionFacts.length || t.qcFacts.length) ? h('details', { class: 'fold' }, h('summary', {}, 'Execution and QC facts'), h('div', { class: 'content' }, h('ul', { class: 'stmts' }, ...[...t.executionFacts, ...t.qcFacts].map(statementItem)), checksBlock(t.checks))) : null, t ? t.executionFacts.length + t.qcFacts.length || null : null),
|
|
2595
|
+
section('fact-records', 'Fact records', t ? h('details', { class: 'fold' }, h('summary', {}, `Show fact records (${t.facts.length})`), h('div', { class: 'content stack' }, ...t.facts.map((f) => h('div', {}, h('button', { class: 'text-btn', onClick: () => openFact(f.id) }, f.title), h('small', {}, ` ${f.statements} statements${f.open ? ` · ${f.open} open` : ''}`))))) : null, t ? t.facts.length : null),
|
|
2596
|
+
section('sessions', 'Agent sessions and context delivery', t ? h('div', {}, h('small', { class: 'faint' }, `As of ${fmtTime(t.asOf)}`), h('div', { class: 'faint' }, 'Agent sessions on this work and context delivery: none connected yet.')) : null),
|
|
2597
|
+
section('change', 'Change', c ? h('div', {}, h('div', { class: 'row wrap' }, h('span', { class: 'tag' }, c.material), effectTag(c.effect), h('small', {}, fmtTime(c.at))), h('p', {}, c.summary), c.before || c.after ? h('div', { class: 'diff' }, h('div', { class: 'old' }, h('small', {}, 'Before'), h('del', {}, c.before || '—')), h('div', { class: 'new' }, h('small', {}, 'After'), c.after || '—')) : null,
|
|
2598
|
+
c.affectsLabels?.length ? h('div', { class: 'row wrap' }, h('small', {}, 'Affects'), ...c.affectsLabels.map((a) => h('button', { class: 'text-btn', onClick: () => go.object({ kind: 'node', id: a.id, label: a.label }) }, namedObject(a)))) : null,
|
|
2599
|
+
h('div', { class: 'row wrap' }, h('small', {}, 'Propagation'), h('span', { class: 'muted' }, propagationText(c.propagationSummary))), h('button', { class: 'btn small', onClick: () => { go.leave?.(); showAffected(c); } }, 'Show affected')) : null),
|
|
2600
|
+
section('sources', 'Sources', h('div', {}, isSource ? h('div', { class: 'faint' }, 'This node is a source; open it below to read the original.') : null, sourceList(d.sources)), d.sources.length),
|
|
2601
|
+
section('relations', 'Relations', d.relations.length ? h('div', { class: 'stack' }, ...d.relations.map((r) => h('div', {}, h('button', { class: 'text-btn', onClick: () => go.object({ kind: 'relation', id: r.id, label: `${r.type}: ${r.fromLabel} → ${r.toLabel}` }) }, r.from === n.id ? `${r.type} → ${r.toLabel}` : `${r.fromLabel} → ${r.type}`), ' ', assessmentTag(r.assessment), basisTag(r.basis)))) : h('div', { class: 'faint' }, n.noEstablishedLink ? 'No established link.' : 'No relation recorded.'), d.relations.length),
|
|
2602
|
+
section('notes', 'Notes', noteCards(d.notes, 'No note on this object or on its path.'), d.notes.length),
|
|
2603
|
+
section('changes', 'Changes', d.changes.length ? h('div', {}, ...d.changes.map((k) => h('div', {}, effectTag(k.effect), ' ', h('button', { class: 'text-btn', onClick: () => go.object({ kind: 'node', id: k.id, label: k.title, category: 'Change' }) }, k.title), h('small', {}, ` ${fmtTime(k.at)}`)))) : null, d.changes.length || null),
|
|
2604
|
+
section('propagation', 'Propagation', d.propagation ? propagationBlock(d.propagation, go) : null, d.propagation?.lacks.length || null),
|
|
2605
|
+
section('why', 'Why the Keeper thinks so', whyFold(d.trace)),
|
|
2606
|
+
].filter(Boolean),
|
|
2607
|
+
};
|
|
2608
|
+
},
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* The popover's page for one object: the summary. `Details` opens the rest. A note of the object opens as another
|
|
2612
|
+
* page of the same popover, with a way back (Spec §6.4).
|
|
2613
|
+
*/
|
|
2614
|
+
async popover(mount, sel, p, { back = null, hints = null } = {}) {
|
|
2615
|
+
const x = await details.load(sel);
|
|
2616
|
+
// What it hangs on, for the popover's place when the object itself is not on screen (app.js anchorFor).
|
|
2617
|
+
if (hints && hints.length === 0) hints.push(...(sel.kind === 'relation' ? [x.to, x.from] : sel.kind === 'note' ? x.mount?.ids ?? [] : []));
|
|
2618
|
+
const page = (target, from) => p.show({ key: `${target.kind}:${target.id}`, label: `Details: ${target.label ?? target.id}`, render: (m, pp) => details.popover(m, target, pp, { back: from }) });
|
|
2619
|
+
const go = {
|
|
2620
|
+
surface: 'popover',
|
|
2621
|
+
note: (note) => page(note, { sel, label: parts.title }),
|
|
2622
|
+
object: (other) => select(other),
|
|
2623
|
+
redraw: () => p.refresh({ force: true }), // the rest of the page follows the asset event, in place
|
|
2624
|
+
};
|
|
2625
|
+
const parts = details.parts(sel, x, go);
|
|
2626
|
+
append(mount,
|
|
2627
|
+
h('div', { class: 'popover-head' },
|
|
2628
|
+
h('div', { class: 'grow' },
|
|
2629
|
+
back ? h('button', { class: 'text-btn popover-back', title: 'Back to the object this note is on', onClick: () => page(back.sel, null) }, `‹ ${back.label}`) : null,
|
|
2630
|
+
h('h3', { class: 'popover-title' }, parts.title), h('div', { class: 'popover-sub' }, parts.sub)),
|
|
2631
|
+
h('button', { class: 'popover-close', title: 'Close (Esc)', 'aria-label': 'Close', onClick: () => p.close('button') }, '×')),
|
|
2632
|
+
h('div', { class: 'popover-body' },
|
|
2633
|
+
parts.tags,
|
|
2634
|
+
parts.sentence ? h('p', { class: 'popover-sentence' }, parts.sentence) : parts.sentenceNone ? h('p', { class: 'popover-sentence faint' }, parts.sentenceNone) : null,
|
|
2635
|
+
parts.place ?? null,
|
|
2636
|
+
...parts.extras,
|
|
2637
|
+
parts.on ?? null,
|
|
2638
|
+
parts.notes ? h('div', { class: 'popover-notes' }, h('h4', {}, `Notes${parts.noteCount ? ` (${parts.noteCount})` : ''}`), parts.notes) : null,
|
|
2639
|
+
h('div', { class: 'row wrap popover-actions' }, ...parts.actions)),
|
|
2640
|
+
h('div', { class: 'popover-foot' },
|
|
2641
|
+
h('button', { class: 'btn small primary', title: sel.kind === 'note' ? 'The whole note, in a larger window' : 'Everything about this object, in a larger window', onClick: () => details.full(sel) }, 'Details')));
|
|
2642
|
+
},
|
|
2643
|
+
|
|
2644
|
+
/**
|
|
2645
|
+
* `Details`: every part, in the dialog (Spec §6.4; CKC-09 AC-12, AC-33, AC-35). A link to another object opens that
|
|
2646
|
+
* object's full details on top of this one — `Back` returns here as it was, `Show on graph` leaves for the graph —
|
|
2647
|
+
* so reading on never drops the owner out of the dialog and no link is a dead end. While it is open it follows the
|
|
2648
|
+
* assets like the popover does: fetched again, swapped only when it differs, scroll position and open folds kept.
|
|
2649
|
+
*/
|
|
2650
|
+
async full(sel, { section = null } = {}) {
|
|
2651
|
+
let x;
|
|
2652
|
+
try { x = await details.load(sel); } catch (e) { toast(e.message); return; }
|
|
2653
|
+
const body = h('div', { class: 'full-details' });
|
|
2654
|
+
let sig = null;
|
|
2655
|
+
const go = {
|
|
2656
|
+
surface: 'full',
|
|
2657
|
+
leave: () => { const d = document.querySelector('#dialog'); if (d?.open) d.close(); },
|
|
2658
|
+
note: (note) => details.full(note),
|
|
2659
|
+
object: (other) => details.full(other),
|
|
2660
|
+
redraw: async () => { await draw(true); void popover.refresh(); },
|
|
2661
|
+
};
|
|
2662
|
+
const build = (data) => {
|
|
2663
|
+
const parts = details.parts(sel, data, go);
|
|
2664
|
+
return { parts, nodes: [
|
|
2665
|
+
h('div', { class: 'full-head' }, h('div', { class: 'muted' }, parts.sub), parts.tags),
|
|
2666
|
+
parts.sentence && sel.kind === 'note' ? h('p', { class: 'muted' }, parts.sentence) : null,
|
|
2667
|
+
parts.sentence && sel.kind === 'node' ? h('p', { class: 'full-sentence' }, parts.sentence) : null,
|
|
2668
|
+
parts.place ?? null,
|
|
2669
|
+
...parts.extras,
|
|
2670
|
+
parts.on ?? null,
|
|
2671
|
+
h('div', { class: 'row wrap' }, ...parts.actions),
|
|
2672
|
+
...parts.sections.map((s) => h('section', { class: 'full-section', 'data-section': s.key }, s.node.matches?.('details') || s.node.querySelector?.(':scope > h4') ? null : h('h4', {}, s.count !== null && s.count !== undefined && s.count !== 0 ? `${s.label} (${s.count})` : s.label), s.node)),
|
|
2673
|
+
].filter(Boolean) };
|
|
2674
|
+
};
|
|
2675
|
+
const draw = async (force = false) => {
|
|
2676
|
+
if (!body.isConnected && sig !== null) return;
|
|
2677
|
+
let data = x;
|
|
2678
|
+
if (sig !== null) { try { data = await details.load(sel); } catch { return; } }
|
|
2679
|
+
const { nodes } = build(data);
|
|
2680
|
+
const next = visHtml(nodes.map((n) => n.outerHTML).join(''));
|
|
2681
|
+
if (!force && next === sig) return;
|
|
2682
|
+
const scroller = body.closest('.dialog-body');
|
|
2683
|
+
const top = scroller?.scrollTop ?? 0;
|
|
2684
|
+
const openFolds = new Set([...body.querySelectorAll('details[open]')].map(foldKey));
|
|
2685
|
+
sig = next;
|
|
2686
|
+
body.replaceChildren(...nodes);
|
|
2687
|
+
body.querySelectorAll('details').forEach((f) => { if (openFolds.has(foldKey(f))) f.open = true; });
|
|
2688
|
+
if (scroller) scroller.scrollTop = top;
|
|
2689
|
+
};
|
|
2690
|
+
const first = build(x);
|
|
2691
|
+
sig = visHtml(first.nodes.map((n) => n.outerHTML).join(''));
|
|
2692
|
+
body.append(...first.nodes);
|
|
2693
|
+
if (sel.kind === 'note') body.classList.add('note-body');
|
|
2694
|
+
const dialog = openDialog(first.parts.title, [body]);
|
|
2695
|
+
dialog.classList.add('details-dialog');
|
|
2696
|
+
dialog._details = { refresh: () => draw(false) };
|
|
2697
|
+
if (section) {
|
|
2698
|
+
const target = body.querySelector(`[data-section="${CSS.escape(section)}"]`);
|
|
2699
|
+
if (target) {
|
|
2700
|
+
target.querySelectorAll(':scope > details').forEach((f) => { f.open = true; });
|
|
2701
|
+
target.scrollIntoView({ block: 'start' });
|
|
2702
|
+
target.classList.add('flash');
|
|
2703
|
+
setTimeout(() => target.classList.remove('flash'), 1800);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
},
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
// ── Agent context (§7.8) ─────────────────────────────────────────────────
|
|
2710
|
+
function mdToNodes(md) {
|
|
2711
|
+
const nodes = [];
|
|
2712
|
+
let list = null;
|
|
2713
|
+
for (const raw of md.split('\n')) {
|
|
2714
|
+
const line = raw.replace(/\r$/, '');
|
|
2715
|
+
if (/^- /.test(line)) { if (!list) { list = h('ul', { class: 'ctx-list' }); nodes.push(list); } const item = h('li', {}); inline(item, line.slice(2)); list.append(item); continue; }
|
|
2716
|
+
if (/^ {2,6}- /.test(line)) { const sub = list?.lastElementChild; const item = h('li', { class: 'sub' }); inline(item, line.trim().slice(2)); if (sub) { let ul = sub.querySelector(':scope > ul'); if (!ul) { ul = h('ul'); sub.append(ul); } ul.append(item); } continue; }
|
|
2717
|
+
list = null;
|
|
2718
|
+
if (!line.trim()) continue;
|
|
2719
|
+
if (line.startsWith('# ')) nodes.push(h('h2', {}, line.slice(2)));
|
|
2720
|
+
else if (line.startsWith('## ')) nodes.push(h('h3', {}, line.slice(3)));
|
|
2721
|
+
else { const p = h('p', {}); inline(p, line); nodes.push(p); }
|
|
2722
|
+
}
|
|
2723
|
+
return nodes;
|
|
2724
|
+
function inline(el, text) {
|
|
2725
|
+
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
|
2726
|
+
for (const part of parts) {
|
|
2727
|
+
if (!part) continue;
|
|
2728
|
+
if (part.startsWith('**')) el.append(h('b', {}, part.slice(2, -2)));
|
|
2729
|
+
else if (part.startsWith('`')) el.append(h('code', {}, part.slice(1, -1)));
|
|
2730
|
+
else el.append(part);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
const context = {
|
|
2736
|
+
async render(main) {
|
|
2737
|
+
const opts = await api(`/api/projects/${P()}/context/options`);
|
|
2738
|
+
const f = state.filters.context ?? (state.filters.context = { scope: 'project', ids: '', purpose: 'Start', kind: 'Implement', recipient: 'Incoming agent', taskVersion: '' });
|
|
2739
|
+
const m = /^sel\/(\w+)\/(.+)$/.exec(state.routeRest || '');
|
|
2740
|
+
if (m) {
|
|
2741
|
+
const id = decodeURIComponent(m[2]);
|
|
2742
|
+
const opt = opts.forWork.find((o) => o.id === id);
|
|
2743
|
+
if (opt) { f.scope = opt.kind; f.ids = opt.id; f.purpose = opt.kind === 'work' ? 'Work' : 'Start'; }
|
|
2744
|
+
else { f.scope = 'path'; f.ids = id; f.purpose = 'Work'; }
|
|
2745
|
+
state.routeRest = '';
|
|
2746
|
+
}
|
|
2747
|
+
const sel = (label, key, options, render) => h('div', { class: 'field' }, h('label', {}, label), h('select', { class: 'input', onChange: (e) => { f[key] = e.target.value; if (key === 'work') { const o = opts.forWork.find((x) => `${x.kind}:${x.id}` === e.target.value); f.scope = o?.kind ?? 'project'; f.ids = o?.id ?? ''; if (o?.kind === 'work') f.purpose = 'Work'; } load(); } }, ...options.map((o) => render(o))));
|
|
2748
|
+
const workValue = `${f.scope}:${f.ids}`;
|
|
2749
|
+
const forWork = sel('For work', 'work', opts.forWork, (o) => h('option', { value: `${o.kind}:${o.id}`, selected: `${o.kind}:${o.id}` === workValue }, o.label + (o.progress ? ` · ${o.progress}` : '')));
|
|
2750
|
+
if (f.scope === 'path' && f.ids) forWork.querySelector('select').append(h('option', { value: `path:${f.ids}`, selected: true }, `Selected path (${f.ids.split(',').length})`));
|
|
2751
|
+
const purpose = sel('Purpose', 'purpose', opts.purposes, (o) => h('option', { value: o, selected: f.purpose === o }, o));
|
|
2752
|
+
const kind = sel('Kind', 'kind', opts.kinds, (o) => h('option', { value: o, selected: f.kind === o }, o));
|
|
2753
|
+
const recipient = sel('Recipient', 'recipient', opts.recipients, (o) => h('option', { value: o, selected: f.recipient === o }, o));
|
|
2754
|
+
const taskVersion = h('div', { class: 'field' }, h('label', {}, 'Task version (as your task states it)'), h('input', { class: 'input', value: f.taskVersion ?? '', placeholder: 'e.g. T-05 v2.0', onChange: (e) => { f.taskVersion = e.target.value; load(); } }));
|
|
2755
|
+
const preview = h('div', { class: 'ctx-preview' });
|
|
2756
|
+
const meta = h('div', { class: 'muted' });
|
|
2757
|
+
let pkg = null;
|
|
2758
|
+
const load = async () => {
|
|
2759
|
+
const params = new URLSearchParams({ scope: f.scope, ids: f.ids, purpose: f.purpose, kind: f.kind, recipient: f.recipient, since: state.lastVisit ?? '', taskVersion: f.taskVersion ?? '' });
|
|
2760
|
+
pkg = await api(`/api/projects/${P()}/context?${params}`);
|
|
2761
|
+
clear(preview); append(preview, ...mdToNodes(pkg.markdown));
|
|
2762
|
+
meta.textContent = `As of ${pkg.asOf ? fmtTime(pkg.asOf) : '—'}${pkg.commit ? ` @ ${pkg.commit.slice(0, 8)}` : ''} · Keeper ${pkg.keeperStatus} · ${pkg.citedSourceIds.length} sources cited · ${pkg.markdown.length} characters`;
|
|
2763
|
+
};
|
|
2764
|
+
const copy = h('button', { class: 'btn small primary', onClick: async () => { if (!pkg) return; try { await navigator.clipboard.writeText(pkg.markdown); toast('Context copied — nothing was installed or injected anywhere'); } catch { const ta = h('textarea', { class: 'input', style: { minHeight: '40vh' } }, pkg.markdown); openDialog('Copy the context', [h('p', { class: 'muted' }, 'The clipboard is unavailable; select the text below and copy it.'), ta]); ta.select(); } } }, 'Copy context');
|
|
2765
|
+
const download = h('button', { class: 'btn small', onClick: () => { if (!pkg) return; const a = h('a', { href: URL.createObjectURL(new Blob([pkg.markdown], { type: 'text/markdown' })), download: `context-${state.projectId}-${f.purpose.toLowerCase()}-${f.kind.toLowerCase().replace(/\s+/g, '-')}.md` }); document.body.append(a); a.click(); a.remove(); } }, 'Download .md');
|
|
2766
|
+
append(main,
|
|
2767
|
+
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Agent context'), h('p', { class: 'sub' }, 'Prepare the start or work context for the next agent, then copy or download it. Roles are optional. Copying or downloading installs nothing and injects nothing into any host session; the same input always gives the same content.')), h('div', { class: 'row' }, copy, download)),
|
|
2768
|
+
h('div', { class: 'row wrap ctx-controls' }, forWork, purpose, kind, recipient, taskVersion),
|
|
2769
|
+
meta, preview);
|
|
2770
|
+
await load();
|
|
2771
|
+
context._load = load;
|
|
2772
|
+
},
|
|
2773
|
+
async onAssets() { if (context._load) await context._load(); },
|
|
2774
|
+
};
|
|
2775
|
+
|
|
2776
|
+
const search = {
|
|
2777
|
+
go(x) {
|
|
2778
|
+
if (x.type === 'source') { openSource(x.id); return; }
|
|
2779
|
+
if (x.type === 'node') {
|
|
2780
|
+
const sel = { kind: 'node', id: x.id, label: x.label };
|
|
2781
|
+
if (OBSERVED.includes(x.detail) && state.graphMode !== 'list') state.resultFocus = x.id;
|
|
2782
|
+
if (state.view !== 'graph') { state.selection = sel; state.pendingPopover = {}; navigate(state.projectId, 'graph'); return; }
|
|
2783
|
+
if (state.resultFocus) { state.selection = sel; graph.focusResult(); select(sel, { trigger: null, origin: 'search-result' }); }
|
|
2784
|
+
else select(sel, { trigger: null, reveal: true });
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
if (x.type === 'change') { navigate(state.projectId, 'changes', encodeURIComponent(x.id)); return; }
|
|
2788
|
+
if (x.type === 'note') {
|
|
2789
|
+
const sel = { kind: 'note', id: x.id, label: x.label };
|
|
2790
|
+
if (state.view === 'notes') { select(sel, { trigger: null }); document.querySelector(`#main .note-card[data-note="${CSS.escape(x.id)}"]`)?.scrollIntoView({ block: 'nearest' }); }
|
|
2791
|
+
else { state.selection = sel; state.pendingPopover = {}; navigate(state.projectId, 'notes'); }
|
|
2792
|
+
}
|
|
2793
|
+
},
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
export const views = { graph, notes, changes, context, scope, keeper: keeperView, connections: keeperView, details, conversation, activity: activityView, search };
|