@minonl/dia 0.0.13 → 0.0.15

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/dist/cli.js CHANGED
@@ -1899,6 +1899,9 @@ function assertInside(base, p) {
1899
1899
  return abs;
1900
1900
  }
1901
1901
  var TREE_SKIP = new Set([".git", "node_modules", ".data"]);
1902
+ function copyFilter(base) {
1903
+ return (p) => !relative3(base, p).split(sep4).some((s) => TREE_SKIP.has(s));
1904
+ }
1902
1905
  function walkFiles(root) {
1903
1906
  const out = [];
1904
1907
  (function rec(dir) {
@@ -2109,7 +2112,7 @@ function install(from, opts = {}) {
2109
2112
  if (!alreadyInstalled || hashTree(src.dir) !== hashTree(target)) {
2110
2113
  rmSync3(target, { recursive: true, force: true });
2111
2114
  mkdirSync7(target, { recursive: true });
2112
- cpSync(src.dir, target, { recursive: true, dereference: true, filter: (p) => !p.split(sep4).some((s) => TREE_SKIP.has(s)) });
2115
+ cpSync(src.dir, target, { recursive: true, dereference: true, filter: copyFilter(src.dir) });
2113
2116
  }
2114
2117
  const rendered = renderContent(src.dir, target, m) ?? planned;
2115
2118
  const appliedConfig = apply(m.config ?? []);
@@ -2881,7 +2884,7 @@ function install2(from, opts = {}) {
2881
2884
  const staging = join13(idDir, `.staging-${process.pid}-${Date.now().toString(36)}`);
2882
2885
  rmSync4(staging, { recursive: true, force: true });
2883
2886
  mkdirSync10(staging, { recursive: true });
2884
- cpSync2(src.dir, staging, { recursive: true, dereference: true, filter: (p) => !p.split(sep5).some((s) => TREE_SKIP.has(s)) });
2887
+ cpSync2(src.dir, staging, { recursive: true, dereference: true, filter: copyFilter(src.dir) });
2885
2888
  const stagingHash = hashTree2(staging);
2886
2889
  let reuseTarget = false;
2887
2890
  if (existsSync11(target)) {
@@ -4776,12 +4779,29 @@ function build(opts = {}) {
4776
4779
  // src/graph-tui.ts
4777
4780
  var ORDER = ["doing", "blocked", "review", "todo", "awaiting_acceptance", "accepted", "done", "dropped", "rejected", "escalated"];
4778
4781
  var color = (s, code, on) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
4782
+ var clip2 = (s, n) => {
4783
+ const one = String(s || "").replace(/\s+/g, " ").trim();
4784
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
4785
+ };
4786
+ var shortId = (id) => id ? id.replace(/^ses_/, "").slice(-8) : "";
4779
4787
  function render2(g, opts = {}) {
4780
4788
  const on = opts.color ?? false;
4781
- const tasks = g.nodes.filter((n) => n.kind === "task");
4782
- const taskById = new Map(tasks.map((t) => [t.id, t]));
4789
+ const width = Math.max(32, opts.width ?? 96);
4790
+ const tasksAll = g.nodes.filter((n) => n.kind === "task");
4791
+ const tasks = tasksAll.filter((t) => (!opts.status || t.status === opts.status) && (!opts.owner || t.owner === opts.owner));
4792
+ const taskById = new Map(tasksAll.map((t) => [t.id, t]));
4783
4793
  const edgesFrom = (id, kind) => g.edges.filter((e) => e.from === id && e.kind === kind);
4784
4794
  const lines = [];
4795
+ const counts = new Map;
4796
+ for (const t of tasksAll)
4797
+ counts.set(t.status || "todo", (counts.get(t.status || "todo") || 0) + 1);
4798
+ const countText = [...ORDER.filter((s) => counts.has(s)), ...[...counts.keys()].filter((s) => !ORDER.includes(s))].map((s) => `${s}=${counts.get(s)}`).join(" ");
4799
+ const claims = g.edges.filter((e) => e.kind === "claims");
4800
+ const filter = [opts.status ? `status=${opts.status}` : "", opts.owner ? `owner=${opts.owner}` : ""].filter(Boolean).join(" ");
4801
+ lines.push(color(`dia graph tasks=${tasksAll.length} ${countText} claims=${claims.length}${filter ? ` filter: ${filter}` : ""}`, "1", on));
4802
+ if (opts.now)
4803
+ lines.push(color(`updated ${opts.now.toLocaleTimeString()}`, "2", on));
4804
+ lines.push("");
4785
4805
  const groups = new Map;
4786
4806
  for (const t of tasks) {
4787
4807
  const st = t.status || "todo";
@@ -4790,34 +4810,41 @@ function render2(g, opts = {}) {
4790
4810
  for (const st of [...ORDER.filter((s) => groups.has(s)), ...[...groups.keys()].filter((s) => !ORDER.includes(s))]) {
4791
4811
  lines.push(color(`[${st}]`, "1;36", on));
4792
4812
  for (const t of groups.get(st).sort((a, b) => a.label.localeCompare(b.label))) {
4793
- lines.push(` ${color(t.label, "1", on)} ${color(t.id.replace(/^task:/, ""), "2", on)}${t.owner ? ` owner=${t.owner}` : ""}`);
4813
+ const owner = t.owner ? ` @${shortId(t.owner)}` : "";
4814
+ const deps = edgesFrom(t.id, "depends_on").length;
4815
+ lines.push(` ${color("●", "32", on)} ${color(clip2(t.label, width - 24), "1", on)} ${color(shortId(t.id.replace(/^task:/, "")), "2", on)}${owner}${deps ? ` deps=${deps}` : ""}`);
4794
4816
  for (const e of edgesFrom(t.id, "depends_on")) {
4795
4817
  const dep = taskById.get(e.to);
4796
- lines.push(` ${color("↳ depends", "34", on)} ${dep?.label || e.to}${dep ? ` [${dep.status}]` : ""}`);
4818
+ lines.push(` ${color("↳ depends", "34", on)} ${clip2(dep?.label || e.to, width - 20)}${dep ? ` [${dep.status}]` : ""}`);
4797
4819
  }
4798
4820
  for (const e of edgesFrom(t.id, "touches"))
4799
- lines.push(` ${color("↳ touches", "35", on)} ${e.to.replace(/^path:/, "")}`);
4800
- if (t.blocked_by?.length)
4801
- lines.push(` ${color(`⚠ blocked_by ${t.blocked_by.join(", ")}`, "31", on)}`);
4821
+ lines.push(` ${color("↳ touches", "35", on)} ${clip2(e.to.replace(/^path:/, ""), width - 20)}`);
4822
+ if (t.blocked_by?.length) {
4823
+ const names = t.blocked_by.map((id) => taskById.get(`task:${id}`)?.label || id);
4824
+ lines.push(` ${color(`⚠ blocked_by ${names.map((x) => clip2(x, 40)).join(", ")}`, "31", on)}`);
4825
+ }
4802
4826
  }
4803
4827
  lines.push("");
4804
4828
  }
4805
4829
  if (!tasks.length)
4806
- lines.push("(no tasks)");
4807
- const claimEdges = g.edges.filter((e) => e.kind === "claims");
4808
- if (claimEdges.length) {
4830
+ lines.push(opts.status || opts.owner ? "(no matching tasks)" : "(no tasks)");
4831
+ if (claims.length) {
4809
4832
  lines.push(color("claims", "1;36", on));
4810
- for (const e of claimEdges)
4811
- lines.push(` ${e.from.replace(/^session:/, "")} → ${e.to.replace(/^path:/, "")}`);
4833
+ for (const e of claims) {
4834
+ const holder = e.from.replace(/^session:/, "");
4835
+ if (opts.owner && holder !== opts.owner)
4836
+ continue;
4837
+ lines.push(` ${shortId(holder)} → ${clip2(e.to.replace(/^path:/, ""), width - 16)}`);
4838
+ }
4812
4839
  lines.push("");
4813
4840
  }
4814
4841
  for (const c of g.cycles)
4815
- lines.push(color(`⚠ cycle: ${c.join(" -> ")}`, "31", on));
4842
+ lines.push(color(`⚠ cycle: ${c.map((x) => x.replace(/^task:/, "")).join(" -> ")}`, "31", on));
4816
4843
  return lines.join(`
4817
4844
  `);
4818
4845
  }
4819
4846
  function watch(build, opts = {}) {
4820
- const paint = () => process.stdout.write("\x1B[2J\x1B[H" + render2(build(), { color: opts.color ?? process.stdout.isTTY }) + `
4847
+ const paint = () => process.stdout.write("\x1B[2J\x1B[H" + render2(build(), { ...opts, now: new Date }) + `
4821
4848
  `);
4822
4849
  paint();
4823
4850
  const timer = setInterval(() => {
@@ -8565,7 +8592,7 @@ usage:
8565
8592
  dia agents list|register|unregister|discover
8566
8593
  dia msg deliver <agent> <id> [--transport tmux|acp]
8567
8594
  dia task add|list|link|status|done [--owner S] [--depends a,b] [--touches p1,p2]
8568
- dia graph [--json] [--task t-...] [--tui [--watch] [--no-color]]
8595
+ dia graph [--json] [--task t-...] [--tui [--watch] [--status S] [--owner S] [--width N] [--no-color]]
8569
8596
  dia claim <path> [--reason R] [--ttl 4h] [--session S] | dia claims [--all] [--mine] [--gc [--days 7]] | dia unclaim <path|id> [--force]
8570
8597
  dia team run <id> --mock | --acp|--dsh --workspace DIR
8571
8598
  dia team gate|accept|reject|next
@@ -8959,10 +8986,17 @@ async function taskCli(argv) {
8959
8986
  async function graphCli(argv) {
8960
8987
  const asJson = has(argv, "--json");
8961
8988
  if (has(argv, "--tui") || has(argv, "--watch")) {
8962
- const paint = (g) => out(render2(g, { color: !!process.stdout.isTTY && !has(argv, "--no-color") }));
8989
+ const width = Number(flag(argv, "--width") || 0);
8990
+ const tuiOpts = {
8991
+ color: !!process.stdout.isTTY && !has(argv, "--no-color"),
8992
+ status: flag(argv, "--status"),
8993
+ owner: flag(argv, "--owner"),
8994
+ width: width > 0 ? width : undefined
8995
+ };
8996
+ const paint = (g) => out(render2(g, tuiOpts));
8963
8997
  try {
8964
8998
  if (has(argv, "--watch"))
8965
- return await watch(() => build({ task: flag(argv, "--task") }), { color: !!process.stdout.isTTY && !has(argv, "--no-color") });
8999
+ return await watch(() => build({ task: flag(argv, "--task") }), tuiOpts);
8966
9000
  const g = build({ task: flag(argv, "--task") });
8967
9001
  paint(g);
8968
9002
  return g.cycles.length ? 3 : 0;
@@ -0,0 +1,730 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>dia graph · 视觉设计稿 v0.3</title>
7
+ <style>
8
+ :root{
9
+ --bg:#f6f7f9; --panel:#fff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb;
10
+ --blue:#2563eb; --violet:#7c3aed; --cyan:#0891b2; --green:#16a34a; --red:#dc2626; --amber:#d97706;
11
+ --shadow:0 1px 2px rgba(17,24,39,.06),0 8px 24px rgba(17,24,39,.06);
12
+ }
13
+ @media (prefers-color-scheme: dark){
14
+ :root{ --bg:#0f1115; --panel:#171a21; --ink:#e5e7eb; --muted:#9ca3af; --line:#2a2f3a;
15
+ --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.25); }
16
+ }
17
+ *{box-sizing:border-box}
18
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
19
+ header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);background:var(--panel);flex-wrap:wrap}
20
+ header h1{font-size:17px;margin:0;letter-spacing:.2px}
21
+ header .sub{color:var(--muted);font-size:12px}
22
+ header .spacer{flex:1}
23
+ .tabs{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
24
+ .tabs button{border:0;background:transparent;color:var(--muted);padding:6px 12px;font:inherit;font-size:12px;cursor:pointer}
25
+ .tabs button.on{background:var(--blue);color:#fff}
26
+ .pill{border:1px solid var(--line);border-radius:999px;padding:4px 10px;font-size:12px;color:var(--muted);background:var(--bg)}
27
+ main{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;padding:18px;max-width:1480px;margin:0 auto}
28
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
29
+ .toolbar{display:flex;gap:8px;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap}
30
+ .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
31
+ .seg button{border:0;background:transparent;color:var(--muted);padding:6px 10px;font:inherit;font-size:12px;cursor:pointer}
32
+ .seg button.on{background:var(--blue);color:#fff}
33
+ .seg button:disabled{opacity:.35;cursor:not-allowed}
34
+ .win-select{border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:8px;padding:6px 8px;font:inherit;font-size:12px}
35
+ .search{position:relative}
36
+ .search input{border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:8px;padding:6px 10px;font:inherit;font-size:12px;width:210px}
37
+ .search .q-list{position:absolute;left:0;top:34px;z-index:20;width:280px;max-height:250px;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);display:none}
38
+ .search .q-list.open{display:block}
39
+ .q-item{padding:6px 10px;font-size:12px;cursor:pointer;display:flex;justify-content:space-between;gap:10px}
40
+ .q-item:hover,.q-item.active{background:color-mix(in srgb,var(--blue) 18%,transparent)}
41
+ .q-item .k{color:var(--muted)}
42
+ .q-item .q-prio{font-style:normal;font-size:10px;color:var(--muted);margin-left:6px}
43
+ .q-head{padding:6px 10px;font-size:10px;color:var(--muted);border-bottom:1px solid var(--line)}
44
+ .q-empty{padding:10px;font-size:12px;color:var(--muted)}
45
+ .node.hit{outline:2px solid var(--blue);outline-offset:1px}
46
+ .node.dim{opacity:.22}
47
+ .focus-chip{display:none;align-items:center;gap:4px;border:1px solid var(--blue);color:var(--blue);border-radius:999px;padding:3px 4px 3px 10px;font-size:11px}
48
+ .focus-chip.on{display:inline-flex}
49
+ .focus-chip button{border:0;background:transparent;color:var(--blue);cursor:pointer;font:inherit;font-size:11px;padding:0 6px}
50
+ .focus-chip .fd{border:0;background:transparent;color:var(--blue);font:inherit;font-size:11px}
51
+ .toolbar .hint{margin-left:auto;color:var(--muted);font-size:12px}
52
+ .canvas{position:relative;height:620px;overflow:hidden}
53
+ .canvas-scroll{position:absolute;inset:0;overflow:auto;padding:10px}
54
+ .lane-rail{position:absolute;left:0;top:0;bottom:0;width:110px;z-index:6;pointer-events:none;background:linear-gradient(90deg,var(--panel) 86%,transparent)}
55
+ svg.edges{position:absolute;inset:0;pointer-events:none;overflow:visible}
56
+ .node{position:absolute;width:190px;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:10px;background:var(--panel);padding:8px 10px;box-shadow:var(--shadow);cursor:pointer}
57
+ .node.path{border-left-color:var(--violet)}
58
+ .node.session{border-left-color:var(--cyan)}
59
+ .node b{display:block;font-size:13px;line-height:1.3;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
60
+ .node .meta{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
61
+ .progress{height:4px;border-radius:99px;background:var(--line);margin-top:6px;overflow:hidden}
62
+ .progress i{display:block;height:100%;background:var(--blue);background-image:linear-gradient(45deg,rgba(255,255,255,.28) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.28) 50%,rgba(255,255,255,.28) 75%,transparent 75%);background-size:12px 12px;animation:flow 1s linear infinite}
63
+ .progress i.blocked{background-color:var(--red);animation-play-state:paused;opacity:.65}
64
+ .progress i.review{background-color:var(--amber)}
65
+ .progress i.done,.progress i.accepted{background-color:var(--muted);background-image:none;animation:none}
66
+ .progress i.todo{background-color:var(--muted);background-image:none;animation:none}
67
+ @keyframes flow{to{background-position:12px 0}}
68
+ .st{font-weight:500}
69
+ .st.doing{color:var(--ink)} .st.blocked{color:var(--red)} .st.review{color:var(--amber)}
70
+ .st.done,.st.accepted{color:var(--muted)} .st.todo{color:var(--muted)}
71
+ .prio{float:right;font-size:10px;font-weight:600;border-radius:5px;padding:0 6px;color:#fff;margin-left:4px}
72
+ .prio{background:transparent;border:1px solid var(--line)}
73
+ .prio.p0{background:var(--red);border-color:var(--red);color:#fff}
74
+ .prio.p1{border-color:var(--amber);color:var(--amber)}
75
+ .prio.p2{border-color:var(--line);color:var(--muted)}
76
+ .pgroup{position:absolute;left:0;pointer-events:none;border-bottom:1px dashed var(--line)}
77
+ .plane-label{position:absolute;left:20px;width:78px;z-index:6;pointer-events:none;padding:2px 6px 2px 0;background:linear-gradient(90deg,var(--panel) 72%,transparent);transform:translateY(-50%);font-size:13px;font-weight:700;letter-spacing:.1em}
78
+ .plane-label small{display:block;font-size:9px;font-weight:400;letter-spacing:0;opacity:.7;margin-top:2px}
79
+ .legend-overlay{position:absolute;right:12px;bottom:12px;z-index:5;background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:10px;padding:8px 10px;box-shadow:var(--shadow);font-size:11px;color:var(--muted);display:grid;gap:5px;min-width:190px}
80
+ .legend-overlay .row{display:flex;align-items:center;gap:8px;white-space:nowrap}
81
+ .legend-overlay .sample{width:38px;height:10px;flex:0 0 38px}
82
+ .legend-overlay b{color:var(--ink);font-weight:600}
83
+ .canvas.simple .legend-overlay .detail-row{display:none}
84
+ aside{display:flex;flex-direction:column;gap:14px}
85
+ .side-title{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--line)}
86
+ .side-title h2{font-size:13px;margin:0}
87
+ .side-title span{font-size:11px;color:var(--muted)}
88
+ .list{padding:6px 14px 12px}
89
+ .item{display:flex;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px dashed var(--line);font-size:12px}
90
+ .item:last-child{border-bottom:0}
91
+ .item .k{color:var(--muted)}
92
+ .attention{padding:8px 14px 14px}
93
+ .attention .row{display:flex;gap:8px;align-items:flex-start;padding:8px 0;border-bottom:1px dashed var(--line);font-size:12px}
94
+ .attention .row:last-child{border-bottom:0}
95
+ .tag{font-size:10px;border-radius:6px;padding:1px 6px;background:rgba(220,38,38,.12);color:var(--red);white-space:nowrap}
96
+ .tag.amber{background:rgba(217,119,6,.12);color:var(--amber)}
97
+ .attention .row{cursor:pointer}
98
+ .quick{display:flex;gap:6px;margin-top:6px}
99
+ .quick button,.btn{border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:7px;padding:3px 8px;font:inherit;font-size:11px;cursor:pointer}
100
+ .quick button.primary,.btn.primary{background:var(--blue);border-color:var(--blue);color:#fff}
101
+ .backdrop{position:fixed;inset:0;background:rgba(0,0,0,.35);opacity:0;pointer-events:none;transition:opacity .18s;z-index:40}
102
+ .backdrop.show{opacity:1;pointer-events:auto}
103
+ .drawer{position:fixed;top:0;right:0;bottom:0;width:440px;max-width:92vw;background:var(--panel);border-left:1px solid var(--line);box-shadow:-12px 0 40px rgba(0,0,0,.25);transform:translateX(100%);transition:transform .2s ease;z-index:50;display:flex;flex-direction:column}
104
+ .drawer.show{transform:none}
105
+ .drawer-head{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
106
+ .drawer-head h3{font-size:15px;margin:0 0 3px}
107
+ .drawer-head .meta{color:var(--muted);font-size:11px}
108
+ .drawer-head .close{margin-left:auto;border:0;background:transparent;color:var(--muted);font-size:18px;cursor:pointer}
109
+ .drawer-tabs{display:flex;gap:6px;padding:10px 16px;border-bottom:1px solid var(--line)}
110
+ .drawer-tabs button{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:7px;padding:4px 10px;font:inherit;font-size:12px;cursor:pointer}
111
+ .drawer-tabs button.on{background:var(--blue);border-color:var(--blue);color:#fff}
112
+ .drawer-body{padding:14px 16px 24px;overflow:auto}
113
+ .drawer-body h4{font-size:12px;margin:14px 0 6px;color:var(--muted);letter-spacing:.05em;text-transform:uppercase}
114
+ .kv{display:grid;grid-template-columns:70px 1fr;gap:4px 10px;font-size:12px}
115
+ .kv .k{color:var(--muted)}
116
+ .iv-seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;margin-bottom:12px}
117
+ .iv-seg button{flex:1;border:0;background:transparent;color:var(--muted);padding:7px 8px;font:inherit;font-size:12px;cursor:pointer}
118
+ .iv-seg button.on{background:var(--blue);color:#fff}
119
+ .iv-row{display:grid;gap:6px;margin-bottom:10px}
120
+ .iv-row label{font-size:11px;color:var(--muted)}
121
+ .iv-row select,.iv-row textarea{width:100%;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);padding:8px;font:inherit;font-size:12px}
122
+ .iv-row textarea{min-height:96px;resize:vertical}
123
+ .iv-status{color:var(--green);font-size:11px;margin-left:8px}
124
+ .rel-list .item{display:flex;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line);font-size:12px}
125
+ .rel-list .item:last-child{border-bottom:0}
126
+ .rel-list .k{color:var(--muted)}
127
+ .kv{display:grid;grid-template-columns:70px 1fr;gap:4px 10px;font-size:12px}
128
+ .kv .k{color:var(--muted)}
129
+ /* timeline */
130
+ .timeline{padding:14px 16px 18px;overflow:hidden;position:relative}
131
+ .tl-grid{display:grid;grid-template-columns:190px minmax(0,1fr);gap:8px;align-items:start}
132
+ .tl-labels{overflow:hidden;position:relative}
133
+ .tl-label-head{height:34px;display:flex;align-items:flex-end;font-size:11px;color:var(--muted);padding-bottom:6px}
134
+ .tl-labels-body{will-change:transform}
135
+ .tl-label-row{height:38px;box-sizing:border-box;padding:6px 8px 4px 0;border-top:1px dashed var(--line);font-size:12px;overflow:hidden}
136
+ .tl-label-row b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
137
+ .tl-label-row span{color:var(--muted);font-size:10px}
138
+ .tl-scroll{overflow:auto;max-height:620px;scrollbar-width:thin}
139
+ .scroll-hint{position:absolute;right:18px;top:10px;z-index:6;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 9px;font-size:10px;color:var(--muted);box-shadow:var(--shadow)}
140
+ .tl-head{display:grid;grid-template-columns:var(--tlw,900px);gap:8px;align-items:end;color:var(--muted);font-size:11px;margin-bottom:8px;position:sticky;top:0;z-index:4;background:var(--panel)}
141
+ .tl-axis{position:relative;height:34px;width:var(--tlw,900px)}
142
+ .tl-axis span{position:absolute;transform:translateX(-50%);white-space:nowrap;line-height:1.2}
143
+ .tl-axis span small{display:block;font-size:9px;opacity:.55}
144
+ .tl-body{position:relative}
145
+ .tl-row{display:grid;grid-template-columns:var(--tlw,900px);gap:8px;align-items:start;min-height:38px;border-top:1px dashed var(--line)}
146
+ .tl-track{position:relative;height:28px;overflow:hidden;width:var(--tlw,900px);margin-top:4px}
147
+ .tl-bar{position:absolute;top:7px;height:14px;border-radius:5px;background:var(--blue);opacity:.9;cursor:pointer}
148
+ .tl-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:5px;background:rgba(255,255,255,.45)}
149
+ .tl-bar.blocked{background:var(--red)} .tl-bar.done,.tl-bar.accepted,.tl-bar.todo{background:var(--muted)}
150
+ .tl-bar.review{background:var(--amber)}
151
+ .tl-bar.critical{outline:2px solid var(--amber);outline-offset:2px}
152
+ .tl-today{position:absolute;top:0;bottom:0;pointer-events:none;z-index:2;border-left:1px dashed var(--red);color:var(--red);font-size:10px;padding-left:4px}
153
+ .tl-caption{color:var(--muted);font-size:12px;padding:10px 0 2px}
154
+ footer{padding:0 22px 22px;color:var(--muted);font-size:11px}
155
+ @media (max-width:980px){ main{grid-template-columns:1fr} .canvas{height:560px} }
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <header>
160
+ <h1>dia graph</h1>
161
+ <span class="sub">只读依赖图 + 时间线 · 视觉设计稿 v0.3</span>
162
+ <div class="tabs" id="tabs">
163
+ <button class="on" data-v="graph">关系图</button>
164
+ <button data-v="time">时间线</button>
165
+ </div>
166
+ <span class="spacer"></span>
167
+ <span class="pill">静态 mockup · 无网络</span>
168
+ <span class="pill" id="route">#/graph</span>
169
+ <span class="pill" id="updated">updated 18:42</span>
170
+ </header>
171
+
172
+ <main>
173
+ <section class="panel">
174
+ <div class="toolbar">
175
+ <div id="graph-tools" style="display:flex;gap:8px;align-items:center">
176
+ <div class="seg" id="layout">
177
+ <button class="on" data-l="priority">优先级视图</button>
178
+ <button data-l="dep">依赖视图</button>
179
+ </div>
180
+ <div class="seg" id="mode">
181
+ <button class="on" data-m="simple">简约</button>
182
+ <button data-m="detail">详细</button>
183
+ </div>
184
+ <div class="search">
185
+ <input id="q" type="search" placeholder="搜索 ID / 名称 / owner" autocomplete="off">
186
+ <div class="q-list" id="q-list"></div>
187
+ </div>
188
+ <span class="focus-chip" id="focus-chip"><span id="focus-text"></span><select id="focus-fd" class="fd"><option value="1">1 层</option><option value="2" selected>2 层</option><option value="all">全链路</option></select><button id="focus-exit">退出</button></span>
189
+ </div>
190
+ <div id="time-tools" style="display:none;gap:8px;align-items:center">
191
+ <div class="seg" id="zoom">
192
+ <button data-z="-">-</button>
193
+ <button data-z="reset">100%</button>
194
+ <button data-z="+">+</button>
195
+ </div>
196
+ </div>
197
+ <select class="win-select" id="win" title="按任务开始时间过滤,结束时间不限(两个视图共用)">
198
+ <option value="7">近 7 天</option>
199
+ <option value="14">近 14 天</option>
200
+ <option value="30">近 30 天</option>
201
+ <option value="0">全部时间</option>
202
+ </select>
203
+ <span class="hint" id="hint">按优先级分行(P0 最高);行内从左到右 = 依赖顺序。简约只看任务和依赖,详细展开具体关联。</span>
204
+ </div>
205
+ <div id="view-graph">
206
+ <div class="canvas" id="canvas">
207
+ <div class="canvas-scroll" id="canvas-scroll">
208
+ <svg class="edges" id="edges"></svg>
209
+ </div>
210
+ <div class="lane-rail" id="lane-rail"></div>
211
+ <div class="legend-overlay" id="legend">
212
+ <div class="row"><svg class="sample" viewBox="0 0 38 10"><defs><marker id="lg-dep" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#2563eb"/></marker></defs><line x1="1" y1="5" x2="30" y2="5" stroke="#2563eb" stroke-width="1.6" marker-end="url(#lg-dep)"/></svg><span><b>→ depends_on</b> 单向</span></div>
213
+ <div class="row detail-row"><svg class="sample" viewBox="0 0 38 10"><defs><marker id="lg-touch" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#7c3aed"/></marker></defs><line x1="1" y1="5" x2="30" y2="5" stroke="#7c3aed" stroke-width="1.6" stroke-dasharray="6 4" marker-end="url(#lg-touch)"/></svg><span><b>→ touches</b> 单向</span></div>
214
+ <div class="row detail-row"><svg class="sample" viewBox="0 0 38 10"><defs><marker id="lg-claim" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#0891b2"/></marker></defs><line x1="1" y1="5" x2="30" y2="5" stroke="#0891b2" stroke-width="1.6" stroke-dasharray="2 3" marker-end="url(#lg-claim)"/></svg><span><b>→ claims</b> 单向</span></div>
215
+ <div class="row detail-row"><svg class="sample" viewBox="0 0 38 10"><defs><marker id="lg-collab" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6" fill="#64748b"/></marker></defs><line x1="3" y1="5" x2="32" y2="5" stroke="#64748b" stroke-width="1.6" stroke-dasharray="1 3" marker-start="url(#lg-collab)" marker-end="url(#lg-collab)"/></svg><span><b>↔ collaborates_with</b> 双向(示例)</span></div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ <div id="view-time" style="display:none">
220
+ <div class="timeline">
221
+ <div class="scroll-hint">← 左右滚动 →</div>
222
+ <div class="tl-grid">
223
+ <div class="tl-labels">
224
+ <div class="tl-label-head">任务</div>
225
+ <div class="tl-labels-body" id="tl-labels-body"></div>
226
+ </div>
227
+ <div class="tl-scroll" id="tl-scroll">
228
+ <div class="tl-head"><div class="tl-axis" id="tl-axis"></div></div>
229
+ <div class="tl-body" id="tl-body"></div>
230
+ </div>
231
+ </div>
232
+ <div class="tl-caption">橙色描边 = 关键路径(数据根迁移 → install.sh 拆分 → launchd);红色虚线 = 今天。</div>
233
+ </div>
234
+ </div>
235
+ </section>
236
+
237
+ <aside>
238
+ <div class="panel">
239
+ <div class="side-title"><h2>概览</h2><span>live data</span></div>
240
+ <div class="list">
241
+ <div class="item"><span class="k"><i class="dot" style="background:var(--muted)"></i>任务</span><b id="stat-tasks">100</b></div>
242
+ <div class="item"><span class="k"><i class="dot" style="background:var(--red)"></i>阻塞</span><b id="stat-blocked">0</b></div>
243
+ <div class="item"><span class="k"><i class="dot" style="background:var(--amber)"></i>待评审</span><b id="stat-review">0</b></div>
244
+ <div class="item"><span class="k"><i class="dot" style="background:var(--muted)"></i>完成</span><b id="stat-done">0</b></div>
245
+ <div class="item"><span class="k">claims</span><b>3</b></div>
246
+ </div>
247
+ </div>
248
+ <div class="panel">
249
+ <div class="side-title"><h2>需要人</h2><span>attention</span></div>
250
+ <div class="attention">
251
+ <div class="row" data-task="t-3"><span class="tag">阻塞</span><div><b>launchd 安装脚本</b><br><span class="k">等待 install.sh 拆分</span><div class="quick"><button class="primary" onclick="event.stopPropagation();openDrawer('t-3','intervene')">介入</button><button onclick="event.stopPropagation();openDrawer('t-3','task')">查看</button></div></div></div>
252
+ <div class="row" data-task="t-4"><span class="tag amber">claim</span><div><b>src/collab.ts</b><br><span class="k">session-a 持有中</span><div class="quick"><button class="primary" onclick="event.stopPropagation();openDrawer('t-4','intervene')">联系持有者</button></div></div></div>
253
+ <div class="row" data-task="t-4"><span class="tag amber">评审</span><div><b>graph TUI</b><br><span class="k">等待独立 verifier</span><div class="quick"><button class="primary" onclick="event.stopPropagation();openDrawer('t-4','intervene')">开始评审</button></div></div></div>
254
+ </div>
255
+ </div>
256
+ </aside>
257
+ </main>
258
+ <div class="backdrop" id="backdrop" onclick="closeDrawer()"></div>
259
+ <aside class="drawer" id="drawer" aria-hidden="true">
260
+ <div class="drawer-head">
261
+ <div><h3 id="drawer-title">任务详情</h3><div class="meta" id="drawer-sub"></div></div>
262
+ <button class="close" onclick="closeDrawer()">×</button>
263
+ </div>
264
+ <div class="drawer-tabs">
265
+ <button data-dtab="task" onclick="setDrawerTab('task')">任务</button>
266
+ <button data-dtab="intervene" onclick="setDrawerTab('intervene')">介入</button>
267
+ </div>
268
+ <div class="drawer-body" id="drawer-body"></div>
269
+ </aside>
270
+ <footer>颜色只表达警告/异常:红=阻塞/P0,橙=待评审/P1;其余状态用中性灰 + 单一蓝色进度。关系图按依赖深度分层,时间线独立展示执行情况。</footer>
271
+
272
+ <script>
273
+ const paths = ['install.sh','src/collab.ts','docs/msg.md','tests/collab.test.ts','kits/dia-msg'];
274
+ const modules = ['collab','graph','claim','release','docs','review','persona','kit'];
275
+ const statusList = ['todo','doing','review','blocked','done','accepted'];
276
+ const tasks = [];
277
+ for (let i=0;i<100;i++){
278
+ const prio = i<6?'P0':i<24?'P1':i<62?'P2':'P3';
279
+ const status = statusList[(i*7)%statusList.length];
280
+ const progress = status==='done'||status==='accepted'?100:status==='todo'?0:(i*13)%95;
281
+ const startD = -15 + ((i*7)%24);
282
+ const endD = Math.min(15, startD + 1 + ((i*3)%8));
283
+ const id = 't-'+String(i+1).padStart(3,'0');
284
+ const deps = i===0 ? [] : ['t-'+String(i-((i%5)+1)).padStart(3,'0')];
285
+ tasks.push({id, label:modules[i%modules.length]+' '+String(i+1).padStart(3,'0'), status, prio, progress, start:startD, end:endD, owner:i%3===0?null:(i%2?'ses_a':'ses_b'), deps});
286
+ }
287
+ const pad=n=>'t-'+String(n+1).padStart(3,'0');
288
+ [[6,1],[7,2],[8,3],[24,7],[25,8],[62,25],[63,26]].forEach(([i,j])=>{ if(tasks[i]&&!tasks[i].deps.includes(pad(j))) tasks[i].deps.push(pad(j)); });
289
+ const taskEdges = [];
290
+ tasks.forEach(t=>t.deps.forEach(d=>taskEdges.push({from:t.id,to:d,kind:'depends_on'})));
291
+ for(let i=0;i<20;i++) taskEdges.push({from:tasks[i*5].id,to:paths[i%paths.length],kind:'touches'});
292
+ for(let i=0;i<12;i++) taskEdges.push({from:i%2?'ses_a':'ses_b',to:paths[i%paths.length],kind:'claims'});
293
+ taskEdges.push({from:'t-001',to:'t-002',kind:'collaborates_with'});
294
+
295
+ const state={view:'graph', layout:'priority', mode:'simple', range:'30d', zoom:28, window:7, q:'', focus:null, fd:2, from:null, to:null};
296
+ const canvas=document.getElementById('canvas'), layer=document.getElementById('canvas-scroll'), rail=document.getElementById('lane-rail'), svg=document.getElementById('edges');
297
+ const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
298
+ const stIcon={doing:'▶',blocked:'⏸',review:'◐',done:'✓',accepted:'✓',todo:'○'};
299
+
300
+ function focusSet(id,depth){
301
+ const by=new Map(tasks.map(t=>[t.id,t]));
302
+ const max=depth==='all'?Infinity:Math.max(1,Number(depth)||2);
303
+ const out=new Set([id]); let frontier=[id];
304
+ for(let step=0; step<max && frontier.length; step++){
305
+ const next=[];
306
+ frontier.forEach(cur=>{
307
+ const t=by.get(cur); if(!t) return;
308
+ (t.deps||[]).forEach(x=>{ if(by.has(x)&&!out.has(x)){ out.add(x); next.push(x); } });
309
+ tasks.forEach(o=>{ if((o.deps||[]).includes(cur)&&!out.has(o.id)){ out.add(o.id); next.push(o.id); } });
310
+ });
311
+ frontier=next;
312
+ }
313
+ return out;
314
+ }
315
+ function visibleTasks(){
316
+ if(state.focus){ const keep=focusSet(state.focus,state.fd); return tasks.filter(t=>keep.has(t.id)); }
317
+ return tasks.filter(t=>state.window<=0 || t.start>=-state.window);
318
+ }
319
+ function searchMatches(){
320
+ const q=(state.q||'').trim().toLowerCase();
321
+ if(!q) return null;
322
+ return tasks.filter(t=>t.id.toLowerCase().includes(q)||t.label.toLowerCase().includes(q)||String(t.owner||'').toLowerCase().includes(q));
323
+ }
324
+ function syncStats(){
325
+ const list=visibleTasks(), st=id=>document.getElementById(id);
326
+ if(!st('stat-tasks')) return;
327
+ st('stat-tasks').textContent = list.length===tasks.length ? String(tasks.length) : list.length+' / '+tasks.length;
328
+ st('stat-blocked').textContent=String(list.filter(t=>t.status==='blocked').length);
329
+ st('stat-review').textContent=String(list.filter(t=>t.status==='review').length);
330
+ st('stat-done').textContent=String(list.filter(t=>t.status==='done'||t.status==='accepted').length);
331
+ }
332
+ function updateHint(){
333
+ const h=document.getElementById('hint'); if(!h||state.view!=='graph') return;
334
+ const n=visibleTasks().length;
335
+ if(state.focus){
336
+ h.textContent='显示 '+n+' 个任务 · '+(state.fd==='all'?'全链路':state.fd+' 层');
337
+ return;
338
+ }
339
+ h.textContent='显示 '+n+' / '+tasks.length+' 个任务 · '+(state.layout==='dep'?'从左到右 = 依赖深度':'按优先级分行(P0 最高)');
340
+ }
341
+ function depths(){
342
+ const list=visibleTasks();
343
+ const by=new Map(list.map(t=>[t.id,t])), memo=new Map();
344
+ const depth=t=>{
345
+ if(!t) return 0;
346
+ if(memo.has(t.id)) return memo.get(t.id);
347
+ memo.set(t.id,0);
348
+ const d=t.deps.length?Math.max(...t.deps.map(x=>{ const dt=by.get(x); return dt?depth(dt):-1; }))+1:0;
349
+ memo.set(t.id,d); return d;
350
+ };
351
+ list.forEach(depth); return memo;
352
+ }
353
+ function levels(){
354
+ return [...new Set(visibleTasks().map(t=>t.prio))].sort((a,b)=>{
355
+ const na=parseInt(String(a).replace(/^P/i,''),10), nb=parseInt(String(b).replace(/^P/i,''),10);
356
+ return (Number.isFinite(na)?na:99)-(Number.isFinite(nb)?nb:99)||String(a).localeCompare(String(b));
357
+ });
358
+ }
359
+ function rowsFor(){
360
+ const list=visibleTasks(), d=depths();
361
+ const gap=state.mode==='detail'?230:145;
362
+ const h=state.mode==='detail'?205:110;
363
+ return levels().map((prio,i)=>{
364
+ const rowTasks=list.filter(t=>t.prio===prio).sort((a,b)=>(d.get(a.id)-d.get(b.id))||a.label.localeCompare(b.label));
365
+ return {prio,y:36+i*gap,h,tasks:rowTasks};
366
+ }).filter(r=>r.tasks.length);
367
+ }
368
+ let cardMetrics={mid:52,h:58}, clickTimer=null;
369
+ function measureCard(){
370
+ const probe=card(tasks[0],-9999,-9999);
371
+ probe.style.visibility='hidden';
372
+ layer.appendChild(probe);
373
+ const prog=probe.querySelector('.progress');
374
+ const mid=prog?prog.offsetTop+prog.offsetHeight/2+probe.clientTop:44;
375
+ const h=probe.offsetHeight||58;
376
+ probe.remove();
377
+ return {mid,h};
378
+ }
379
+ function bandCenter(r, bandH){ return r.y-12+bandH/2; }
380
+ function cardTop(r){
381
+ const bandH=state.mode==='detail'?230:145;
382
+ const center=bandCenter(r,bandH);
383
+ // 详细:进度条中心线对齐泳道中心;简约:卡片盒整体垂直居中
384
+ return state.mode==='detail'?center-cardMetrics.mid:center-cardMetrics.h/2;
385
+ }
386
+ function edgeOffset(){ return state.mode==='detail'?cardMetrics.mid:cardMetrics.h/2; }
387
+ function pos(){
388
+ const rows=rowsFor(), p=new Map();
389
+ rows.forEach(r=>r.tasks.forEach((t,i)=>p.set(t.id,{x:170+i*230,y:cardTop(r)})));
390
+ return {p,rows};
391
+ }
392
+ function card(t,x,y,showPrio=false){
393
+ const el=document.createElement('div'); el.className='node task '+t.status; el.style.left=x+'px'; el.style.top=y+'px';
394
+ const rel=taskEdges.filter(e=>e.from===t.id||e.to===t.id).map(e=>{
395
+ const outgoing=e.from===t.id, other=outgoing?e.to:e.from;
396
+ const kind=e.kind==='depends_on'?(outgoing?'depends_on':'depended_by'):e.kind;
397
+ return '<div class="meta" style="margin-top:2px">↳ '+esc(kind)+': '+esc(other)+'</div>';
398
+ }).slice(0,4).join('');
399
+ const badge=showPrio?'<span class="prio '+esc(t.prio.toLowerCase())+'">'+esc(t.prio)+'</span>':'';
400
+ el.innerHTML='<b>'+esc(t.label)+badge+'</b>'+
401
+ '<div class="meta">'+esc(t.id)+' · <span class="st '+esc(t.status)+'">'+stIcon[t.status]+' '+esc(t.status)+'</span> · '+t.progress+'%</div>'+
402
+ '<div class="progress"><i class="'+esc(t.status)+'" style="width:'+t.progress+'%"></i></div>'+
403
+ (state.mode==='detail'?'<div class="meta" style="margin-top:5px">'+(t.owner?'@'+esc(t.owner):'unassigned')+' · dep '+t.deps.length+'</div>'+rel:'');
404
+ el.dataset.id=t.id;
405
+ el.onclick=()=>{ clearTimeout(clickTimer); clickTimer=setTimeout(()=>openDrawer(t.id,'task'),180); };
406
+ el.ondblclick=e=>{ e.preventDefault(); clearTimeout(clickTimer); focusOn(t.id); };
407
+ return el;
408
+ }
409
+ function edgePath(a,b){
410
+ const o=edgeOffset(), x1=a.x+190,y1=a.y+o,x2=b.x,y2=b.y+o;
411
+ if(x2>=x1) return `M${x1} ${y1} H${x1+Math.max(24,(x2-x1)/2)} V${y2} H${x2}`;
412
+ return `M${x1} ${y1} H${x1-30} V${y2} H${x2}`;
413
+ }
414
+ function drawGraph(){
415
+ canvas.classList.toggle('simple', state.mode==='simple');
416
+ if(state.layout==='dep'){ drawDepGraph(); return; }
417
+ cardMetrics=measureCard();
418
+ const {p,rows}=pos();
419
+ [...layer.querySelectorAll('.node,.pgroup')].forEach(e=>e.remove());
420
+ rail.style.display=''; rail.querySelectorAll('.plane-label').forEach(e=>e.remove());
421
+ const maxX=Math.max(600, ...rows.map(r=>170+(r.tasks.length-1)*230+190+10));
422
+ const maxY=rows.length?Math.max(...rows.map(r=>r.y+r.h))+40:600;
423
+ const bandH=state.mode==='detail'?230:145;
424
+ rows.forEach((r,i)=>{
425
+ const g=document.createElement('div'); g.className='pgroup '+r.prio.toLowerCase();
426
+ g.style.top=(r.y-12)+'px'; g.style.width=maxX+'px'; g.style.height=bandH+'px';
427
+ const alpha=Math.max(0.02, 0.075-i*0.012);
428
+ g.style.background='color-mix(in srgb, var(--blue) '+Math.round(alpha*100)+'%, transparent)';
429
+ layer.appendChild(g);
430
+ const label=document.createElement('div'); label.className='plane-label '+r.prio.toLowerCase();
431
+ label.dataset.y=String(r.y-12+bandH/2); label.style.top=(r.y-12+bandH/2-layer.scrollTop)+'px';
432
+ label.style.color=i===0?'var(--red)':i===1?'var(--amber)':'var(--muted)';
433
+ label.innerHTML=esc(r.prio)+' · '+r.tasks.length+(i===0?'<small>紧急</small>':i===1?'<small>高</small>':'<small>常规</small>');
434
+ rail.appendChild(label);
435
+ r.tasks.forEach((t,j)=>layer.appendChild(card(t,170+j*230,cardTop(r))));
436
+ });
437
+ svg.setAttribute('width',maxX); svg.setAttribute('height',maxY);
438
+ const defs=`<defs>
439
+ <marker id="ar-dep" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7" fill="#2563eb"/></marker>
440
+ <marker id="ar-collab" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7" fill="#64748b"/></marker>
441
+ </defs>`;
442
+ const lines=taskEdges.filter(e=>e.kind==='depends_on'||e.kind==='collaborates_with').map(e=>{
443
+ const a=p.get(e.from), b=p.get(e.to); if(!a||!b) return '';
444
+ const style=e.kind==='collaborates_with'
445
+ ? 'stroke="#64748b" stroke-dasharray="1 3" marker-start="url(#ar-collab)" marker-end="url(#ar-collab)"'
446
+ : 'stroke="#2563eb" marker-end="url(#ar-dep)"';
447
+ return `<path d="${edgePath(a,b)}" fill="none" stroke-width="1.6" ${style}/>`;
448
+ }).join('');
449
+ svg.innerHTML=defs+lines;
450
+ document.getElementById('updated').textContent='updated '+new Date().toLocaleTimeString();
451
+ syncStats(); updateHint(); applySearch();
452
+ }
453
+ let drawerTask=null, drawerTab='task', ivChannel='tui';
454
+ function relations(t){
455
+ return taskEdges.filter(e=>e.from===t.id||e.to===t.id).map(e=>{
456
+ const outgoing=e.from===t.id, other=outgoing?e.to:e.from;
457
+ const kind=e.kind==='depends_on'?(outgoing?'depends_on':'depended_by'):e.kind;
458
+ return '<div class="item"><span class="k">'+esc(kind)+'</span><span>'+esc(other)+'</span></div>';
459
+ }).join('') || '<p class="k">无边</p>';
460
+ }
461
+ function taskHtml(t){
462
+ return '<div class="kv"><div class="k">status</div><div>'+esc(t.status)+'</div>'+
463
+ '<div class="k">priority</div><div>'+esc(t.prio)+'</div>'+
464
+ '<div class="k">owner</div><div>'+esc(t.owner||'-')+'</div>'+
465
+ '<div class="k">progress</div><div>'+t.progress+'%</div>'+
466
+ '<div class="k">deps</div><div>'+esc(t.deps.join(', ')||'-')+'</div></div>'+
467
+ '<h4>关联</h4><div class="rel-list">'+relations(t)+'</div>';
468
+ }
469
+ function targets(ch){
470
+ if(ch==='dingtalk') return [['group','乐语助人前端组 · 群聊'],['dm','与 session-a 私聊']];
471
+ if(ch==='msg') return [['ses_a','session-a'],['ses_b','session-b']];
472
+ return [['ses_a','session-a · opencode TUI (online)'],['ses_b','session-b · dsh TUI (online)']];
473
+ }
474
+ function interveneHtml(t){
475
+ const chs=[['tui','TUI 会话'],['dingtalk','钉钉消息链'],['msg','dia msg']];
476
+ const opts=targets(ivChannel).map(([v,l])=>'<option value="'+v+'">'+esc(l)+'</option>').join('');
477
+ return '<div class="iv-seg">'+chs.map(([v,l])=>'<button class="'+(ivChannel===v?'on':'')+'" onclick="setChannel(\''+v+'\')">'+l+'</button>').join('')+'</div>'+
478
+ '<div class="iv-row"><label>目标会话 / 消息链</label><select id="iv-target">'+opts+'</select></div>'+
479
+ '<div class="iv-row"><label>介入消息</label><textarea id="iv-text" placeholder="例如:这个任务卡在什么点?需要我做什么?"></textarea></div>'+
480
+ '<div><button class="btn primary" onclick="sendIntervention()">发送介入</button><span class="iv-status" id="iv-status"></span></div>'+
481
+ '<p class="k" style="margin-top:12px">设计稿:发送仅演示。真实接入分别走 TUI 注入 / 钉钉消息链 / dia msg;同一任务可切换通道。</p>';
482
+ }
483
+ function openDrawer(id, tab){
484
+ drawerTask=tasks.find(t=>t.id===id); if(!drawerTask) return;
485
+ drawerTab=tab||'task';
486
+ document.getElementById('drawer-title').textContent='任务详情';
487
+ document.getElementById('drawer-sub').textContent=drawerTask.label+' · '+drawerTask.id+' · '+drawerTask.prio+' · '+drawerTask.status;
488
+ document.getElementById('drawer').classList.add('show');
489
+ document.getElementById('drawer').setAttribute('aria-hidden','false');
490
+ document.getElementById('backdrop').classList.add('show');
491
+ setDrawerTab(drawerTab);
492
+ }
493
+ function setDrawerTab(tab){
494
+ drawerTab=tab;
495
+ document.querySelectorAll('[data-dtab]').forEach(b=>b.classList.toggle('on',b.dataset.dtab===tab));
496
+ const body=document.getElementById('drawer-body');
497
+ body.innerHTML=tab==='intervene'?interveneHtml(drawerTask):taskHtml(drawerTask);
498
+ }
499
+ function setChannel(ch){ ivChannel=ch; setDrawerTab('intervene'); }
500
+ function sendIntervention(){
501
+ const text=(document.getElementById('iv-text')||{}).value||'';
502
+ if(!text.trim()){ document.getElementById('iv-status').textContent='请先输入内容'; return; }
503
+ document.getElementById('iv-status').textContent='已排队(设计稿)';
504
+ }
505
+ function closeDrawer(){
506
+ document.getElementById('drawer').classList.remove('show');
507
+ document.getElementById('drawer').setAttribute('aria-hidden','true');
508
+ document.getElementById('backdrop').classList.remove('show');
509
+ }
510
+ function dayDate(d){
511
+ const base=new Date('2026-11-06T00:00:00');
512
+ const dt=new Date(base.getTime()+d*86400000);
513
+ return ('0'+(dt.getMonth()+1)).slice(-2)+'-'+('0'+dt.getDate()).slice(-2);
514
+ }
515
+ function isoDate(d){ const base=new Date('2026-11-06T00:00:00'); const dt=new Date(base.getTime()+d*86400000); return dt.toISOString().slice(0,10); }
516
+ function timelineWindow(){
517
+ if(state.from!==null&&state.to!==null) return {from:state.from,to:state.to};
518
+ const list=visibleTasks();
519
+ const w=state.window>0?state.window:14;
520
+ const from=-w;
521
+ const maxEnd=list.length?Math.max(...list.map(t=>t.end)):w;
522
+ return {from,to:Math.max(0,maxEnd)+1};
523
+ }
524
+ function drawTimeline(){
525
+ const win=timelineWindow(), span=Math.max(1,win.to-win.from);
526
+ const x=v=>((v-win.from)/span)*100;
527
+ const chartW=Math.max(640, span*state.zoom);
528
+ document.getElementById('view-time').style.setProperty('--tlw', chartW+'px');
529
+ const axis=document.getElementById('tl-axis'); axis.innerHTML='';
530
+ const step=span<=10?1:span<=18?2:3;
531
+ for(let d=win.from;d<=win.to;d+=step){
532
+ const el=document.createElement('span'); el.style.left=x(d)+'%';
533
+ el.innerHTML=dayDate(d)+'<small>'+(d===0?'今天':(d>0?'+'+d+'d':d+'d'))+'</small>';
534
+ if(d===win.from) el.style.transform='translateX(0)'; else if(d+step>win.to) el.style.transform='translateX(-100%)';
535
+ if(d===0)el.style.color='var(--red)'; axis.appendChild(el);
536
+ }
537
+ const body=document.getElementById('tl-body'); body.innerHTML=''; const labelsBody=document.getElementById('tl-labels-body'); labelsBody.innerHTML=''; const critical=new Set(['t-1','t-2','t-3']);
538
+ visibleTasks().slice().sort((a,b)=>a.start-b.start).forEach(t=>{
539
+ const row=document.createElement('div'); row.className='tl-row';
540
+ row.innerHTML='<div class="tl-track"></div>';
541
+ const label=document.createElement('div'); label.className='tl-label-row'; label.innerHTML='<b>'+esc(t.label)+'</b><span>'+esc(t.id)+' · '+esc(t.prio)+' · '+esc(t.status)+'</span>'; labelsBody.appendChild(label);
542
+ const track=row.querySelector('.tl-track');
543
+ const bar=document.createElement('div'); bar.className='tl-bar '+t.status+(critical.has(t.id)?' critical':'');
544
+ bar.style.left=x(t.start)+'%'; bar.style.width=Math.max(1.5,x(t.end)-x(t.start))+'%';
545
+ bar.title=dayDate(t.start)+' → '+dayDate(t.end)+' ('+t.progress+'%)';
546
+ bar.innerHTML='<div class="fill" style="width:'+t.progress+'%"></div>'; bar.onclick=()=>openDrawer(t.id,'task'); track.appendChild(bar);
547
+ if(win.from<=0&&win.to>=0){ const line=document.createElement('div'); line.className='tl-today'; line.style.left=x(0)+'%'; line.style.top='0'; line.style.bottom='0'; line.textContent=''; track.appendChild(line); }
548
+ body.appendChild(row);
549
+ });
550
+ syncStats();
551
+ }
552
+ function drawDepGraph(){
553
+ cardMetrics=measureCard();
554
+ const list=visibleTasks(), d=depths(), groups=new Map();
555
+ list.forEach(t=>{ const k=d.get(t.id)||0; groups.set(k,[...(groups.get(k)||[]),t]); });
556
+ const p=new Map(); const rows=[];
557
+ const gap=14;
558
+ [...groups.keys()].sort((a,b)=>a-b).forEach((depth,ci)=>{
559
+ const all=groups.get(depth).sort((a,b)=>a.prio.localeCompare(b.prio)||a.label.localeCompare(b.label));
560
+ rows.push({depth,all,x:30+ci*230});
561
+ });
562
+ [...layer.querySelectorAll('.node,.pgroup')].forEach(e=>e.remove());
563
+ rail.innerHTML=''; rail.style.display='none';
564
+ let maxY=0;
565
+ // 先渲染卡片,再按实测高度逐列堆叠,避免详细模式卡片相互重叠
566
+ rows.forEach(r=>{
567
+ let y=60;
568
+ r.all.forEach(t=>{
569
+ const el=card(t,r.x,y,true);
570
+ el.style.top=y+'px';
571
+ layer.appendChild(el);
572
+ p.set(t.id,{x:r.x,y});
573
+ y+=el.offsetHeight+gap;
574
+ });
575
+ maxY=Math.max(maxY,y);
576
+ });
577
+ maxY=Math.max(600,maxY);
578
+ const maxX=Math.max(600,...rows.map(r=>r.x+200+10));
579
+ svg.setAttribute('width',maxX); svg.setAttribute('height',maxY+40);
580
+ const defs=`<defs>
581
+ <marker id="ar-dep" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7" fill="#2563eb"/></marker>
582
+ <marker id="ar-collab" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7" fill="#64748b"/></marker>
583
+ </defs>`;
584
+ const lines=taskEdges.filter(e=>e.kind==='depends_on'||e.kind==='collaborates_with').map(e=>{
585
+ const a=p.get(e.from), b=p.get(e.to); if(!a||!b) return '';
586
+ const style=e.kind==='collaborates_with'?'stroke="#64748b" stroke-dasharray="1 3" marker-start="url(#ar-collab)" marker-end="url(#ar-collab)"':'stroke="#2563eb" marker-end="url(#ar-dep)"';
587
+ return `<path d="${edgePath(a,b)}" fill="none" stroke-width="1.6" ${style}/>`;
588
+ }).join('');
589
+ svg.innerHTML=defs+lines;
590
+ document.getElementById('updated').textContent='updated '+new Date().toLocaleTimeString();
591
+ syncStats(); updateHint(); applySearch();
592
+ }
593
+ function syncLaneLabels(){
594
+ rail.querySelectorAll('.plane-label').forEach(el=>{
595
+ el.style.top=(Number(el.dataset.y||0)-layer.scrollTop)+'px';
596
+ });
597
+ }
598
+ function applySearch(){
599
+ const ms=searchMatches();
600
+ layer.querySelectorAll('.node.task').forEach(el=>{
601
+ el.classList.remove('hit','dim');
602
+ if(!ms) return;
603
+ if(ms.some(t=>t.id===el.dataset.id)) el.classList.add('hit'); else el.classList.add('dim');
604
+ });
605
+ const box=document.getElementById('q-list');
606
+ if(box && box.classList.contains('open')) renderSearchList(ms);
607
+ }
608
+ function renderSearchList(ms){
609
+ const box=document.getElementById('q-list'); if(!box) return;
610
+ const q=(state.q||'').trim();
611
+ const browse=!q;
612
+ const pool=browse?visibleTasks().slice().sort((a,b)=>a.prio.localeCompare(b.prio)||a.start-b.start):ms;
613
+ if(!pool||!pool.length){
614
+ box.innerHTML='<div class="q-empty">'+(browse?'当前时间窗内没有任务':'没有匹配的任务')+'</div>';
615
+ box.classList.add('open'); return;
616
+ }
617
+ const head=browse?('<div class="q-head">可见 '+pool.length+' 个 · ↑↓ 选择,Enter 聚焦</div>')
618
+ :('<div class="q-head">匹配 '+pool.length+' 个 · ↑↓ 选择,Enter 聚焦</div>');
619
+ box.innerHTML=head+pool.slice(0,20).map(t=>'<div class="q-item" data-id="'+esc(t.id)+'"><span>'+esc(t.label)+'<i class="q-prio">'+esc(t.prio)+'</i></span><span class="k">'+esc(t.id)+'</span></div>').join('');
620
+ box.classList.add('open');
621
+ box.querySelectorAll('.q-item').forEach(el=>el.onclick=()=>focusOn(el.dataset.id));
622
+ }
623
+ function searchListItems(){
624
+ const box=document.getElementById('q-list');
625
+ return box?[...box.querySelectorAll('.q-item')]:[];
626
+ }
627
+ function moveSearchCursor(delta){
628
+ let items=searchListItems();
629
+ if(!items.length){ renderSearchList(searchMatches()); items=searchListItems(); if(!items.length) return; }
630
+ let idx=items.findIndex(el=>el.classList.contains('active'));
631
+ idx=idx<0?0:(idx+delta+items.length)%items.length;
632
+ items.forEach((el,i)=>el.classList.toggle('active',i===idx));
633
+ items[idx].scrollIntoView({block:'nearest'});
634
+ }
635
+ function focusOn(id){
636
+ state.focus=id; state.fd=state.fd||2; state.q='';
637
+ const qi=document.getElementById('q'); if(qi) qi.value='';
638
+ go(state.view,state.mode,state.range,state.zoom);
639
+ }
640
+ function readRoute(){
641
+ const raw=location.hash.replace(/^#\/?/,''); const [path,qs]=raw.split('?');
642
+ const params=new URLSearchParams(qs||location.search);
643
+ const range=(params.get('range')||'30d');
644
+ const zoom=Math.min(84,Math.max(14,parseInt(params.get('zoom')||'28',10)||28));
645
+ const sx=Number(params.get('sx')||0);
646
+ const fromRaw=params.get('from'), toRaw=params.get('to');
647
+ const toOff=v=>v?Math.round((new Date(v+'T00:00:00').getTime()-new Date('2026-11-06T00:00:00').getTime())/86400000):null;
648
+ const winRaw=params.get('w');
649
+ const rangeRaw=params.get('range');
650
+ const win=winRaw!==null?Math.max(0,parseInt(winRaw,10)||0):(rangeRaw?Math.max(0,parseInt(rangeRaw,10)||0):7);
651
+ const fdRaw=params.get('fd');
652
+ const fd=fdRaw==='all'?'all':Math.min(9,Math.max(1,parseInt(fdRaw||'2',10)||2));
653
+ return {view:(path==='timeline'||params.get('view')==='timeline')?'time':'graph', layout:params.get('layout')==='dep'?'dep':'priority', mode:params.get('mode')==='detail'?'detail':'simple', range, zoom, sx, window:win, q:params.get('q')||'', focus:params.get('focus')||null, fd, from:toOff(fromRaw), to:toOff(toRaw), task:params.get('task'), tab:params.get('tab')==='intervene'?'intervene':'task'};
654
+ }
655
+ function routeString(view,mode,range,zoom,from,to){
656
+ const p=view==='time'?'timeline':'graph'; const qs=new URLSearchParams();
657
+ if(view==='graph'&&mode==='detail') qs.set('mode','detail');
658
+ if(view==='graph'&&state.layout==='dep') qs.set('layout','dep');
659
+ if(view==='graph'){
660
+ if(state.window!==7) qs.set('w',String(state.window));
661
+ if(state.q&&state.q.trim()) qs.set('q',state.q.trim());
662
+ if(state.focus){ qs.set('focus',state.focus); if(state.fd!==2) qs.set('fd',String(state.fd)); }
663
+ }
664
+ if(view==='time'){
665
+ if(from!==null&&to!==null){ qs.set('from',isoDate(from)); qs.set('to',isoDate(to)); }
666
+ if(state.window!==7) qs.set('w',String(state.window));
667
+ if(zoom&&zoom!==28) qs.set('zoom',String(zoom));
668
+ }
669
+ return '#/'+p+(qs.toString()?'?'+qs.toString():'');
670
+ }
671
+ function applyRoute(){
672
+ const r=readRoute(); state.view=r.view; state.layout=r.layout; state.mode=r.mode; state.range=r.range; state.zoom=r.zoom; state.sx=r.sx||0; state.window=r.window; state.q=r.q; state.focus=r.focus; state.fd=r.fd; state.from=r.from; state.to=r.to;
673
+ document.querySelectorAll('#tabs button').forEach(x=>x.classList.toggle('on',x.dataset.v===r.view));
674
+ document.querySelectorAll('#mode button').forEach(x=>x.classList.toggle('on',x.dataset.m===r.mode));
675
+ document.querySelectorAll('#layout button').forEach(x=>x.classList.toggle('on',x.dataset.l===r.layout));
676
+ document.getElementById('view-graph').style.display=r.view==='graph'?'':'none';
677
+ document.getElementById('view-time').style.display=r.view==='time'?'':'none';
678
+ document.getElementById('graph-tools').style.display=r.view==='graph'?'flex':'none';
679
+ document.getElementById('time-tools').style.display=r.view==='time'?'flex':'none';
680
+ const zbtn=document.querySelector('#zoom button[data-z="reset"]'); if(zbtn) zbtn.textContent=Math.round(r.zoom/28*100)+'%';
681
+ const dec=document.querySelector('#zoom button[data-z="-"]'), inc=document.querySelector('#zoom button[data-z="+"]'); if(dec) dec.disabled=r.zoom<=14; if(inc) inc.disabled=r.zoom>=84;
682
+ if(r.view!=='graph') document.getElementById('hint').textContent='时间线:左右滚动查看;-/+缩放;橙框 = 关键路径';
683
+ const winSel=document.getElementById('win'); if(winSel) winSel.value=String(r.window);
684
+ const qIn=document.getElementById('q'); if(qIn && qIn.value!==r.q) qIn.value=r.q;
685
+ const chip=document.getElementById('focus-chip');
686
+ if(chip){ chip.classList.toggle('on',!!r.focus); if(r.focus) document.getElementById('focus-text').textContent='聚焦 '+r.focus+' ·'; }
687
+ const fdEl=document.getElementById('focus-fd'); if(fdEl) fdEl.value=String(r.fd);
688
+ const rp=document.getElementById('route'); if(rp) rp.textContent=location.hash||routeString(r.view,r.mode,r.range,state.zoom,state.from,state.to);
689
+ document.title='dia graph · '+(r.view==='time'?'时间线':'关系图');
690
+ if(r.view==='time'){ drawTimeline(); const sc=document.getElementById('tl-scroll'), lb=document.getElementById('tl-labels-body'); sc.scrollTop=0; lb.style.transform='translateY(0px)'; if(state.sx) sc.scrollLeft=state.sx; } else drawGraph();
691
+ if(r.task) openDrawer(r.task,r.tab);
692
+ syncLaneLabels();
693
+ }
694
+ function go(view,mode,range,zoom){ const h=routeString(view,mode,range||state.range,zoom||state.zoom,state.from,state.to); if(location.hash===h) applyRoute(); else location.hash=h; }
695
+ document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>go(b.dataset.v==='time'?'time':'graph',state.mode,state.range));
696
+ document.querySelectorAll('#layout button').forEach(b=>b.onclick=()=>{state.layout=b.dataset.l;go(state.view,state.mode,state.range,state.zoom);});
697
+ const winSelEl=document.getElementById('win');
698
+ if(winSelEl) winSelEl.onchange=()=>{ state.window=Number(winSelEl.value); go(state.view,state.mode,state.range,state.zoom); };
699
+ const qInput=document.getElementById('q');
700
+ if(qInput){
701
+ qInput.oninput=()=>{ state.q=qInput.value; applySearch(); renderSearchList(searchMatches()); };
702
+ qInput.onkeydown=e=>{
703
+ if(e.key==='ArrowDown'){ e.preventDefault(); moveSearchCursor(1); }
704
+ else if(e.key==='ArrowUp'){ e.preventDefault(); moveSearchCursor(-1); }
705
+ else if(e.key==='Enter'){
706
+ const items=searchListItems();
707
+ const active=items.find(el=>el.classList.contains('active'))||items[0];
708
+ if(active) focusOn(active.dataset.id);
709
+ } else if(e.key==='Escape'){ state.q=''; qInput.value=''; applySearch(); qInput.blur(); }
710
+ };
711
+ qInput.onfocus=()=>{ renderSearchList(searchMatches()); };
712
+ qInput.onclick=()=>{ renderSearchList(searchMatches()); };
713
+ qInput.onblur=()=>{ setTimeout(()=>{ const box=document.getElementById('q-list'); if(box) box.classList.remove('open'); },160); };
714
+ }
715
+ const fdSel=document.getElementById('focus-fd');
716
+ if(fdSel) fdSel.onchange=()=>{ state.fd=fdSel.value==='all'?'all':Number(fdSel.value); go(state.view,state.mode,state.range,state.zoom); };
717
+ const fxBtn=document.getElementById('focus-exit');
718
+ if(fxBtn) fxBtn.onclick=()=>{ state.focus=null; go(state.view,state.mode,state.range,state.zoom); };
719
+ document.querySelectorAll('#mode button').forEach(b=>b.onclick=()=>go(state.view,b.dataset.m,state.range,state.zoom));
720
+ document.querySelectorAll('#zoom button').forEach(b=>b.onclick=()=>{ const z=b.dataset.z==='+'?Math.min(84,state.zoom+8):b.dataset.z==='-'?Math.max(14,state.zoom-8):28; go('time',state.mode,state.range,z); });
721
+ layer.addEventListener('scroll',syncLaneLabels,{passive:true});
722
+ const tlScroller=document.getElementById('tl-scroll');
723
+ tlScroller.addEventListener('scroll',()=>{ const lb=document.getElementById('tl-labels-body'); if(lb) lb.style.transform='translateY(-'+tlScroller.scrollTop+'px)'; },{passive:true});
724
+ document.querySelectorAll('.attention .row[data-task]').forEach(r=>r.onclick=()=>openDrawer(r.dataset.task,'intervene'));
725
+ window.addEventListener('hashchange',applyRoute);
726
+ applyRoute();
727
+ if(document.fonts&&document.fonts.ready){ document.fonts.ready.then(()=>{ if(state.view==='graph'){ if(state.layout==='dep') drawDepGraph(); else drawGraph(); } }); }
728
+ </script>
729
+ </body>
730
+ </html>
@@ -0,0 +1,160 @@
1
+ # dia graph Web 视觉设计稿(v0.3)
2
+
3
+ > 静态 mockup:[`graph-web-mockup.html`](graph-web-mockup.html)(浏览器直接打开,无网络/无依赖)
4
+ > 数据契约:`dia graph --json`(schema 1);时间线所需 start/end 为后续扩展字段。
5
+
6
+ ## 1. 两个视图
7
+
8
+ 顶部 tab:
9
+
10
+ - **关系图**:回答“谁依赖谁、先后顺序、优先级”;
11
+ - **时间线**:独立甘特图,回答“什么时候做、做到哪、是否延期”。
12
+
13
+ ### 关系图:按依赖深度分层,不按状态分列
14
+
15
+ - 纵轴 = 优先级,**P0 最高、P1 中间、P2 最下**;每个优先级是一条**通栏泳道色带**,泳道之间用虚线分隔,不再用闭合方框;
16
+ - P0/P1/P2 标签固定在左侧泳道栏、垂直居中;**横向滚动时标签吸住左边缘**(JS 同步 scrollLeft),泳道底色按级别计算(P0 最深,向下递减),可自然扩展 P3/P4;
17
+ - 组内从左到右 = 依赖顺序(按依赖深度排序);
18
+ - **状态不占布局**:用内联图标/文本 + 进度条表达;
19
+ - `简约`:只显示 task + `depends_on`;
20
+ - `详细`:展开 owner、session、path、touches、claims,并在卡片里列具体关联(`depends_on / depended_by / touches / claims / collaborates_with`)。
21
+ - 图例固定在画布右下角:只显示当前模式会用到的边;`→` 单向、`↔` 双向,线型与图上一致。
22
+
23
+ ### 时间线:独立甘特
24
+
25
+ - 每行一个 task,横轴为相对今天的天数(`-8d … today … +4d`);
26
+ - 条长 = start/end,内部填充 = progress;
27
+ - 颜色 = status;橙色描边 = 关键路径;红色虚线 = 今天;
28
+ - 点击条/行可看任务详情。
29
+
30
+ ## 2. 边语义
31
+
32
+ | 关系 | 方向 | 线型 | 颜色 |
33
+ |---|---|---|---|
34
+ | `depends_on` | 单向 `→` | 实线 | 蓝 |
35
+ | `touches` | 单向 `→` | 虚线 `6 4` | 紫 |
36
+ | `claims` | 单向 `→` | 点线 `2 3` | 青 |
37
+ | `collaborates_with` | 双向 `↔` | 点线 `1 3` + 双箭头 | 灰(视觉示例,未进 M1 契约) |
38
+
39
+ ## 3. 为什么不再按状态列/行
40
+
41
+ 状态列会把“关系”切碎,且 TODO/DONE、REVIEW/BLOCKED 混在一列没有语义。现在:
42
+
43
+ - 依赖顺序是主结构(拓扑层);
44
+ - 状态是属性(颜色/进度);
45
+ - 优先级是行分组(P0/P1/P2 各一行,最高在最上,虚线框圈组);
46
+ - 时间放在独立时间线,不挤在关系图里。
47
+
48
+ ## 4. 与数据契约的映射
49
+
50
+ | UI | 数据 |
51
+ |---|---|
52
+ | task 卡片 | `nodes[kind=task]` + status/owner |
53
+ | 依赖边 | `edges[kind=depends_on]` |
54
+ | touches / claims | `edges` 对应 kind |
55
+ | 进度 | 后续 `task.progress`(0-100) |
56
+ | 时间线 | 后续 `task.start/end`(相对/ISO) |
57
+ | 关键路径 | 后续由依赖图最长路径计算 |
58
+
59
+ ## 5. 实现约束
60
+
61
+ `tests/static.test.ts` 禁止框架 import `node:http` / `fetch` / 网络模块。因此:
62
+
63
+ 1. 本稿是静态 HTML,不进入 `src/`;
64
+ 2. 真实实现优先 `dia graph --export-html` 生成静态文件;
65
+ 3. live Web 需独立可选包 + 单独评审(loopback only)。
66
+
67
+ ## 6. 后续
68
+
69
+ - 先确认“依赖深度分层 + 简约/详细 + 时间线”结构;
70
+ - 确认后做 `--export-html`(零网络),再评估 live 轮询。
71
+
72
+ ## 7. URL 路由
73
+
74
+ 静态稿支持 hash 路由(`file://` 直接可用):
75
+
76
+ ```
77
+ #/graph
78
+ #/graph?mode=simple
79
+ #/graph?mode=detail
80
+ #/timeline
81
+ ```
82
+
83
+ - `view` 也可用 query 形式:`?view=timeline&mode=detail`;
84
+ - 切换 tab / 简约详细会同步更新 hash,便于分享/回链;
85
+ - 真正的独立 path(`/graph`、`/timeline`)需要部署层 rewrite 到同一 HTML,或导出时生成
86
+ `graph.html` / `timeline.html`;静态文件本身不能凭空提供两个 path。
87
+
88
+ ## 8. 颜色语义(v0.4)
89
+
90
+ 原则:**颜色只表达警告/异常,正常状态去色**。
91
+
92
+ | 颜色 | 用途 | 不再用于 |
93
+ |---|---|---|
94
+ | 红 | blocked、P0、异常/失败 | 普通任务状态 |
95
+ | 橙 | review、claim 提醒、P1 | 完成态 |
96
+ | 蓝 | 只有“进行中”的进度条 | done/accepted/todo |
97
+ | 灰 | todo / done / accepted / P2 / 中性文本 | 警告/异常 |
98
+
99
+ - 优先级:P0 红实心;P1 橙描边;P2 灰描边(不抢眼);
100
+ - 状态文本:只有 blocked / review 带颜色,其余中性;
101
+ - 进度条:doing 蓝色条纹动画;blocked 红色暂停/置灰;review 橙色;done/accepted 灰色静态;
102
+ - 时间线同一套语义;关键路径只用橙色描边。
103
+
104
+ ## 9. 任务抽屉与介入(v0.5)
105
+
106
+ 关系图里的卡片**全部是 task**,不再叫“节点详情”。
107
+
108
+ - 点击 task 卡片 → 右侧抽屉「任务详情」,两个 tab:
109
+ - **任务**:status / priority / owner / progress / deps / 关联;
110
+ - **介入**:通道切换 + 目标会话 + 输入框 + 发送(设计稿只演示)。
111
+ - 「需要人」每行带快速操作:
112
+ - 阻塞 → `介入` / `查看`
113
+ - claim → `联系持有者`
114
+ - 待评审 → `开始评审`
115
+ - 介入通道明确区分:
116
+ 1. **TUI 会话**:选择 opencode / dsh 会话,输入后注入;
117
+ 2. **钉钉消息链**:选择群聊/私聊,输入后走钉钉;
118
+ 3. **dia msg**:选择 session id,走 dia 消息邮箱。
119
+ - 同一任务可以在抽屉里切换通道,方便“会话里没人就发钉钉 / 需要留痕就 dia msg”。
120
+ - 深链:`#/graph?task=t-4&tab=intervene` 可直接打开任务抽屉。
121
+
122
+ ## 10. 规模样例(100 tasks / 30 天)
123
+
124
+ mockup 数据改为 **100 个任务、时间跨度 30 天**,用来验证真实规模下的布局:
125
+
126
+ - 关系图有两种布局:**优先级视图**(P0/P1/P2/P3 泳道)和**依赖视图**(按依赖深度从左到右);
127
+ 两种视图**都不折叠任务**(没有 `+N 个` 聚合,任何卡片都能点开),靠时间窗 + 搜索 + 聚焦控制规模;
128
+ - mock 数据补齐跨优先级依赖,确保两种视图都能看到跨层关系;依赖边只连接当前可见任务;
129
+ - 卡片垂直基准分两种模式:**详细** = 进度条中心线(关系虚线/箭头也锚在进度条中心,同一行连线与进度条同高);
130
+ **简约** = 卡片盒整体垂直居中(连线锚在卡片盒中心);两种模式都和泳道中心线/左侧级别标签对齐;
131
+ - 卡片偏移量按实际渲染测量(含字体加载后重排),不写死魔法数;泳道顶部/右侧空白按实际内容收紧。
132
+ - 时间线:按当前时间窗过滤后纵向滚动;轴显示绝对日期(`MM-DD`)并保留相对天数
133
+ (`-7d / 今天 / +Nd`)。
134
+ - 泳道标签在**外层固定 rail**,不参与横向滚动;只随纵向 `scrollTop` 同步位置;
135
+ - 泳道标签为全宽色带 + 虚线分隔,P0/P1/P2/P3 由数据动态生成。
136
+ - 依赖视图按实测卡片高度逐列堆叠,`详细` 模式下卡片不会互相重叠;依赖视图没有泳道,左侧不留级别标签遮罩/占位;
137
+ - 关系视图参数:`#/graph?layout=dep`(依赖视图)/ 默认优先级视图;
138
+ - 时间窗参数(两个视图共用):`#/graph?w=7|14|30|0`(0 = 全部);时间线 `#/timeline?w=7`;
139
+ 显式区间:`#/timeline?from=2026-10-22&to=2026-11-21`。
140
+
141
+ ## 10.1 规模控制:时间窗 + 搜索 + 聚焦
142
+
143
+ 无折叠后,用三种手段控制一次看多少:
144
+
145
+ - **时间窗(默认近 7 天,按开始时间,结束时间不限)**:只显示 `start >= 今天 - N` 的任务,
146
+ 长任务即使结束在很久以后也照常显示;时间线轴自动从 `-N` 延伸到最晚的 `end`;
147
+ - **搜索**:聚焦输入框就出下拉(空时列出当前可见任务,按优先级/开始时间排序),输入 ID / 名称 / owner 实时过滤;
148
+ `↑↓` 选择、`Enter` 聚焦、`Esc` 清空;命中卡片高亮描边、其余置灰,下拉项点击也可聚焦;
149
+ - **聚焦(类 nx graph focus)**:双击卡片或点搜索结果 → 只保留该任务的依赖邻域,
150
+ 芯片可切 `1 层 / 2 层 / 全链路`,「退出」回全量;聚焦状态写进 URL(`focus=t-003&fd=2`);
151
+ - 被过滤掉的任务不计入概览统计,标题显示 `显示 N / 总数`。
152
+
153
+ ## 11. 时间线滚动与缩放
154
+
155
+ - 左侧任务列是**独立固定 pane**(不依赖 sticky),横向滚动时始终可见;任务名顶部对齐;
156
+ - 时间区右上角显示「← 左右滚动 →」提示,避免看不出可滚动;
157
+ - 右侧时间轴在 `tl-scroll` 内横向滚动,表头与内容同步;
158
+ - `zoom` = 每天像素宽度(默认 28,范围 **14–84**,即 50%–300%),控制条 `- / 100% / +`,到边界禁用;
159
+ - URL:`#/timeline?w=7&zoom=40`;
160
+ - 任务纵向滚动,时间窗默认近 7 天开始(结束不限),绝对日期 + 相对天数同时显示。
package/docs/index.md CHANGED
@@ -88,3 +88,9 @@ Agent 入口:[`llms.txt`](https://github.com/minonl/dia/blob/main/llms.txt)(
88
88
  |---|---|
89
89
  | [框架启动方案(bootstrap)](history/framework-bootstrap.md) | 已归档(2026-11-03):本文的启动/分发方案(release tarball + agentctl + framework.lock)未采用; |
90
90
 
91
+ ## 其他
92
+
93
+ | 文档 | 一句话 |
94
+ |---|---|
95
+ | [dia graph Web 视觉设计稿(v0.3)](design/graph-web.md) | 静态 mockup:graph-web-mockup.html(浏览器直接打开,无网络/无依赖) |
96
+
package/docs/status.md CHANGED
@@ -90,6 +90,7 @@
90
90
  | D0.21 | worktree 抽取 `packages/wt` | **完成** | 7 处 python→node;`dia wt`;4 套测试原样迁移全绿 |
91
91
  | D0.23 | doctor 平台版本自检 | **完成** | `platform:opencode` / `platform:dsh` 报版本(缺宿主 = warn,不判红);`tests/active-doctor.test.ts` |
92
92
  | D0.44 | 配置迁移走 LLM 任务 | **完成** | `docs/migrations/llm-migration.md`:升级只改声明;迁移=任务+判据+回滚;禁止代码里堆版本分支(安全敏感/原子场景例外) |
93
+ | D0.64 | 复制过滤按相对路径判断 | **完成** | 绝对路径匹配 `node_modules` 会误伤 npm 全局安装(kit 整树被过滤 → `E_KIT_EMPTY`);改 `copyFilter(base)` 用相对路径,persona/kit 共用 |
93
94
  | D0.63 | 运行时数据 `.data/` 不进快照、不参与哈希 | **完成** | `TREE_SKIP`(kit/persona 共用):安装复制与 `hashTree`/verify 都跳过 `.data`,防运行时写入造成 verify 假漂移 |
94
95
  | D0.62 | persona 旧版技能软链视为受管 | **完成** | `linkManaged` 同时认 `~/.dia/kits/**` 与 `~/.dia/personas/**`;`persona update` 升级不再需要 `--force` |
95
96
  | D0.61 | kit 空内容拒绝 + doctor 只查引用 kit | **完成** | `kit add` 拒绝空清单/空树(E_MANIFEST/E_KIT_EMPTY);doctor 按 persona lock 的 kits 校验,孤儿不再拖红 |
package/docs/worktree.md CHANGED
@@ -11,27 +11,29 @@ dia wt adopt <repo> --apply # 写入配置 + 三个守卫钩子
11
11
  dia wt adopt --all --apply # 批量:仓根下所有主 checkout(跳过 *-worktree--*)
12
12
  ```
13
13
 
14
- 写入内容(幂等):
14
+ 写入内容(幂等,**不改仓内文件、不写全局配置**):
15
15
 
16
- | 项 | 位置 |
17
- |---|---|
18
- | 策略 | `<git-common-dir>/dia/config.json`(`defaultBranch` / `template` / `hooksPath` / `hooksPrevious` / `hooksChain`) |
19
- | 钩子 | `<git-common-dir>/dia/hooks/{pre-commit,pre-push,post-checkout}` |
20
- | `core.hooksPath` | 指向上面目录,**必须是绝对路径**(相对路径在 worktree 会静默失效) |
16
+ | 仓类型 | 做法 | 记录 |
17
+ |---|---|---|
18
+ | 普通仓 | 钩子写 `<git-common-dir>/dia/hooks/`,并把**仓库本地** `core.hooksPath` 指过去(绝对路径) | `hooksManager=local` |
19
+ | **simple-git-hooks 仓** | 不碰 `.git/hooks`、不改 `package.json`;守卫写进 `SIMPLE_GIT_HOOKS_RC` 指向的脚本(钩子会 source 它) | `hooksManager=simple-git-hooks` |
20
+ | 已有其它 hooksPath(husky / `.githooks`) | **不抢占**,只报告(建议改用 simple-git-hooks) | `hooksManager=external` |
21
+
22
+ > 判定:`package.json` 里有 `simple-git-hooks`(配置或 devDependency)→ RC 模式;否则看 `core.hooksPath` 是否已被别人占用。
21
23
 
22
24
  守卫行为:
23
25
 
24
26
  | 钩子 | 规则 |
25
27
  |---|---|
26
28
  | `pre-commit` | 主 checkout 上、分支属于 protected(默认分支 + main/master/develop/dev)→ 拒绝 |
27
- | `pre-push` | 主 checkout 上**任何 push**(含 tag / feature 分支)→ 拒绝;推 `main/master/develop/dev` → 拒绝 |
29
+ | `pre-push` | 主 checkout 上**任何 push**(含 tag / feature 分支)→ 拒绝;推 `main/master/develop/dev` → 拒绝;并对推送内容做敏感信息扫描 |
28
30
  | `post-checkout` | 主 checkout 切到非默认分支 → 黄色提示(不阻断) |
29
31
 
30
- **钩子链**:`hooksChain`(首次 adopt 前的 hooksPath,如 `.husky` / `.githooks`)→ `<common>/hooks` → 默认放行。
31
- 所以 husky / simple-git-hooks / lefthook 的钩子不会被吞。
32
+ **钩子链**:本地钩子先跑守卫,再执行 `<common>/hooks/<name>`(simple-git-hooks 等管理器生成处);`pre-push` 的 stdin 原样转发。
33
+ `SIMPLE_GIT_HOOKS_RC` 由 adopt 写入 `~/.zshenv`(带 `# dia-wt:` 标记),脚本内容固定为 `dia wt guard <hook>`。
32
34
 
33
- > ⚠️ `simple-git-hooks` 的 `postinstall` 会重写 `core.hooksPath` 下的钩子。install 后若守卫被覆盖:
34
- > `dia wt doctor --fix`(或重跑 `dia wt adopt --apply`)。
35
+ > ⚠️ husky / `.githooks` 仓:本地 `core.hooksPath` 会覆盖一切,守卫不生效(doctor WARN)。
36
+ > 迁移办法:把钩子迁到 simple-git-hooks(`postinstall: simple-git-hooks` + `simple-git-hooks` 配置),再 `dia wt adopt --apply`。
35
37
 
36
38
  ## 2. 巡检
37
39
 
@@ -53,6 +55,8 @@ dia wt doctor --all --fix # 无损修复:重装三个钩子 + 校正 hooksPa
53
55
  | D06 | 无 prunable / locked |
54
56
  | D07 | 同一分支不被多个 worktree 占用 |
55
57
  | D11 | 默认分支可解析(config > origin/HEAD(校验仍存在)> main > master) |
58
+ | D20 | 全局 `core.hooksPath` 不得指向 dia 旧钩子目录(防外溢;有则 FAIL + 清理命令) |
59
+ | D21 | 仓库本地 `core.hooksPath` 与模式一致:local=dia 目录;external=只 WARN |
56
60
 
57
61
  挂成日巡检(mac launchd / linux cron 由 `dia job` 统一):
58
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minonl/dia",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "description": "dia (嗲) — lightweight agent framework: knowledge index, audit, session ingest",
6
6
  "bin": {