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.
Files changed (48) hide show
  1. package/AGENTS.md +11 -3
  2. package/README.md +10 -1
  3. package/bin/changeledger.mjs +204 -21
  4. package/package.json +1 -1
  5. package/src/check.mjs +12 -0
  6. package/src/commands/agent-context.mjs +65 -0
  7. package/src/commands/agent-prompt.mjs +22 -0
  8. package/src/commands/agent.mjs +13 -10
  9. package/src/commands/check.mjs +55 -0
  10. package/src/commands/commit.mjs +39 -0
  11. package/src/commands/context.mjs +60 -27
  12. package/src/commands/fix.mjs +72 -0
  13. package/src/commands/register.mjs +9 -1
  14. package/src/commands/search.mjs +56 -0
  15. package/src/config-migration.mjs +44 -9
  16. package/src/config.mjs +13 -0
  17. package/src/contract.mjs +73 -15
  18. package/src/fix.mjs +127 -0
  19. package/src/framing.mjs +30 -0
  20. package/src/git.mjs +119 -2
  21. package/src/lifecycle.mjs +2 -2
  22. package/src/metrics.mjs +42 -0
  23. package/src/search.mjs +162 -0
  24. package/src/viewer/domain.mjs +2 -2
  25. package/src/viewer/public/app-state.js +62 -6
  26. package/src/viewer/public/app.js +138 -39
  27. package/src/viewer/public/index.html +2 -2
  28. package/src/viewer/public/state.js +6 -2
  29. package/src/viewer/public/styles.css +76 -5
  30. package/src/viewer/public/view-renderers.js +160 -48
  31. package/src/viewer/server/router.mjs +40 -6
  32. package/templates/config.yml +7 -1
  33. package/templates/contract/agent-contexts/audit.md +22 -0
  34. package/templates/contract/agent-contexts/implementation.md +18 -0
  35. package/templates/contract/agent-contexts/investigation.md +14 -0
  36. package/templates/contract/agent-contexts/review.md +22 -0
  37. package/templates/contract/agent-prompts/audit.md +37 -0
  38. package/templates/contract/agent-prompts/implementation.md +42 -0
  39. package/templates/contract/agent-prompts/investigation.md +41 -0
  40. package/templates/contract/agent-prompts/review.md +36 -0
  41. package/templates/contract/budgets.yml +16 -0
  42. package/templates/contract/close.md +19 -14
  43. package/templates/contract/core.md +41 -30
  44. package/templates/contract/delegation.md +2 -1
  45. package/templates/contract/implement.md +24 -10
  46. package/templates/contract/review.md +7 -9
  47. package/templates/contract/spec.md +14 -1
  48. 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
- type: 'all',
11
- owner: 'all',
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
- type: state.filters.type,
43
- owner: state.filters.owner,
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.type = typeof filters.type === 'string' ? filters.type : 'all';
57
- state.filters.owner = typeof filters.owner === 'string' ? filters.owner : 'all';
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);
@@ -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
- litRender(
128
- html`<option value="all">All owners</option>
129
- ${owners.map((o) => html`<option value=${o}>${o}</option>`)}`,
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 = (state.repo.specs || []).filter(
676
- (s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
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
- function renderMetrics() {
743
- litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
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 futureSch = schema > SUPPORTED_SCHEMA_VERSION;
883
- const outdated = schema < SUPPORTED_SCHEMA_VERSION;
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 ${SUPPORTED_SCHEMA_VERSION} to enable the Form editor.</p>
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 > SUPPORTED_SCHEMA_VERSION) {
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
- closeStatusMenuOnOutsideClick($('#status-filter .filter-menu'), event.target);
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 closeStatusMenuOnOutsideClick(menu, target) {
1598
- if (!menu?.open || menu.contains(target)) return false;
1599
- menu.open = false;
1600
- return true;
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
- <select id="type-filter" class="filter"></select>
30
- <select id="owner-filter" class="filter"></select>
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
- if (f.type !== 'all' && c.type !== f.type) return false;
26
- if (f.owner !== 'all' && c.owner !== f.owner) return false;
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
- max-width: 1500px;
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: flex;
1325
- flex-direction: column;
1380
+ display: grid;
1381
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1326
1382
  gap: 10px;
1327
- max-width: 760px;
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;