changeledger 0.8.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 +11 -3
- package/README.md +10 -1
- package/bin/changeledger.mjs +204 -21
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +65 -0
- package/src/commands/agent-prompt.mjs +22 -0
- package/src/commands/agent.mjs +13 -10
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +39 -0
- package/src/commands/context.mjs +60 -27
- 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 +73 -15
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +30 -0
- package/src/git.mjs +119 -2
- package/src/lifecycle.mjs +2 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +2 -2
- package/src/viewer/public/app-state.js +62 -6
- package/src/viewer/public/app.js +138 -39
- package/src/viewer/public/index.html +2 -2
- package/src/viewer/public/state.js +6 -2
- 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-contexts/implementation.md +18 -0
- package/templates/contract/agent-contexts/investigation.md +14 -0
- package/templates/contract/agent-contexts/review.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +42 -0
- package/templates/contract/agent-prompts/investigation.md +41 -0
- package/templates/contract/agent-prompts/review.md +36 -0
- package/templates/contract/budgets.yml +16 -0
- package/templates/contract/close.md +19 -14
- package/templates/contract/core.md +41 -30
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +24 -10
- package/templates/contract/review.md +7 -9
- package/templates/contract/spec.md +14 -1
- package/templates/contract/validation.md +1 -1
|
@@ -7,8 +7,9 @@ const VALID_DETAIL_SIZES = new Set(['compact', 'wide', 'full']);
|
|
|
7
7
|
let storage = null;
|
|
8
8
|
|
|
9
9
|
const emptyProjectFilters = () => ({
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
types: [],
|
|
11
|
+
owners: [],
|
|
12
|
+
includeUnassigned: false,
|
|
12
13
|
statuses: [],
|
|
13
14
|
showArchived: false,
|
|
14
15
|
showDiscarded: false,
|
|
@@ -21,6 +22,9 @@ export const state = {
|
|
|
21
22
|
text: '',
|
|
22
23
|
type: 'all',
|
|
23
24
|
owner: 'all',
|
|
25
|
+
types: new Set(),
|
|
26
|
+
owners: new Set(),
|
|
27
|
+
includeUnassigned: false,
|
|
24
28
|
statuses: new Set(),
|
|
25
29
|
showArchived: false,
|
|
26
30
|
showDiscarded: false,
|
|
@@ -39,8 +43,9 @@ export const state = {
|
|
|
39
43
|
|
|
40
44
|
function currentProjectFilters() {
|
|
41
45
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
types: [...state.filters.types],
|
|
47
|
+
owners: [...state.filters.owners],
|
|
48
|
+
includeUnassigned: state.filters.includeUnassigned,
|
|
44
49
|
statuses: [...state.filters.statuses],
|
|
45
50
|
showArchived: state.filters.showArchived,
|
|
46
51
|
showDiscarded: state.filters.showDiscarded,
|
|
@@ -53,8 +58,23 @@ function saveCurrentProjectFilters() {
|
|
|
53
58
|
|
|
54
59
|
function applyProjectFilters(id) {
|
|
55
60
|
const filters = state.projectFilters[id] ?? emptyProjectFilters();
|
|
56
|
-
state.filters.
|
|
57
|
-
|
|
61
|
+
state.filters.types = new Set(
|
|
62
|
+
Array.isArray(filters.types)
|
|
63
|
+
? filters.types.filter((value) => typeof value === 'string')
|
|
64
|
+
: typeof filters.type === 'string' && filters.type !== 'all'
|
|
65
|
+
? [filters.type]
|
|
66
|
+
: [],
|
|
67
|
+
);
|
|
68
|
+
state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
|
|
69
|
+
state.filters.owners = new Set(
|
|
70
|
+
Array.isArray(filters.owners)
|
|
71
|
+
? filters.owners.filter((value) => typeof value === 'string')
|
|
72
|
+
: typeof filters.owner === 'string' && filters.owner !== 'all'
|
|
73
|
+
? [filters.owner]
|
|
74
|
+
: [],
|
|
75
|
+
);
|
|
76
|
+
state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
|
|
77
|
+
state.filters.includeUnassigned = filters.includeUnassigned === true;
|
|
58
78
|
state.filters.statuses = new Set(
|
|
59
79
|
Array.isArray(filters.statuses)
|
|
60
80
|
? filters.statuses.filter((value) => typeof value === 'string')
|
|
@@ -144,9 +164,13 @@ export function normalizeRepoState(repo) {
|
|
|
144
164
|
}
|
|
145
165
|
if (state.sortDir !== 1 && state.sortDir !== -1) state.sortDir = 1;
|
|
146
166
|
if (!repo.types.includes(state.filters.type)) state.filters.type = 'all';
|
|
167
|
+
state.filters.types = new Set(
|
|
168
|
+
[...state.filters.types].filter((type) => repo.types.includes(type)),
|
|
169
|
+
);
|
|
147
170
|
const owners = new Set(repo.changes.map((change) => change.owner).filter(Boolean));
|
|
148
171
|
if (state.filters.owner !== 'all' && !owners.has(state.filters.owner))
|
|
149
172
|
state.filters.owner = 'all';
|
|
173
|
+
state.filters.owners = new Set([...state.filters.owners].filter((owner) => owners.has(owner)));
|
|
150
174
|
const statuses = new Set(repo.statuses);
|
|
151
175
|
state.filters.statuses = new Set(
|
|
152
176
|
[...state.filters.statuses].filter((status) => statuses.has(status)),
|
|
@@ -168,16 +192,48 @@ export function setTextFilter(text) {
|
|
|
168
192
|
persistViewerState();
|
|
169
193
|
}
|
|
170
194
|
|
|
195
|
+
export function toggleTypeFilter(type) {
|
|
196
|
+
if (state.filters.types.has(type)) state.filters.types.delete(type);
|
|
197
|
+
else state.filters.types.add(type);
|
|
198
|
+
state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
|
|
199
|
+
persistViewerState();
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
export function setTypeFilter(type) {
|
|
203
|
+
state.filters.types = new Set(type === 'all' ? [] : [type]);
|
|
172
204
|
state.filters.type = type;
|
|
173
205
|
persistViewerState();
|
|
174
206
|
}
|
|
175
207
|
|
|
208
|
+
export function toggleOwnerFilter(owner) {
|
|
209
|
+
if (state.filters.owners.has(owner)) state.filters.owners.delete(owner);
|
|
210
|
+
else state.filters.owners.add(owner);
|
|
211
|
+
state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
|
|
212
|
+
persistViewerState();
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
export function setOwnerFilter(owner) {
|
|
216
|
+
state.filters.owners = new Set(owner === 'all' ? [] : [owner]);
|
|
177
217
|
state.filters.owner = owner;
|
|
178
218
|
persistViewerState();
|
|
179
219
|
}
|
|
180
220
|
|
|
221
|
+
export function toggleUnassignedOwner() {
|
|
222
|
+
state.filters.includeUnassigned = !state.filters.includeUnassigned;
|
|
223
|
+
persistViewerState();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function clearTypeFilters() {
|
|
227
|
+
state.filters.types.clear();
|
|
228
|
+
persistViewerState();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function clearOwnerFilters() {
|
|
232
|
+
state.filters.owners.clear();
|
|
233
|
+
state.filters.includeUnassigned = false;
|
|
234
|
+
persistViewerState();
|
|
235
|
+
}
|
|
236
|
+
|
|
181
237
|
export function toggleStatusFilter(status) {
|
|
182
238
|
if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
|
|
183
239
|
else state.filters.statuses.add(status);
|
package/src/viewer/public/app.js
CHANGED
|
@@ -13,24 +13,27 @@ import {
|
|
|
13
13
|
searchAllProjects,
|
|
14
14
|
} from './api.js';
|
|
15
15
|
import {
|
|
16
|
+
clearOwnerFilters,
|
|
16
17
|
clearStatusFilters,
|
|
18
|
+
clearTypeFilters,
|
|
17
19
|
initializeProjects,
|
|
18
20
|
invalidateCache,
|
|
19
21
|
normalizeRepoState,
|
|
20
22
|
restoreViewerState,
|
|
21
23
|
selectProject,
|
|
22
24
|
setDetailPresentation,
|
|
23
|
-
setOwnerFilter,
|
|
24
25
|
setRepo,
|
|
25
26
|
setSortKey,
|
|
26
27
|
setTextFilter,
|
|
27
|
-
setTypeFilter,
|
|
28
28
|
setView,
|
|
29
29
|
state,
|
|
30
30
|
toggleGlobalMode,
|
|
31
|
+
toggleOwnerFilter,
|
|
31
32
|
toggleShowArchived,
|
|
32
33
|
toggleShowDiscarded,
|
|
33
34
|
toggleStatusFilter,
|
|
35
|
+
toggleTypeFilter,
|
|
36
|
+
toggleUnassignedOwner,
|
|
34
37
|
} from './app-state.js';
|
|
35
38
|
import { cssIdent, initMermaid, makeMermaidExpandable, renderMermaid } from './security.js';
|
|
36
39
|
import { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
@@ -45,7 +48,7 @@ import {
|
|
|
45
48
|
tableRow,
|
|
46
49
|
validationPanel,
|
|
47
50
|
} from './view-parts.js';
|
|
48
|
-
import { graphSvg, metricsHtml, specsListHtml } from './view-renderers.js';
|
|
51
|
+
import { graphSvg, metricsHtml, sortSpecsByUpdated, specsListHtml } from './view-renderers.js';
|
|
49
52
|
|
|
50
53
|
export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
|
|
51
54
|
export { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
@@ -115,27 +118,87 @@ export function showNoProjects(root = document) {
|
|
|
115
118
|
|
|
116
119
|
// Rebuilt on each project load (types/statuses can differ per project).
|
|
117
120
|
function hydrateFilters() {
|
|
118
|
-
litRender(
|
|
119
|
-
html`<option value="all">All types</option>
|
|
120
|
-
${state.repo.types.map((t) => html`<option value=${t}>${t}</option>`)}`,
|
|
121
|
-
$('#type-filter'),
|
|
122
|
-
);
|
|
123
|
-
$('#type-filter').value = state.filters.type;
|
|
124
121
|
$('#lang').textContent = state.repo.language;
|
|
125
|
-
|
|
126
122
|
const owners = [...new Set(state.repo.changes.map((c) => c.owner).filter(Boolean))].sort();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
renderChoiceFilter(
|
|
124
|
+
$('#type-filter'),
|
|
125
|
+
'Type',
|
|
126
|
+
state.repo.types,
|
|
127
|
+
state.filters.types,
|
|
128
|
+
toggleTypeFilter,
|
|
129
|
+
clearTypeFilters,
|
|
130
|
+
);
|
|
131
|
+
renderChoiceFilter(
|
|
130
132
|
$('#owner-filter'),
|
|
133
|
+
'Owner',
|
|
134
|
+
owners,
|
|
135
|
+
state.filters.owners,
|
|
136
|
+
toggleOwnerFilter,
|
|
137
|
+
clearOwnerFilters,
|
|
138
|
+
true,
|
|
131
139
|
);
|
|
132
|
-
if (state.filters.owner !== 'all' && !owners.includes(state.filters.owner)) setOwnerFilter('all');
|
|
133
|
-
$('#owner-filter').value = state.filters.owner;
|
|
134
|
-
$('#owner-filter').style.display = owners.length ? '' : 'none';
|
|
135
|
-
|
|
136
140
|
renderStatusFilter();
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
export function choiceFilterSummary(label, selected, includeUnassigned = false) {
|
|
144
|
+
const count = selected.size + Number(includeUnassigned);
|
|
145
|
+
if (count === 1 && includeUnassigned) return 'Unassigned';
|
|
146
|
+
return count
|
|
147
|
+
? count === 1
|
|
148
|
+
? [...selected][0]
|
|
149
|
+
: `${count} selected`
|
|
150
|
+
: `All ${label.toLowerCase()}s`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderChoiceFilter(host, label, choices, selected, toggle, clear, owners = false) {
|
|
154
|
+
const summary = choiceFilterSummary(label, selected, owners && state.filters.includeUnassigned);
|
|
155
|
+
litRender(
|
|
156
|
+
html`<details class="filter-menu">
|
|
157
|
+
<summary class="filter-trigger">
|
|
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 data-choice-summary>${summary}</span>
|
|
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
|
+
</summary>
|
|
162
|
+
<div class="filter-popover">
|
|
163
|
+
<div class="filter-heading"><span>${label}</span><button type="button" data-clear>Clear</button></div>
|
|
164
|
+
<div class="filter-options">
|
|
165
|
+
${choices.map((choice) => html`<label class="check-option"><input type="checkbox" data-choice=${choice} .checked=${selected.has(choice)} /><span class="check-box" aria-hidden="true"></span><span>${choice}</span></label>`)}
|
|
166
|
+
${owners ? html`<label class="check-option"><input type="checkbox" data-unassigned .checked=${state.filters.includeUnassigned} /><span class="check-box" aria-hidden="true"></span><span>Unassigned</span></label>` : nothing}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</details>`,
|
|
170
|
+
host,
|
|
171
|
+
);
|
|
172
|
+
const syncSummary = () => {
|
|
173
|
+
host.querySelector('[data-choice-summary]').textContent = choiceFilterSummary(
|
|
174
|
+
label,
|
|
175
|
+
selected,
|
|
176
|
+
owners && state.filters.includeUnassigned,
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
host.querySelectorAll('[data-choice]').forEach((input) => {
|
|
180
|
+
input.onchange = () => {
|
|
181
|
+
toggle(input.dataset.choice);
|
|
182
|
+
syncSummary();
|
|
183
|
+
render();
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
if (owners)
|
|
187
|
+
host.querySelector('[data-unassigned]').onchange = () => {
|
|
188
|
+
toggleUnassignedOwner();
|
|
189
|
+
syncSummary();
|
|
190
|
+
render();
|
|
191
|
+
};
|
|
192
|
+
host.querySelector('[data-clear]').onclick = () => {
|
|
193
|
+
clear();
|
|
194
|
+
host.querySelectorAll('[data-choice], [data-unassigned]').forEach((input) => {
|
|
195
|
+
input.checked = false;
|
|
196
|
+
});
|
|
197
|
+
syncSummary();
|
|
198
|
+
render();
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
139
202
|
function renderStatusFilter() {
|
|
140
203
|
const sf = $('#status-filter');
|
|
141
204
|
litRender(
|
|
@@ -672,8 +735,10 @@ function sortVal(c, key) {
|
|
|
672
735
|
/* Specs view */
|
|
673
736
|
function renderSpecs() {
|
|
674
737
|
const q = state.filters.text.toLowerCase();
|
|
675
|
-
const specs = (
|
|
676
|
-
(
|
|
738
|
+
const specs = sortSpecsByUpdated(
|
|
739
|
+
(state.repo.specs || []).filter(
|
|
740
|
+
(s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
|
|
741
|
+
),
|
|
677
742
|
);
|
|
678
743
|
litRender(specsListHtml(specs, fmtDateTime), $('#specs'));
|
|
679
744
|
$('#specs')
|
|
@@ -739,8 +804,32 @@ export function handleSpecBodyClick(event, _openSpecByName) {
|
|
|
739
804
|
|
|
740
805
|
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
|
|
741
806
|
|
|
742
|
-
|
|
743
|
-
|
|
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'));
|
|
744
833
|
}
|
|
745
834
|
|
|
746
835
|
export function syncViewerShell(root = document, renderContent = true) {
|
|
@@ -775,7 +864,6 @@ let configMode = 'form'; // 'form' | 'raw'
|
|
|
775
864
|
let configDirty = false; // true when form/raw has unsaved edits
|
|
776
865
|
let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
|
|
777
866
|
|
|
778
|
-
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
779
867
|
// Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
|
|
780
868
|
// _confirmImpl is replaceable in tests (JSDOM lacks showModal).
|
|
781
869
|
let _confirmImpl = null;
|
|
@@ -879,8 +967,9 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
879
967
|
}
|
|
880
968
|
|
|
881
969
|
const schema = config.schemaVersion ?? 0;
|
|
882
|
-
const
|
|
883
|
-
const
|
|
970
|
+
const supported = config.supported;
|
|
971
|
+
const futureSch = schema > supported;
|
|
972
|
+
const outdated = schema < supported;
|
|
884
973
|
|
|
885
974
|
return html`<div class="config-section">
|
|
886
975
|
${
|
|
@@ -914,7 +1003,7 @@ function configSectionTemplate(config, mode, preview) {
|
|
|
914
1003
|
: outdated
|
|
915
1004
|
? html`<div class="config-migration-card">
|
|
916
1005
|
<h3>Migration required</h3>
|
|
917
|
-
<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>
|
|
918
1007
|
${
|
|
919
1008
|
preview?.already_current
|
|
920
1009
|
? html`<p class="config-migration-ok">Migration already applied.</p>`
|
|
@@ -1157,7 +1246,7 @@ async function openManagedProject(id, { reload = false } = {}) {
|
|
|
1157
1246
|
const structured = await getProjectConfigStructured(id);
|
|
1158
1247
|
managedConfig = { id, ...structured };
|
|
1159
1248
|
// Default to form for current schema, raw for future schema
|
|
1160
|
-
if (structured.schemaVersion >
|
|
1249
|
+
if (structured.schemaVersion > structured.supported) {
|
|
1161
1250
|
configMode = 'raw';
|
|
1162
1251
|
} else {
|
|
1163
1252
|
configMode = 'form';
|
|
@@ -1556,16 +1645,13 @@ function bootstrap() {
|
|
|
1556
1645
|
if (active) enterGlobal();
|
|
1557
1646
|
else activateView(state.currentView);
|
|
1558
1647
|
};
|
|
1559
|
-
$('#type-filter').onchange = (e) => {
|
|
1560
|
-
setTypeFilter(e.target.value);
|
|
1561
|
-
render();
|
|
1562
|
-
};
|
|
1563
|
-
$('#owner-filter').onchange = (e) => {
|
|
1564
|
-
setOwnerFilter(e.target.value);
|
|
1565
|
-
render();
|
|
1566
|
-
};
|
|
1567
1648
|
document.addEventListener('pointerdown', (event) => {
|
|
1568
|
-
|
|
1649
|
+
closeFilterMenusOnOutsideClick(
|
|
1650
|
+
['#type-filter', '#owner-filter', '#status-filter'].map((selector) =>
|
|
1651
|
+
$(`${selector} .filter-menu`),
|
|
1652
|
+
),
|
|
1653
|
+
event.target,
|
|
1654
|
+
);
|
|
1569
1655
|
});
|
|
1570
1656
|
$('#view-board').onclick = () => activateView('board');
|
|
1571
1657
|
$('#view-table').onclick = () => activateView('table');
|
|
@@ -1592,12 +1678,25 @@ function bootstrap() {
|
|
|
1592
1678
|
|
|
1593
1679
|
loadProjects();
|
|
1594
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
|
+
}
|
|
1595
1692
|
}
|
|
1596
1693
|
|
|
1597
|
-
export function
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1694
|
+
export function closeFilterMenusOnOutsideClick(menus, target) {
|
|
1695
|
+
return menus.reduce((closed, menu) => {
|
|
1696
|
+
if (!menu?.open || menu.contains(target)) return closed;
|
|
1697
|
+
menu.open = false;
|
|
1698
|
+
return true;
|
|
1699
|
+
}, false);
|
|
1601
1700
|
}
|
|
1602
1701
|
|
|
1603
1702
|
// Only a real browser page with the app shell bootstraps; importing the module
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
<select id="project" class="filter" title="Project"></select>
|
|
27
27
|
<input id="search" class="search" type="search" placeholder="Search anything…" />
|
|
28
28
|
<button type="button" id="toggle-global" class="chip" title="Search across all projects">Global</button>
|
|
29
|
-
<
|
|
30
|
-
<
|
|
29
|
+
<div id="type-filter"></div>
|
|
30
|
+
<div id="owner-filter"></div>
|
|
31
31
|
<div id="status-filter"></div>
|
|
32
32
|
<div class="spacer"></div>
|
|
33
33
|
<div class="tabs">
|
|
@@ -22,8 +22,12 @@ function haystack(c) {
|
|
|
22
22
|
// predicate so the rule is testable.
|
|
23
23
|
export function isVisible(c, f) {
|
|
24
24
|
if (!passesTombstones(c, f)) return false;
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const types = f.types ?? new Set(f.type && f.type !== 'all' ? [f.type] : []);
|
|
26
|
+
const owners = f.owners ?? new Set(f.owner && f.owner !== 'all' ? [f.owner] : []);
|
|
27
|
+
if (types.size && !types.has(c.type)) return false;
|
|
28
|
+
if (owners.size || f.includeUnassigned) {
|
|
29
|
+
if (c.owner ? !owners.has(c.owner) : !f.includeUnassigned) return false;
|
|
30
|
+
}
|
|
27
31
|
if (f.statuses.size && !f.statuses.has(c.status)) return false;
|
|
28
32
|
const q = f.text.toLowerCase();
|
|
29
33
|
if (!q) return true;
|
|
@@ -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;
|