changeledger 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -1
- package/README.md +5 -0
- package/bin/changeledger.mjs +127 -20
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +2 -1
- package/src/commands/agent-prompt.mjs +1 -1
- package/src/commands/agent.mjs +3 -3
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +39 -0
- package/src/commands/context.mjs +57 -23
- package/src/commands/fix.mjs +72 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/search.mjs +56 -0
- package/src/config-migration.mjs +44 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +67 -13
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +8 -0
- package/src/git.mjs +119 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +1 -1
- package/src/viewer/public/app.js +61 -11
- package/src/viewer/public/styles.css +76 -5
- package/src/viewer/public/view-renderers.js +160 -48
- package/src/viewer/server/router.mjs +40 -6
- package/templates/config.yml +7 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +4 -0
- package/templates/contract/close.md +1 -1
- package/templates/contract/core.md +14 -4
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +14 -2
- package/templates/contract/spec.md +14 -1
package/src/metrics.mjs
CHANGED
|
@@ -70,6 +70,29 @@ function avg(nums) {
|
|
|
70
70
|
return nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : 0;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Nearest-rank percentile over an ascending-sorted array; 0 for an empty input
|
|
74
|
+
// so callers never divide by zero or render NaN.
|
|
75
|
+
function percentile(sorted, p) {
|
|
76
|
+
if (!sorted.length) return 0;
|
|
77
|
+
const rank = Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length)));
|
|
78
|
+
return sorted[rank - 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Count of `review → in-progress` verdicts (the `fail --retry` outcome) — the
|
|
82
|
+
// same event grammar as lifecycle transitions, filtered to the implicit
|
|
83
|
+
// review-verdict shape so an explicit `status:` move or a validation verdict
|
|
84
|
+
// isn't miscounted as a retry.
|
|
85
|
+
function reviewRetryCount(change) {
|
|
86
|
+
let count = 0;
|
|
87
|
+
for (const line of logBody(change).split('\n')) {
|
|
88
|
+
const event = parseLogEvent(line);
|
|
89
|
+
if (event && !event.explicit && event.from === 'in-review' && event.to === 'in-progress') {
|
|
90
|
+
count++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
export function computeMetrics(changes = [], { now } = {}) {
|
|
74
97
|
const nowIso = now ?? '9999-12-31T23:59:59Z';
|
|
75
98
|
|
|
@@ -80,12 +103,17 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
80
103
|
const aging = [];
|
|
81
104
|
let blockedMs = 0;
|
|
82
105
|
const byType = new Map(); // type → { closed, cycles:[] }
|
|
106
|
+
const byOwner = new Map(); // owner → { closed, cycles:[] }
|
|
107
|
+
let reviewRetries = 0;
|
|
83
108
|
|
|
84
109
|
for (const c of changes) {
|
|
85
110
|
const status = c.frontmatter?.status;
|
|
86
111
|
const type = c.frontmatter?.type ?? 'unknown';
|
|
112
|
+
const owner = c.frontmatter?.owner || 'unassigned';
|
|
87
113
|
const created = c.frontmatter?.created;
|
|
88
114
|
|
|
115
|
+
reviewRetries += reviewRetryCount(c);
|
|
116
|
+
|
|
89
117
|
if (ACTIVE.includes(status)) wip[status] = (wip[status] ?? 0) + 1;
|
|
90
118
|
|
|
91
119
|
const segs = statusTimeline(c, nowIso);
|
|
@@ -114,6 +142,11 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
114
142
|
bt.closed += 1;
|
|
115
143
|
bt.cycles.push(cycleMs);
|
|
116
144
|
byType.set(type, bt);
|
|
145
|
+
|
|
146
|
+
const bo = byOwner.get(owner) ?? { closed: 0, cycles: [] };
|
|
147
|
+
bo.closed += 1;
|
|
148
|
+
bo.cycles.push(cycleMs);
|
|
149
|
+
byOwner.set(owner, bo);
|
|
117
150
|
}
|
|
118
151
|
}
|
|
119
152
|
}
|
|
@@ -130,17 +163,26 @@ export function computeMetrics(changes = [], { now } = {}) {
|
|
|
130
163
|
const byTypeArr = [...byType.entries()]
|
|
131
164
|
.map(([type, v]) => ({ type, closed: v.closed, avgCycleMs: avg(v.cycles) }))
|
|
132
165
|
.sort((a, b) => b.closed - a.closed);
|
|
166
|
+
const byOwnerArr = [...byOwner.entries()]
|
|
167
|
+
.map(([owner, v]) => ({ owner, closed: v.closed, avgCycleMs: avg(v.cycles) }))
|
|
168
|
+
.sort((a, b) => b.closed - a.closed);
|
|
169
|
+
const validationWaitMs = timeInStatus.find((t) => t.state === 'in-validation')?.avgMs ?? 0;
|
|
133
170
|
|
|
134
171
|
return {
|
|
135
172
|
count: perChange.length,
|
|
136
173
|
avgCycleMs: avg(cycles),
|
|
137
174
|
medianCycleMs: median(cycles),
|
|
175
|
+
p50CycleMs: percentile(cycles, 50),
|
|
176
|
+
p85CycleMs: percentile(cycles, 85),
|
|
138
177
|
perChange,
|
|
139
178
|
throughput,
|
|
140
179
|
timeInStatus,
|
|
141
180
|
wip,
|
|
142
181
|
aging: aging.sort((a, b) => b.ms - a.ms),
|
|
143
182
|
blockedMs,
|
|
183
|
+
validationWaitMs,
|
|
184
|
+
reviewRetries,
|
|
144
185
|
byType: byTypeArr,
|
|
186
|
+
byOwner: byOwnerArr,
|
|
145
187
|
};
|
|
146
188
|
}
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Deterministic lexical search over changes and specs. No embeddings, no
|
|
2
|
+
// network — a fixed-boost term-frequency score (title x3, headings/CR x2,
|
|
3
|
+
// body x1) with a stable tie-break, so repeated runs are byte-for-byte
|
|
4
|
+
// identical. See change 20260711-103758.
|
|
5
|
+
|
|
6
|
+
const ACCENTS = /[̀-ͯ]/g;
|
|
7
|
+
const CR_HEADING = /^###\s+CR\d+.*$/gm;
|
|
8
|
+
const MARKDOWN_HEADING = /^#{1,6}\s+(.+)$/gm;
|
|
9
|
+
|
|
10
|
+
// Lowercase + strip diacritics so "búsqueda" and "busqueda" match the same term.
|
|
11
|
+
export function normalize(text) {
|
|
12
|
+
return (text ?? '').normalize('NFD').replace(ACCENTS, '').toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tokenize(query) {
|
|
16
|
+
return normalize(query)
|
|
17
|
+
.split(/\s+/)
|
|
18
|
+
.map((t) => t.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function countOccurrences(haystackNormalized, term) {
|
|
23
|
+
if (!term) return 0;
|
|
24
|
+
let count = 0;
|
|
25
|
+
let idx = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const found = haystackNormalized.indexOf(term, idx);
|
|
28
|
+
if (found === -1) break;
|
|
29
|
+
count++;
|
|
30
|
+
idx = found + term.length;
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Locates the first case/accent-insensitive occurrence of `term` in `rawText`
|
|
36
|
+
// and returns a short surrounding snippet, or null when there is no match.
|
|
37
|
+
function findSnippet(rawText, term, span = 40) {
|
|
38
|
+
const normalized = normalize(rawText);
|
|
39
|
+
const idx = normalized.indexOf(term);
|
|
40
|
+
if (idx === -1) return null;
|
|
41
|
+
const start = Math.max(0, idx - span);
|
|
42
|
+
const end = Math.min(rawText.length, idx + term.length + span);
|
|
43
|
+
const prefix = start > 0 ? '…' : '';
|
|
44
|
+
const suffix = end < rawText.length ? '…' : '';
|
|
45
|
+
return `${prefix}${rawText.slice(start, end).replace(/\s+/g, ' ').trim()}${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Splits a change's stages into a heading/CR bucket (boost x2) and a body
|
|
49
|
+
// bucket (boost x1). CR heading lines are lifted out of the body so they are
|
|
50
|
+
// scored once, at the heading boost, not twice.
|
|
51
|
+
function changeHeadingsAndBody(stages) {
|
|
52
|
+
const headings = [];
|
|
53
|
+
const bodyParts = [];
|
|
54
|
+
for (const stage of stages) {
|
|
55
|
+
headings.push(stage.heading);
|
|
56
|
+
const crHeadings = stage.body.match(CR_HEADING) ?? [];
|
|
57
|
+
headings.push(...crHeadings);
|
|
58
|
+
bodyParts.push(stage.body.replace(CR_HEADING, ''));
|
|
59
|
+
}
|
|
60
|
+
return { headings: headings.join('\n'), body: bodyParts.join('\n\n') };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Same split for a spec's free-form body: its own `#`..`######` headings
|
|
64
|
+
// (boost x2) versus the remaining prose (boost x1).
|
|
65
|
+
function specHeadingsAndBody(body) {
|
|
66
|
+
const headings = [...body.matchAll(MARKDOWN_HEADING)].map((m) => m[1]);
|
|
67
|
+
const stripped = body.replace(MARKDOWN_HEADING, '');
|
|
68
|
+
return { headings: headings.join('\n'), body: stripped };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Builds the searchable corpus from a loaded repo (see repo.mjs `loadRepo`):
|
|
72
|
+
// every change — including archived ones — and every spec, reduced to the
|
|
73
|
+
// fields the scorer needs.
|
|
74
|
+
export function buildCorpus({ changes, specs }) {
|
|
75
|
+
const fromChanges = changes.map((c) => {
|
|
76
|
+
const { headings, body } = changeHeadingsAndBody(c.stages);
|
|
77
|
+
return {
|
|
78
|
+
ref: `#${c.frontmatter.id}`,
|
|
79
|
+
kind: 'change',
|
|
80
|
+
title: c.frontmatter.title ?? '',
|
|
81
|
+
status: c.frontmatter.status ?? null,
|
|
82
|
+
type: c.frontmatter.type ?? null,
|
|
83
|
+
headings,
|
|
84
|
+
body,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fromSpecs = specs.map((s) => {
|
|
89
|
+
const slug = s.name.replace(/\.md$/, '');
|
|
90
|
+
const { headings, body } = specHeadingsAndBody(s.body);
|
|
91
|
+
return {
|
|
92
|
+
ref: `spec:${slug}`,
|
|
93
|
+
kind: 'spec',
|
|
94
|
+
title: s.frontmatter.title ?? slug,
|
|
95
|
+
status: null,
|
|
96
|
+
type: null,
|
|
97
|
+
headings,
|
|
98
|
+
body,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return [...fromChanges, ...fromSpecs];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreDocument(doc, terms) {
|
|
106
|
+
const titleN = normalize(doc.title);
|
|
107
|
+
const headingsN = normalize(doc.headings);
|
|
108
|
+
const bodyN = normalize(doc.body);
|
|
109
|
+
let score = 0;
|
|
110
|
+
for (const term of terms) {
|
|
111
|
+
score += countOccurrences(titleN, term) * 3;
|
|
112
|
+
score += countOccurrences(headingsN, term) * 2;
|
|
113
|
+
score += countOccurrences(bodyN, term) * 1;
|
|
114
|
+
}
|
|
115
|
+
return score;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildSnippet(doc, terms) {
|
|
119
|
+
for (const field of [doc.title, doc.headings, doc.body]) {
|
|
120
|
+
for (const term of terms) {
|
|
121
|
+
const snippet = findSnippet(field, term);
|
|
122
|
+
if (snippet) return snippet;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Scores and ranks `docs` (as produced by `buildCorpus`) against `query`.
|
|
129
|
+
// On an equal score, a spec sorts before a change — specs are the current
|
|
130
|
+
// persistent truth, so that's what an agent should read first. Among equals
|
|
131
|
+
// of the same kind, ties break by `ref` descending so the same repo state
|
|
132
|
+
// always yields the same byte-for-byte output.
|
|
133
|
+
export function searchDocuments(docs, query, { limit = 10, type, status } = {}) {
|
|
134
|
+
const terms = tokenize(query);
|
|
135
|
+
if (!terms.length) return [];
|
|
136
|
+
|
|
137
|
+
const hits = [];
|
|
138
|
+
for (const doc of docs) {
|
|
139
|
+
if (type && doc.type !== type) continue;
|
|
140
|
+
if (status && doc.status !== status) continue;
|
|
141
|
+
const score = scoreDocument(doc, terms);
|
|
142
|
+
if (score <= 0) continue;
|
|
143
|
+
hits.push({
|
|
144
|
+
ref: doc.ref,
|
|
145
|
+
kind: doc.kind,
|
|
146
|
+
title: doc.title,
|
|
147
|
+
type: doc.type,
|
|
148
|
+
status: doc.status,
|
|
149
|
+
score,
|
|
150
|
+
snippet: buildSnippet(doc, terms),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
hits.sort((a, b) => {
|
|
155
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
156
|
+
if (a.kind !== b.kind) return a.kind === 'spec' ? -1 : 1;
|
|
157
|
+
if (a.ref === b.ref) return 0;
|
|
158
|
+
return a.ref < b.ref ? 1 : -1;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return hits.slice(0, limit);
|
|
162
|
+
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -407,7 +407,7 @@ export function previewConfigMigration(projects, id, rev) {
|
|
|
407
407
|
return {
|
|
408
408
|
code: 200,
|
|
409
409
|
body: {
|
|
410
|
-
summary: `Config migration
|
|
410
|
+
summary: `Config migration ${migrationResult.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
|
|
411
411
|
changes: migrationResult.changes,
|
|
412
412
|
yaml: migrationResult.yaml,
|
|
413
413
|
},
|
package/src/viewer/public/app.js
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
tableRow,
|
|
49
49
|
validationPanel,
|
|
50
50
|
} from './view-parts.js';
|
|
51
|
-
import { graphSvg, metricsHtml, specsListHtml } from './view-renderers.js';
|
|
51
|
+
import { graphSvg, metricsHtml, sortSpecsByUpdated, specsListHtml } from './view-renderers.js';
|
|
52
52
|
|
|
53
53
|
export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
|
|
54
54
|
export { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
@@ -156,7 +156,7 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
|
|
|
156
156
|
html`<details class="filter-menu">
|
|
157
157
|
<summary class="filter-trigger">
|
|
158
158
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
|
|
159
|
-
<span>${summary}</span>
|
|
159
|
+
<span data-choice-summary>${summary}</span>
|
|
160
160
|
<svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path></svg>
|
|
161
161
|
</summary>
|
|
162
162
|
<div class="filter-popover">
|
|
@@ -169,19 +169,32 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
|
|
|
169
169
|
</details>`,
|
|
170
170
|
host,
|
|
171
171
|
);
|
|
172
|
+
const syncSummary = () => {
|
|
173
|
+
host.querySelector('[data-choice-summary]').textContent = choiceFilterSummary(
|
|
174
|
+
label,
|
|
175
|
+
selected,
|
|
176
|
+
owners && state.filters.includeUnassigned,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
172
179
|
host.querySelectorAll('[data-choice]').forEach((input) => {
|
|
173
180
|
input.onchange = () => {
|
|
174
181
|
toggle(input.dataset.choice);
|
|
182
|
+
syncSummary();
|
|
175
183
|
render();
|
|
176
184
|
};
|
|
177
185
|
});
|
|
178
186
|
if (owners)
|
|
179
187
|
host.querySelector('[data-unassigned]').onchange = () => {
|
|
180
188
|
toggleUnassignedOwner();
|
|
189
|
+
syncSummary();
|
|
181
190
|
render();
|
|
182
191
|
};
|
|
183
192
|
host.querySelector('[data-clear]').onclick = () => {
|
|
184
193
|
clear();
|
|
194
|
+
host.querySelectorAll('[data-choice], [data-unassigned]').forEach((input) => {
|
|
195
|
+
input.checked = false;
|
|
196
|
+
});
|
|
197
|
+
syncSummary();
|
|
185
198
|
render();
|
|
186
199
|
};
|
|
187
200
|
}
|
|
@@ -722,8 +735,10 @@ function sortVal(c, key) {
|
|
|
722
735
|
/* Specs view */
|
|
723
736
|
function renderSpecs() {
|
|
724
737
|
const q = state.filters.text.toLowerCase();
|
|
725
|
-
const specs = (
|
|
726
|
-
(
|
|
738
|
+
const specs = sortSpecsByUpdated(
|
|
739
|
+
(state.repo.specs || []).filter(
|
|
740
|
+
(s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
|
|
741
|
+
),
|
|
727
742
|
);
|
|
728
743
|
litRender(specsListHtml(specs, fmtDateTime), $('#specs'));
|
|
729
744
|
$('#specs')
|
|
@@ -789,8 +804,32 @@ export function handleSpecBodyClick(event, _openSpecByName) {
|
|
|
789
804
|
|
|
790
805
|
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
|
|
791
806
|
|
|
792
|
-
|
|
793
|
-
|
|
807
|
+
// The shared metrics module is dynamic-imported once and cached: the client
|
|
808
|
+
// computes metrics itself, over the filtered set, instead of duplicating
|
|
809
|
+
// `computeMetrics` (20260711-155721 CR2). Served read-only from the CLI's own
|
|
810
|
+
// `src/metrics.mjs` by the router.
|
|
811
|
+
let sharedMetricsModule;
|
|
812
|
+
function loadMetricsModule() {
|
|
813
|
+
sharedMetricsModule ??= import('/shared/metrics.mjs');
|
|
814
|
+
return sharedMetricsModule;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// `computeMetrics` speaks the CLI's native change shape (`{ frontmatter,
|
|
818
|
+
// stages }`); `state.repo.changes` is already flattened for board/table
|
|
819
|
+
// rendering. Adapt back rather than reshaping the shared module's contract,
|
|
820
|
+
// which the server also relies on unchanged.
|
|
821
|
+
function toMetricsChange(c) {
|
|
822
|
+
return {
|
|
823
|
+
frontmatter: { id: c.id, type: c.type, status: c.status, owner: c.owner, created: c.created },
|
|
824
|
+
stages: c.stages,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function renderMetrics() {
|
|
829
|
+
const changes = visibleChanges();
|
|
830
|
+
const { computeMetrics } = await loadMetricsModule();
|
|
831
|
+
const metrics = computeMetrics(changes.map(toMetricsChange), { now: new Date().toISOString() });
|
|
832
|
+
litRender(metricsHtml(metrics, changes.length), $('#metrics'));
|
|
794
833
|
}
|
|
795
834
|
|
|
796
835
|
export function syncViewerShell(root = document, renderContent = true) {
|
|
@@ -825,7 +864,6 @@ let configMode = 'form'; // 'form' | 'raw'
|
|
|
825
864
|
let configDirty = false; // true when form/raw has unsaved edits
|
|
826
865
|
let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
|
|
827
866
|
|
|
828
|
-
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
829
867
|
// Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
|
|
830
868
|
// _confirmImpl is replaceable in tests (JSDOM lacks showModal).
|
|
831
869
|
let _confirmImpl = null;
|
|
@@ -929,8 +967,9 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
929
967
|
}
|
|
930
968
|
|
|
931
969
|
const schema = config.schemaVersion ?? 0;
|
|
932
|
-
const
|
|
933
|
-
const
|
|
970
|
+
const supported = config.supported;
|
|
971
|
+
const futureSch = schema > supported;
|
|
972
|
+
const outdated = schema < supported;
|
|
934
973
|
|
|
935
974
|
return html`<div class="config-section">
|
|
936
975
|
${
|
|
@@ -964,7 +1003,7 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
964
1003
|
: outdated
|
|
965
1004
|
? html`<div class="config-migration-card">
|
|
966
1005
|
<h3>Migration required</h3>
|
|
967
|
-
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${
|
|
1006
|
+
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${supported} to enable the Form editor.</p>
|
|
968
1007
|
${
|
|
969
1008
|
preview?.already_current
|
|
970
1009
|
? html`<p class="config-migration-ok">Migration already applied.</p>`
|
|
@@ -1207,7 +1246,7 @@ async function openManagedProject(id, { reload = false } = {}) {
|
|
|
1207
1246
|
const structured = await getProjectConfigStructured(id);
|
|
1208
1247
|
managedConfig = { id, ...structured };
|
|
1209
1248
|
// Default to form for current schema, raw for future schema
|
|
1210
|
-
if (structured.schemaVersion >
|
|
1249
|
+
if (structured.schemaVersion > structured.supported) {
|
|
1211
1250
|
configMode = 'raw';
|
|
1212
1251
|
} else {
|
|
1213
1252
|
configMode = 'form';
|
|
@@ -1639,6 +1678,17 @@ function bootstrap() {
|
|
|
1639
1678
|
|
|
1640
1679
|
loadProjects();
|
|
1641
1680
|
setInterval(load, 5000);
|
|
1681
|
+
|
|
1682
|
+
// The topbar wraps to multiple rows at content-dependent widths, so the
|
|
1683
|
+
// CSS fallback constant cannot bound the projects panels; track the real
|
|
1684
|
+
// rendered height instead.
|
|
1685
|
+
const topbar = document.querySelector('.topbar');
|
|
1686
|
+
if (topbar && typeof ResizeObserver === 'function') {
|
|
1687
|
+
const syncHeaderHeight = () =>
|
|
1688
|
+
document.documentElement.style.setProperty('--header-height', `${topbar.offsetHeight}px`);
|
|
1689
|
+
new ResizeObserver(syncHeaderHeight).observe(topbar);
|
|
1690
|
+
syncHeaderHeight();
|
|
1691
|
+
}
|
|
1642
1692
|
}
|
|
1643
1693
|
|
|
1644
1694
|
export function closeFilterMenusOnOutsideClick(menus, target) {
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
--status-done: #3fb950;
|
|
23
23
|
--status-discarded: #9aa0aa;
|
|
24
24
|
--status-muted: #8b929e;
|
|
25
|
+
/* fallback only: app.js keeps this synced to the real .topbar height */
|
|
26
|
+
--header-height: 55px;
|
|
25
27
|
font-family: "Avenir Next", Avenir, ui-sans-serif, sans-serif;
|
|
26
28
|
color-scheme: dark;
|
|
27
29
|
}
|
|
@@ -115,17 +117,20 @@ body {
|
|
|
115
117
|
/* Project management */
|
|
116
118
|
.projects-view {
|
|
117
119
|
padding: 18px;
|
|
120
|
+
height: calc(100dvh - var(--header-height));
|
|
121
|
+
box-sizing: border-box;
|
|
118
122
|
}
|
|
119
123
|
.projects-shell {
|
|
120
124
|
display: grid;
|
|
121
125
|
grid-template-columns: minmax(320px, 0.8fr) minmax(460px, 1.2fr);
|
|
122
126
|
gap: 16px;
|
|
123
|
-
|
|
124
|
-
margin: 0 auto;
|
|
127
|
+
height: 100%;
|
|
125
128
|
}
|
|
126
129
|
.projects-list,
|
|
127
130
|
.project-editor {
|
|
128
131
|
min-width: 0;
|
|
132
|
+
min-height: 0;
|
|
133
|
+
overflow-y: auto;
|
|
129
134
|
border: 1px solid var(--line);
|
|
130
135
|
border-radius: 14px;
|
|
131
136
|
background: var(--panel);
|
|
@@ -303,8 +308,16 @@ body {
|
|
|
303
308
|
cursor: wait;
|
|
304
309
|
}
|
|
305
310
|
@media (max-width: 900px) {
|
|
311
|
+
.projects-view {
|
|
312
|
+
height: auto;
|
|
313
|
+
}
|
|
306
314
|
.projects-shell {
|
|
307
315
|
grid-template-columns: 1fr;
|
|
316
|
+
height: auto;
|
|
317
|
+
}
|
|
318
|
+
.projects-list,
|
|
319
|
+
.project-editor {
|
|
320
|
+
overflow-y: visible;
|
|
308
321
|
}
|
|
309
322
|
.project-row {
|
|
310
323
|
grid-template-columns: 10px minmax(0, 1fr) auto;
|
|
@@ -894,6 +907,49 @@ body {
|
|
|
894
907
|
font-size: 13px;
|
|
895
908
|
border-bottom: 1px solid var(--line);
|
|
896
909
|
}
|
|
910
|
+
.muted {
|
|
911
|
+
color: var(--muted);
|
|
912
|
+
}
|
|
913
|
+
.metrics-grid {
|
|
914
|
+
display: grid;
|
|
915
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
916
|
+
gap: 14px;
|
|
917
|
+
margin-top: 18px;
|
|
918
|
+
}
|
|
919
|
+
@media (max-width: 1100px) {
|
|
920
|
+
.metrics-grid {
|
|
921
|
+
grid-template-columns: minmax(0, 1fr);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
.metrics-panel {
|
|
925
|
+
background: var(--panel);
|
|
926
|
+
border: 1px solid var(--line);
|
|
927
|
+
border-radius: 8px;
|
|
928
|
+
padding: 4px 18px 16px;
|
|
929
|
+
min-width: 0;
|
|
930
|
+
}
|
|
931
|
+
.metrics-panel .metrics-h:first-child {
|
|
932
|
+
margin-top: 14px;
|
|
933
|
+
}
|
|
934
|
+
.throughput-svg {
|
|
935
|
+
width: 100%;
|
|
936
|
+
height: auto;
|
|
937
|
+
}
|
|
938
|
+
.tp-rect {
|
|
939
|
+
fill: var(--accent, #4a9eff);
|
|
940
|
+
}
|
|
941
|
+
.tp-value {
|
|
942
|
+
font-size: 11px;
|
|
943
|
+
fill: var(--text);
|
|
944
|
+
}
|
|
945
|
+
.tp-date {
|
|
946
|
+
font-size: 10px;
|
|
947
|
+
fill: var(--muted);
|
|
948
|
+
}
|
|
949
|
+
.tp-axis {
|
|
950
|
+
stroke: var(--line);
|
|
951
|
+
stroke-width: 1;
|
|
952
|
+
}
|
|
897
953
|
|
|
898
954
|
/* Overlay + detail */
|
|
899
955
|
.detail-open {
|
|
@@ -1321,10 +1377,10 @@ body {
|
|
|
1321
1377
|
/* Specs view */
|
|
1322
1378
|
.specs-view {
|
|
1323
1379
|
padding: 16px;
|
|
1324
|
-
display:
|
|
1325
|
-
|
|
1380
|
+
display: grid;
|
|
1381
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
1326
1382
|
gap: 10px;
|
|
1327
|
-
|
|
1383
|
+
align-items: start;
|
|
1328
1384
|
}
|
|
1329
1385
|
.spec-card {
|
|
1330
1386
|
background: var(--panel);
|
|
@@ -1340,6 +1396,21 @@ body {
|
|
|
1340
1396
|
font-weight: 600;
|
|
1341
1397
|
margin-bottom: 6px;
|
|
1342
1398
|
}
|
|
1399
|
+
.spec-excerpt {
|
|
1400
|
+
margin: 8px 0 0;
|
|
1401
|
+
color: var(--muted);
|
|
1402
|
+
font-size: 0.9em;
|
|
1403
|
+
overflow: hidden;
|
|
1404
|
+
display: -webkit-box;
|
|
1405
|
+
-webkit-line-clamp: 3;
|
|
1406
|
+
-webkit-box-orient: vertical;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
@media (max-width: 680px) {
|
|
1410
|
+
.specs-view {
|
|
1411
|
+
grid-template-columns: 1fr;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1343
1414
|
.empty {
|
|
1344
1415
|
color: var(--muted);
|
|
1345
1416
|
padding: 8px;
|