feat(orchestrator): tasks tab with register, inbox and queue
Add a Tasks tab that lists the kvb-org-rasci register next to the inbox and the heavy-job queue, in list, board and RASCI matrix views, each task with its roles, WSJF, open work and the merge flow of its worktree. - Task priority pins and WSJF estimates are signed with the operator's passkey and applied by the runner through rasci.py; the hub verifies the signature itself before it queues a job or logs the change, the runner verifies it again and runs only the checked argv, from /, with a pinned PATH and a 30 s process-group limit. - The runner refuses task changes into a council that is a symlink, not owned by its uid or given by a relative path, and while the installed council.py, nofollow.py or rasci.py differ from the tree. - Only tasks the hub can sign whole are editable (task slug and length). - Page lookups keyed by register text use Object.hasOwn, so a status or party named like an Object.prototype member cannot break the views. - The inbox screen renders through the tasks screen, so its task filter shows with "Wartet auf Operator" pressed. Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox). 🎯 Fix: 93 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01f791a1f4
commit
c1310c3322
37 files changed
+1436
-80
No files matched your search
@@ -407,6 +407,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "style-inbox.css" %}
|
||||
|
||||
{% include "style-ui.css" %}
|
||||
|
||||
{% include "style-tasks.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -506,10 +508,16 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-tree.js" %}
|
||||
|
||||
{% include "page-flow.js" %}
|
||||
|
||||
{% include "page-cleanup.js" %}
|
||||
|
||||
{% include "page-offices.js" %}
|
||||
|
||||
{% include "page-tasks.js" %}
|
||||
|
||||
{% include "page-tasks-prio.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const ICONS = {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git",
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3",
|
||||
};
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
|
||||
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
|
||||
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
|
||||
|
||||
function tabOf(screen) {
|
||||
@@ -36,7 +38,7 @@ const Nav = (() => {
|
||||
return {screen: "stream", view: null, redirect: null};
|
||||
}
|
||||
|
||||
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
|
||||
return {TABS, SUBS, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Nav;
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
for (const overlap of repo.overlaps || []) {
|
||||
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
|
||||
}
|
||||
for (const edge of flowEdges(repo)) {
|
||||
links.push({source: `branch:${repo.path}\u0000${edge.from}`, target: `branch:${repo.path}\u0000${edge.into}`, kind: "flow", side: edge.side});
|
||||
}
|
||||
}
|
||||
for (const task of data.tasks || []) {
|
||||
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
|
||||
@@ -113,7 +116,7 @@
|
||||
const holder = model.byId.get(`wt:${wt.path}`);
|
||||
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
|
||||
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
|
||||
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
|
||||
layer.append(artMark(holder, moon, 7, [wt.state === "changed" ? "dirty" : "", wt.open_work.length ? "open" : ""].filter(Boolean).join(" "), null));
|
||||
});
|
||||
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
|
||||
});
|
||||
@@ -148,6 +151,13 @@
|
||||
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
|
||||
}
|
||||
}
|
||||
for (const edge of flowEdges(repo)) {
|
||||
const from = spots.get(edge.from);
|
||||
const to = spots.get(edge.into);
|
||||
const bend = edge.side === "pull" ? 0.55 : 0.15;
|
||||
arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"},
|
||||
s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`)));
|
||||
}
|
||||
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
|
||||
const labels = branches.map((branch) => {
|
||||
const at = spots.get(branch.name);
|
||||
@@ -192,7 +202,7 @@
|
||||
function artLegend(view) {
|
||||
const words = {
|
||||
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
|
||||
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
|
||||
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees",
|
||||
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
|
||||
};
|
||||
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
|
||||
@@ -212,9 +222,11 @@
|
||||
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
|
||||
} else if (node.kind === "branch") {
|
||||
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
|
||||
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
|
||||
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
|
||||
} else if (node.kind === "worktree") {
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
flowDetails(data.flow, data.branch),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
|
||||
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
|
||||
} else if (node.kind === "agent") {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
direct: [[740, 0, 0.2]],
|
||||
radio: [[520, 0, 0.07]],
|
||||
};
|
||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]};
|
||||
const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"};
|
||||
|
||||
function workChips(reasons) {
|
||||
if (!reasons || !reasons.length) {
|
||||
return null;
|
||||
}
|
||||
return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => {
|
||||
const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"];
|
||||
return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label);
|
||||
}));
|
||||
}
|
||||
|
||||
function flowLine(flow, branch) {
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen");
|
||||
}
|
||||
const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–"));
|
||||
const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right"));
|
||||
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`},
|
||||
h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"),
|
||||
h("span", {class: "flow-side"}, refs("pull")), arrow(),
|
||||
h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(),
|
||||
h("span", {class: "flow-side"}, refs("push")));
|
||||
}
|
||||
|
||||
function flowDetails(flow, branch) {
|
||||
if (!flow) {
|
||||
return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt.");
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return flowLine(flow, branch);
|
||||
}
|
||||
return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])];
|
||||
}
|
||||
|
||||
function flowTarget(names, ref) {
|
||||
if (names.has(ref)) {
|
||||
return ref;
|
||||
}
|
||||
const bare = ref.includes("/") ? ref.slice(ref.indexOf("/") + 1) : "";
|
||||
return names.has(bare) ? bare : null;
|
||||
}
|
||||
|
||||
function flowEdges(repo) {
|
||||
const names = new Set(repo.branches.map((entry) => entry.name));
|
||||
const edges = [];
|
||||
for (const branch of repo.branches) {
|
||||
for (const flow of branch.worktrees.map((wt) => wt.flow).filter((found) => found && found.source !== "invalid")) {
|
||||
for (const ref of flow.pull) {
|
||||
const from = flowTarget(names, ref);
|
||||
if (from && from !== branch.name) {
|
||||
edges.push({from, into: branch.name, side: "pull", ref});
|
||||
}
|
||||
}
|
||||
for (const ref of flow.push) {
|
||||
const into = flowTarget(names, ref);
|
||||
if (into && into !== branch.name) {
|
||||
edges.push({from: branch.name, into, side: "push", ref});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
const inInbox = ui.screen === "inbox";
|
||||
const current = inInbox ? visibleInbox()[ui.cursor] : null;
|
||||
const index = Number(event.key) - 1;
|
||||
if (/^[1-3]$/.test(event.key)) {
|
||||
if (/^[1-9]$/.test(event.key) && index < Nav.TABS.length) {
|
||||
event.preventDefault();
|
||||
go(Nav.TABS[index][0], null);
|
||||
} else if (inInbox && (event.key === "j" || event.key === "k")) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
function paletteItems() {
|
||||
return [
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
if (ui.screen === "agents") {
|
||||
return JSON.stringify([ui.views.agents]);
|
||||
}
|
||||
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
|
||||
return JSON.stringify(ui.screen === "artifacts" || ui.screen === "tasks" ? [ui.views[ui.screen]] : ui.screen === "rules" ? state.rules : []);
|
||||
}
|
||||
|
||||
function screenOf() {
|
||||
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
|
||||
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
@@ -66,6 +66,7 @@
|
||||
radioDom = null;
|
||||
inboxDom = null;
|
||||
inboxSig = "";
|
||||
taskView.dom = null;
|
||||
const main = document.getElementById("main");
|
||||
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
|
||||
main.replaceChildren(screenOf());
|
||||
@@ -82,6 +83,7 @@
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawRadio();
|
||||
drawTasks();
|
||||
drawStatus();
|
||||
tick();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
const FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
|
||||
const PRIO_MAX = 999;
|
||||
const ESTIMATOR = HUMAN;
|
||||
const shownArgv = (argv) => ["rasci.py", ...argv].join(" ");
|
||||
const prioText = (text) => (/^[1-9][0-9]{0,2}$/.test(text.trim()) ? String(Number(text.trim())) : "");
|
||||
|
||||
function fibonacciPicker(label, picked) {
|
||||
return h("select", {class: "field", "aria-label": label}, FIBONACCI.map((value) => h("option", {value, selected: value === picked}, String(value))));
|
||||
}
|
||||
|
||||
async function changeTask(row, argv, label, outcome) {
|
||||
const items = [{kind: "task", id: row.task, argv}];
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Signiere mit dem Passkey …"));
|
||||
const signed = await proofFor(items, label);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
return;
|
||||
}
|
||||
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`));
|
||||
toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true);
|
||||
await load();
|
||||
}
|
||||
|
||||
function priorityForm(row) {
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const own = row.estimates[ESTIMATOR] || null;
|
||||
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priorität", placeholder: "1"});
|
||||
const value = fibonacciPicker("Geschäftswert", own ? own.value : 5);
|
||||
const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3);
|
||||
const pinCode = h("code", {});
|
||||
const estimateCode = h("code", {});
|
||||
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
|
||||
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
|
||||
const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften");
|
||||
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern");
|
||||
const sync = () => {
|
||||
pin.disabled = !prioText(prio.value);
|
||||
pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv());
|
||||
estimateCode.textContent = shownArgv(estimateArgv());
|
||||
};
|
||||
for (const field of [prio, value, complexity]) {
|
||||
field.addEventListener("input", sync);
|
||||
}
|
||||
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome));
|
||||
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
|
||||
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null;
|
||||
sync();
|
||||
return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"},
|
||||
h("h3", {}, "Priorität anheften"),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin),
|
||||
h("div", {class: "command"}, pinCode),
|
||||
h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop),
|
||||
h("div", {class: "command"}, estimateCode),
|
||||
h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()),
|
||||
outcome);
|
||||
}
|
||||
|
||||
function taskRoles(row) {
|
||||
const entries = Object.entries(row.roles);
|
||||
return [h("h3", {}, "RASCI", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")];
|
||||
}
|
||||
|
||||
function taskEstimates(row) {
|
||||
const entries = Object.entries(row.estimates);
|
||||
const score = row.wsjf;
|
||||
return [h("h3", {}, "WSJF-Schätzungen", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."),
|
||||
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
|
||||
}
|
||||
|
||||
function taskJobs(row) {
|
||||
const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse();
|
||||
return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
|
||||
h("div", {class: "row"}, h("code", {}, job.command), status(job.status)),
|
||||
job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null;
|
||||
}
|
||||
|
||||
function taskSheet(id) {
|
||||
const row = state.tasks.find((entry) => entry.task === id);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
|
||||
openSheet(row.task, [
|
||||
h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null),
|
||||
h("p", {class: "text"}, titleOf(row)),
|
||||
meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts),
|
||||
workChips(row.open_work),
|
||||
flowDetails(row.flow, branchOf(row)),
|
||||
taskRoles(row),
|
||||
row.kind === "task" ? taskEstimates(row) : null,
|
||||
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."),
|
||||
taskJobs(row),
|
||||
], false);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
|
||||
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
|
||||
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
|
||||
const TASK_FILTERS = [
|
||||
["alle", "Alle", "bi-list-check", () => true],
|
||||
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
|
||||
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
|
||||
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
|
||||
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
||||
["beendet", "Beendet", "bi-check2-circle", (row) => row.ended],
|
||||
];
|
||||
const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"};
|
||||
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
|
||||
const MATRIX_PARTIES = 40;
|
||||
const taskView = {filter: "alle", q: "", dom: null, sig: ""};
|
||||
|
||||
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
|
||||
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
|
||||
const titleOf = (row) => (row.title !== "-" ? row.title : row.task);
|
||||
const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running"));
|
||||
|
||||
function taskStatus(row) {
|
||||
const [label, tone] = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status] : [row.status, "neutral"];
|
||||
return pill(label, tone);
|
||||
}
|
||||
|
||||
function taskBadges(row, withStatus) {
|
||||
const [kindName, kindTone] = Object.hasOwn(TASK_KINDS, row.kind) ? TASK_KINDS[row.kind] : [null, null];
|
||||
const score = row.wsjf;
|
||||
return [
|
||||
kindName ? pill(kindName, kindTone) : null,
|
||||
withStatus ? taskStatus(row) : null,
|
||||
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
|
||||
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
|
||||
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
||||
openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
function roleSummary(row) {
|
||||
const holders = (letter) => Object.entries(row.roles).filter(([, letters]) => letters.includes(letter)).map(([party]) => party);
|
||||
return ["R", "A"].map((letter) => (holders(letter).length ? h("span", {title: ROLE_WORDS[letter]}, `${letter}: ${holders(letter).join(", ")}`) : null));
|
||||
}
|
||||
|
||||
function taskPlace(row) {
|
||||
if (row.kind === "queue") {
|
||||
return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued));
|
||||
}
|
||||
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
|
||||
return place ? h("code", {title: row.repo || place}, place) : null;
|
||||
}
|
||||
|
||||
function opener(row) {
|
||||
return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
||||
}
|
||||
|
||||
function taskItem(row) {
|
||||
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
||||
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
|
||||
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
|
||||
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
|
||||
opener(row),
|
||||
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null,
|
||||
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
|
||||
flowLine(row.flow, branchOf(row)),
|
||||
workChips(row.open_work));
|
||||
}
|
||||
|
||||
function taskCard(row) {
|
||||
const classes = ["task-card", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
||||
return h("li", {class: classes, "data-task": row.task},
|
||||
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task)),
|
||||
opener(row),
|
||||
h("div", {class: "states"}, taskBadges(row, row.status === "awaiting")),
|
||||
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
|
||||
row.flow ? flowLine(row.flow, branchOf(row)) : null,
|
||||
workChips(row.open_work));
|
||||
}
|
||||
|
||||
function taskBoard(rows) {
|
||||
return h("div", {class: "task-board"}, TASK_COLUMNS.map(([id, label]) => {
|
||||
const cards = rows.filter((row) => columnOf(row) === id);
|
||||
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
|
||||
h("h3", {class: "group-title"}, label, counter(cards.length)),
|
||||
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
|
||||
}));
|
||||
}
|
||||
|
||||
function roleLetters(letters) {
|
||||
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
|
||||
}
|
||||
|
||||
function taskMatrix(rows) {
|
||||
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de"));
|
||||
if (!parties.length) {
|
||||
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I.");
|
||||
}
|
||||
const columns = parties.slice(0, MATRIX_PARTIES);
|
||||
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
|
||||
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null);
|
||||
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
|
||||
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
|
||||
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
|
||||
columns.map((party) => {
|
||||
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
|
||||
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
|
||||
}));
|
||||
const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
|
||||
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
|
||||
}
|
||||
|
||||
function chooseTaskFilter(id) {
|
||||
if (id === "operator") {
|
||||
go("inbox", null);
|
||||
return;
|
||||
}
|
||||
taskView.filter = id;
|
||||
if (ui.screen === "inbox") {
|
||||
go("tasks", null);
|
||||
} else {
|
||||
drawTasks();
|
||||
}
|
||||
}
|
||||
|
||||
function taskChips(rows, inbox) {
|
||||
return TASK_FILTERS.map(([id, label, name, test]) => {
|
||||
const total = test ? rows.filter(test).length : waitingCount();
|
||||
return h("button", {type: "button", class: "chip preset", "data-filter": id, "aria-pressed": String(inbox ? id === "operator" : id === taskView.filter), title: `${label}: ${total}`, on: {click: () => chooseTaskFilter(id)}},
|
||||
icon(name), label, counter(total));
|
||||
});
|
||||
}
|
||||
|
||||
function taskViewSwitch(inbox) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
|
||||
function tasksScreen() {
|
||||
const inbox = ui.screen === "inbox";
|
||||
const view = ui.views.tasks;
|
||||
const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"});
|
||||
const count = h("span", {class: "muted"});
|
||||
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
|
||||
taskView.q = event.target.value;
|
||||
drawTasks();
|
||||
}}});
|
||||
const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"});
|
||||
taskView.dom = {chips, count, body, inbox, view};
|
||||
taskView.sig = "";
|
||||
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), chips, body);
|
||||
}
|
||||
|
||||
function drawTasks() {
|
||||
const dom = taskView.dom;
|
||||
if (!dom) {
|
||||
return;
|
||||
}
|
||||
const q = taskView.q.trim().toLowerCase();
|
||||
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
|
||||
if (sig === taskView.sig) {
|
||||
return;
|
||||
}
|
||||
taskView.sig = sig;
|
||||
const rows = state.tasks;
|
||||
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
|
||||
if (dom.inbox) {
|
||||
dom.count.textContent = `${rows.length} Aufgaben im Register`;
|
||||
return;
|
||||
}
|
||||
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
|
||||
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
|
||||
const shown = rows.filter(test).filter(hit);
|
||||
dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`;
|
||||
const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix};
|
||||
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
|
||||
}
|
||||
@@ -50,16 +50,21 @@
|
||||
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
|
||||
}
|
||||
|
||||
function openWork(wt) {
|
||||
return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
|
||||
}
|
||||
|
||||
function worktreeRow(wt) {
|
||||
const review = wt.review;
|
||||
return h("div", {class: "wt"},
|
||||
return h("div", {class: wt.open_work.length ? "wt open-work" : "wt", "data-open-work": wt.open_work.join(" ") || null},
|
||||
h("div", {class: "wt-head"},
|
||||
h("span", {class: "states"}, worktreeStates(wt)),
|
||||
h("span", {class: "states"}, worktreeStates(wt), openWork(wt)),
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
copyButton(wt.path)),
|
||||
flowLine(wt.flow, wt.branch),
|
||||
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
|
||||
}
|
||||
|
||||
@@ -114,9 +119,11 @@
|
||||
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
|
||||
const dirty = trees.filter((wt) => wt.dirty).length;
|
||||
const merged = repo.branches.filter((entry) => entry.cleanup).length;
|
||||
const busy = trees.filter((wt) => wt.open_work.length).length;
|
||||
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null,
|
||||
busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null),
|
||||
branches.map((entry) => branchRow(repo, entry)),
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||
details.addEventListener("toggle", () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.graph.tasks { grid-template-columns: minmax(0, 1fr); }
|
||||
.tasks-body { min-width: 0; }
|
||||
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
|
||||
.tasks > .chips { margin-bottom: 0; }
|
||||
.chip .count { margin-left: 2px; }
|
||||
.task-list, .task-cards { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.task-row { display: grid; gap: 6px; min-width: 0; padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); }
|
||||
.task-row .head-row { margin-bottom: 0; }
|
||||
.task-row > .linkish { justify-self: start; font-size: var(--fs-lg); text-align: left; }
|
||||
.task-row.open-work, .task-card.open-work, .wt.open-work, .matrix tr.open-work > th { box-shadow: inset 3px 0 0 var(--warn); }
|
||||
.task-row.ended, .task-card.ended { opacity: .72; }
|
||||
.task-id { font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; }
|
||||
.task-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.task-meta code { overflow-wrap: anywhere; }
|
||||
.task-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 12px; align-items: start; }
|
||||
.task-column { display: grid; gap: 8px; min-width: 0; padding: var(--sp-3); }
|
||||
.task-card { display: grid; gap: 4px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); }
|
||||
.task-card .linkish { overflow-wrap: anywhere; }
|
||||
.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; font-size: var(--fs-sm); }
|
||||
.flow-label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-weight: 650; }
|
||||
.flow-side { display: inline-flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
|
||||
.flow-ref { padding: 0 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--sunken); line-height: 20px; overflow-wrap: anywhere; }
|
||||
.flow-ref.here { border-color: transparent; background: var(--accent-soft); color: var(--accent); font-weight: 650; }
|
||||
.flow-arrow { display: inline-flex; color: var(--muted); }
|
||||
.flow.invalid { gap: 6px; color: var(--bad); }
|
||||
.states.work .state { gap: 5px; }
|
||||
.matrix-box { display: grid; gap: 8px; min-width: 0; }
|
||||
.matrix-scroll { max-width: 100%; max-height: 70vh; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
|
||||
table.matrix { border-collapse: separate; border-spacing: 0; font-size: var(--fs-sm); }
|
||||
table.matrix th, table.matrix td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: center; vertical-align: middle; white-space: nowrap; }
|
||||
table.matrix thead th { position: sticky; top: 0; z-index: 1; background: var(--panel); vertical-align: bottom; }
|
||||
table.matrix th[scope="row"], table.matrix thead th:first-child { position: sticky; left: 0; background: var(--panel); text-align: left; }
|
||||
table.matrix thead th:first-child { z-index: 2; }
|
||||
table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-width: 320px; white-space: normal; }
|
||||
.matrix-task { display: grid; gap: 2px; }
|
||||
.matrix .party { display: inline-block; max-height: 150px; overflow: hidden; text-overflow: ellipsis; writing-mode: vertical-rl; transform: rotate(180deg); }
|
||||
.matrix td .letter + .letter { margin-left: 2px; }
|
||||
.task-edit { display: grid; gap: 8px; }
|
||||
.task-edit h3 { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.task-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
|
||||
.task-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.task-form input[type="number"] { width: 120px; }
|
||||
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
|
||||
svg .edge.flow { stroke-dasharray: 2 5; }
|
||||
Reference in new issue
Block a user