rvlib-shipkit 1.3.0 → 1.4.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 (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
@@ -105,6 +105,17 @@ export class Answers {
105
105
  this.h = null
106
106
  }
107
107
 
108
+ /** the busy timeout sqlite holds on the open handle, in ms: how long its
109
+ * last statement could wait on a writer. Null with no handle */
110
+ busyTimeout(): number | null {
111
+ try {
112
+ const r = this.h?.query('pragma busy_timeout').get() as Record<string, number> | null | undefined
113
+ return r == null ? null : (Object.values(r)[0] ?? null)
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
108
119
  /** the stored answer, or null when the check must run */
109
120
  read<T>(ns: string, name: string, key: string): Stored<T> | null {
110
121
  const row = this.with(READ_BUSY_MS, (h) => h.query('select v, at, keeps, payload from answers where ns = ? and name = ? and key = ?').get(ns, name, key) as Row | null)
@@ -31,9 +31,8 @@
31
31
  // A measured negative may be kept only for a check about the WORLD. One about
32
32
  // THIS REPO is fixed by a human and the next run goes green, so replaying its
33
33
  // red would be the cache lying about work already done.
34
- import { FOREVER_MS, fmtStamp, type Line } from '../core/wire.ts'
34
+ import { FOREVER_MS, type Line, type Stamp } from '../core/wire.ts'
35
35
  import { FRESH_ENV, withAnswers, type Answers, type Stored } from './store.ts'
36
- import { CACHED, RAN } from '../core/outcome-words.ts'
37
36
 
38
37
  /** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
39
38
  * Long enough to stop a re-ask storm inside one working session, short
@@ -80,11 +79,21 @@ function adoptTimed(a: Answers, ns: string, name: string, key: string): Stored<L
80
79
  return head != null && e.key.slice(head[0].length) === rest && Array.isArray(e.payload) && e.payload.length > 0 && e.payload.every((l) => l.kind === 'ok' || l.kind === 'note')
81
80
  })
82
81
  if (old == null) return null
83
- const payload = old.payload.map((l, i): Line => (i !== 0 ? l : { ...l, text: l.text.replace(new RegExp(`\\((${CACHED} at [^)]*?) until [^)]*\\)$`), '($1 until its declaration changes)') }))
82
+ const payload = unbake(old.payload)
84
83
  a.write([{ ns, name, key, payload, at: old.at, keeps: FOREVER_MS }], { now: old.at })
85
84
  return { ...old, key, keeps: FOREVER_MS, payload }
86
85
  }
87
86
 
87
+ /** the times an older build wrote into the first line's text, as `(cached at
88
+ * <stamp> until <stamp>)` or `(ran at <stamp>, kept until <stamp>)`: the
89
+ * entry holds them as data, so the text drops them */
90
+ const BAKED = / \((?:cached|ran) at \d{4}-\d\d-\d\d \d\d:\d\d:\d\d,? (?:kept )?until [^)]*\)$/
91
+ const unbake = (lines: Line[]): Line[] => lines.map((l, i) => (i === 0 ? { ...l, text: l.text.replace(BAKED, '') } : l))
92
+
93
+ /** the first line, stamped with when its answer was measured and until when it keeps */
94
+ const stamped = (lines: Line[], stamp: Stamp): Line[] => lines.map((l, i) => (i === 0 ? { ...l, stamp } : l))
95
+ const untilOf = (at: number, keeps: number): number | null => (keeps >= FOREVER_MS ? null : at + keeps)
96
+
88
97
  /** the answer for this key, or the one a forever key adopts */
89
98
  const readOrAdopt = (a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null => a.read<Line[]>(ns, name, key) ?? adoptTimed(a, ns, name, key)
90
99
 
@@ -104,11 +113,9 @@ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: num
104
113
  if (entry == null || !Array.isArray(entry.payload)) return null
105
114
  const keeps = entry.keeps ?? ttlMs
106
115
  if (now - entry.at >= keeps || now < entry.at) return null
107
- // the stamp was baked in when the answer was MEASURED (below): a replay
108
- // prints the stored lines VERBATIM, so the live run and every later replay
109
- // render the same string. Annotating replays but not live runs diffed on
110
- // every fresh-vs-cached alternation, so neither side annotates.
111
- return entry.payload
116
+ // the times ride as data on the first line, rendered by whoever prints it
117
+ // against its own clock, so a line read a day later says how old it is
118
+ return stamped(unbake(entry.payload), { at: entry.at, until: untilOf(entry.at, keeps), cached: true })
112
119
  }
113
120
 
114
121
  export type Measured = {
@@ -150,11 +157,6 @@ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: n
150
157
  return m.lines
151
158
  }
152
159
  const now = c.now()
153
- const lines = m.lines.map((l, i): Line =>
154
- i !== 0 ? l
155
- : keepsFor >= FOREVER_MS ? { ...l, text: `${l.text} (${CACHED} at ${fmtStamp(now)} until its declaration changes)` }
156
- : { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` },
157
- )
158
- withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
159
- return lines
160
+ withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: m.lines, at: now, keeps: keepsFor }], { now }))
161
+ return stamped(m.lines, { at: now, until: untilOf(now, keepsFor) })
160
162
  }
@@ -146,12 +146,17 @@ export type RepoConfig = {
146
146
  /** notify command for new RED needs while the hub serves, `{message}` substituted, as in a push-notification CLI */
147
147
  notifyCommand?: string
148
148
  /** the `.shipkit/<venue>/` release contracts: which ones this repo is excused
149
- * from, and any manifest the published toolkit does not carry. */
149
+ * from, any manifest the published toolkit does not carry, and the dir
150
+ * its site serves when the deploy does not say. */
150
151
  contracts?: {
151
152
  /** venue id to the reason it is waived. The only bypass, and it always prints */
152
153
  exempt?: Record<string, string>
153
154
  /** manifests declared by this repo, beside the built-in venues */
154
155
  extra?: ContractManifest[]
156
+ /** the dir the site serves, repo-relative, where the identity contract
157
+ * wants favicon.png, og.png and logo.*. Absent: the dir the deploy names
158
+ * (a Pages workflow's artifact path, a wrangler output dir), else `public` */
159
+ webRoot?: string
155
160
  }
156
161
  /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
157
162
  * block — for ecosystem-specific names the published lib may not carry */
@@ -71,12 +71,12 @@ export const failingOf = (r: ActivityRow): StatusCheck[] => {
71
71
  if (r.failing != null) return r.failing
72
72
  const c = r.status
73
73
  if (c == null) return []
74
- const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
74
+ const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '', lines: [] })
75
75
  return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
76
76
  }
77
77
 
78
78
  /** each failing and warning check: its id (a link to its page), its title,
79
- * how old that outcome is, and the first line it printed about it */
79
+ * how old that outcome is, and every line it printed about it */
80
80
  export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
81
81
  const now = app.activity.now
82
82
  return (
@@ -93,7 +93,7 @@ export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow })
93
93
  {c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
94
94
  {c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
95
95
  </span>
96
- {c.line === '' ? null : <span className="acchkline">{c.line}</span>}
96
+ {(c.lines ?? (c.line === '' ? [] : [c.line])).map((l, i) => <span key={i} className="acchkline">{l}</span>)}
97
97
  </li>
98
98
  ))}
99
99
  </ul>
@@ -12,6 +12,7 @@ import type { RepoSt } from './repo-st.ts'
12
12
  import { applyRunEvent, idleRun, startRun, stripText, type RunProgress } from './run-strip.ts'
13
13
  import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent, WhenAt } from './hub-types.ts'
14
14
  import { WHEN_OPTION } from './check-words.ts'
15
+ import { lineText } from '../../core/wire.ts'
15
16
 
16
17
  /** the colour rule, the SAME one the server applies, because a row must not
17
18
  * change meaning between a live result and the poll that confirms it */
@@ -199,7 +200,7 @@ export class ChecksSvc {
199
200
  state: stateOf(e),
200
201
  severity: e.status === 'fail' ? (e.advisory ? 'yellow' : 'red') : e.status === 'warn' ? 'yellow' : undefined,
201
202
  check: { ...u, status: e.status, replayed: e.replayed, lastMs: e.replayed ? null : Math.round(e.ms), lastAt: Date.now(), advisory: e.advisory },
202
- md: e.lines.length === 0 ? r.md : `\`\`\`\n${e.lines.map((l) => (l.kind === 'fix-fail' || l.kind === 'fix-warn' ? ` fix: ${l.text}` : l.kind === 'fail' ? `FAIL: ${l.text}` : l.kind === 'warn' ? `WARN ${l.text}` : l.kind === 'skip' ? `skip: ${l.text}` : `ok: ${l.text}`)).join('\n')}\n\`\`\``,
203
+ md: e.lines.length === 0 ? r.md : `\`\`\`\n${e.lines.map((l) => (l.kind === 'fix-fail' || l.kind === 'fix-warn' ? ` fix: ${lineText(l)}` : l.kind === 'fail' ? `FAIL: ${lineText(l)}` : l.kind === 'warn' ? `WARN ${lineText(l)}` : l.kind === 'skip' ? `skip: ${lineText(l)}` : `ok: ${lineText(l)}`)).join('\n')}\n\`\`\``,
203
204
  }
204
205
  })
205
206
  }
@@ -0,0 +1,124 @@
1
+ // The checks page and the asks page: one dense table each, in the activity
2
+ // page's table style, one line per item under group headers that count their
3
+ // lines. What needs attention comes first; the quiet groups (passing, off,
4
+ // done) fold by default and each viewer's fold choice is kept. The filter
5
+ // input and the severity keys narrow the lines, since both read
6
+ // `visibleInstances`. Groups, order and words: col-tables.ts.
7
+ import { observer } from 'mobx-react-lite'
8
+ import type { ReactNode } from 'react'
9
+ import { checkName } from './check-words.ts'
10
+ import { askGroups, checkGroups, dayMs, findingLine, planWord, type TableGroup } from './col-tables.ts'
11
+ import { durationTone, fmtMs } from './dur.ts'
12
+ import type { Resource } from './hub-types.ts'
13
+ import type { RepoSt } from './repo-st.ts'
14
+ import { TimeAgo } from './time-ago.tsx'
15
+
16
+ const emptyWords = (app: RepoSt): string => (app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : app.query.trim() !== '' ? 'nothing matches the filter' : 'nothing here yet')
17
+
18
+ /** the page frame: the collection's title and count, then the table */
19
+ const Page = ({ title, n, cols, empty, children }: { title: string; n: number; cols: ReactNode; empty: string; children: ReactNode }) => (
20
+ <div className="ctpage">
21
+ <div className="cghead">{title}<span className="cnt">{n}</span></div>
22
+ {n === 0 ? <div className="ph">{empty}</div> : (
23
+ <table className="actable cttable">
24
+ <colgroup>{cols}</colgroup>
25
+ {children}
26
+ </table>
27
+ )}
28
+ </div>
29
+ )
30
+
31
+ /** one group: its header line (a click folds it), then its lines unless folded */
32
+ const Group = observer(<K extends string>({ app, col, g, span, row }: { app: RepoSt; col: string; g: TableGroup<K>; span: number; row: (r: Resource) => ReactNode }) => {
33
+ const key = `${col}:${g.key}`
34
+ const folded = app.cols.groupFolded(key, g.foldedByDefault)
35
+ return (
36
+ <tbody className="ctgroup">
37
+ <tr className={`ctgrp g-${g.key}`} data-k={g.key} onClick={() => app.cols.toggleGroup(key, g.foldedByDefault)}>
38
+ <td colSpan={span}><span className="ctcaret">{folded ? '▸' : '▾'}</span>{g.label}</td>
39
+ </tr>
40
+ {folded ? null : g.rows.map(row)}
41
+ </tbody>
42
+ )
43
+ })
44
+
45
+ const DOT: Record<string, { tone: string; mark: string }> = {
46
+ fail: { tone: 't-red', mark: '●' },
47
+ warn: { tone: 't-warn', mark: '●' },
48
+ pass: { tone: 't-ok', mark: '●' },
49
+ skip: { tone: 'acdim', mark: '●' },
50
+ never: { tone: 'acdim', mark: '○' },
51
+ off: { tone: 'acdim', mark: '○' },
52
+ }
53
+
54
+ const CheckRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
55
+ const c = r.check!
56
+ const name = checkName(c.checkId, r.title)
57
+ const dot = c.when === 'off' ? DOT.off! : c.status == null ? DOT.never! : DOT[c.status]!
58
+ const finding = findingLine(c)
59
+ const tone = durationTone(c.lastMs, c.timeoutMs, c.budgetMs)
60
+ return (
61
+ <tr className={`ctrow s-${c.status ?? 'never'}`} data-k={c.checkId} onClick={() => app.cols.select(r.collection, r.id)} title={c.description}>
62
+ <td className={`ctdot ${dot.tone}`}>{dot.mark}</td>
63
+ <td className={`ctid ${c.status === 'fail' ? 't-red' : c.status === 'warn' ? 't-warn' : ''}`}>{c.checkId}</td>
64
+ <td className="cttitle">{name.title === c.checkId ? '' : name.title}</td>
65
+ <td className="acwhen">{c.lastAt == null ? '' : <TimeAgo clock={app.clock} at={c.lastAt} />}</td>
66
+ <td className="ctdur">
67
+ {c.lastMs == null ? null : <span className={`dnum ${tone ?? ''}`}>{fmtMs(c.lastMs)}</span>}
68
+ <span className="ctbudget">{` / ${fmtMs(c.budgetMs)}`}</span>
69
+ </td>
70
+ <td className="ctfind" title={c.lastLines.length > 0 ? c.lastLines.join('\n') : undefined}>{finding}</td>
71
+ <td className="ctplan">{planWord(c)}</td>
72
+ </tr>
73
+ )
74
+ })
75
+
76
+ export const CheckTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
77
+ const col = app.cols.sidebarCols.find((x) => x.id === id)
78
+ if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
79
+ const items = app.cols.visibleInstances(col)
80
+ const cols = (
81
+ <>
82
+ <col className="c-dot" /><col className="c-id" /><col className="c-title" /><col className="c-age" /><col className="c-dur" /><col /><col className="c-plan" />
83
+ </>
84
+ )
85
+ return (
86
+ <Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
87
+ {checkGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={7} row={(r) => <CheckRow key={r.id} app={app} r={r} />} />)}
88
+ </Page>
89
+ )
90
+ })
91
+
92
+ const AskRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
93
+ const a = r.ask
94
+ const done = r.id.startsWith('ask:done/')
95
+ const at = dayMs(done ? (a?.closed ?? a?.created) : a?.created)
96
+ const blocks = a == null || a.blocks === '-' ? '' : a.blocks
97
+ return (
98
+ <tr className={`ctrow s-${done ? 'done' : 'open'}`} data-k={r.id} onClick={() => app.cols.select(r.collection, r.id)} title={r.file}>
99
+ <td className={`ctkind ${done ? 'acdim' : ''}`}>{a?.kind ?? r.slot ?? ''}</td>
100
+ <td className={`cttitle ${done ? 'acdim' : ''}`}>{r.title}</td>
101
+ <td className="acwhen">{at == null ? '' : <TimeAgo clock={app.clock} at={at} />}</td>
102
+ <td className="ctblocks" title={blocks === '' ? undefined : blocks}>{blocks}</td>
103
+ {done ? <td /> : a?.probe != null
104
+ ? <td className="ctprobe t-ok" title={`closes itself once \`${a.probe}\` exits 0`}>auto close</td>
105
+ : <td className="ctprobe acdim" title="no probe: done by hand">manual</td>}
106
+ </tr>
107
+ )
108
+ })
109
+
110
+ export const AskTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
111
+ const col = app.cols.sidebarCols.find((x) => x.id === id)
112
+ if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
113
+ const items = app.cols.visibleInstances(col)
114
+ const cols = (
115
+ <>
116
+ <col className="c-kind" /><col /><col className="c-age" /><col className="c-blocks" /><col className="c-probe" />
117
+ </>
118
+ )
119
+ return (
120
+ <Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
121
+ {askGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={5} row={(r) => <AskRow key={r.id} app={app} r={r} />} />)}
122
+ </Page>
123
+ )
124
+ })
@@ -0,0 +1,115 @@
1
+ // The checks page and the asks page as dense tables: which group each line
2
+ // belongs to, the order inside a group, and every word a line says. Pure, so
3
+ // a test pins them rather than a screenshot (col-tables-view.tsx draws them).
4
+ import { parseDuration } from '../../core/wire.ts'
5
+ import { spanShort } from '../../plan/plan-timeline.ts'
6
+ import type { CachePop } from './check-words.ts'
7
+ import type { CheckFacts, Resource } from './hub-types.ts'
8
+
9
+ /** one group of lines under its header. `key` is what the viewer's fold
10
+ * choice is remembered under, with the collection before it */
11
+ export type TableGroup<K extends string> = { key: K; label: string; rows: Resource[]; foldedByDefault: boolean }
12
+
13
+ export type CheckGroupKey = 'fail' | 'warn' | 'never' | 'pass' | 'off'
14
+
15
+ /** the groups in the order a reader wants them: what needs attention first */
16
+ const CHECK_GROUPS: { key: CheckGroupKey; word: string; folded: boolean }[] = [
17
+ { key: 'fail', word: 'failing', folded: false },
18
+ { key: 'warn', word: 'warning', folded: false },
19
+ { key: 'never', word: 'never ran', folded: false },
20
+ { key: 'pass', word: 'passing', folded: true },
21
+ { key: 'off', word: 'off', folded: true },
22
+ ]
23
+
24
+ /** a skipped check had nothing to look at, which is nothing to fix: it sits
25
+ * with the passing ones, its dot grey */
26
+ export function checkGroupOf(c: CheckFacts): CheckGroupKey {
27
+ if (c.when === 'off') return 'off'
28
+ if (c.status === 'fail') return 'fail'
29
+ if (c.status === 'warn') return 'warn'
30
+ if (c.status == null) return 'never'
31
+ return 'pass'
32
+ }
33
+
34
+ const idOf = (r: Resource): string => r.check?.checkId ?? r.id
35
+
36
+ /** most recent first; a check with no result by its id */
37
+ const byRecent = (a: Resource, b: Resource): number => (b.check?.lastAt ?? -1) - (a.check?.lastAt ?? -1) || idOf(a).localeCompare(idOf(b))
38
+
39
+ export function checkGroups(rows: readonly Resource[]): TableGroup<CheckGroupKey>[] {
40
+ const withFacts = rows.filter((r) => r.check != null)
41
+ return CHECK_GROUPS.map((g) => {
42
+ const mine = withFacts.filter((r) => checkGroupOf(r.check!) === g.key).sort(byRecent)
43
+ return { key: g.key, label: `${mine.length} ${g.word}`, rows: mine, foldedByDefault: g.folded }
44
+ }).filter((g) => g.rows.length > 0)
45
+ }
46
+
47
+ /** a wire line as plain text: the markdown ticks it is rendered with go */
48
+ const plain = (l: string): string => l.replace(/`/g, '').trim()
49
+
50
+ const escape = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
51
+
52
+ /** a line without what its row already shows: the verdict word (the dot
53
+ * says it) and the check's own id (the id column says it) */
54
+ export function strippedLine(line: string, id: string): string {
55
+ const lead = new RegExp(`^(?:(?:FAIL|WARN|✗|⚠):?\\s*|\\[${escape(id)}\\]\\s*|${escape(id)}(?:\\s+—\\s+|:\\s+|\\s+|$))`)
56
+ let l = line
57
+ for (let m = l.match(lead); m != null && m[0] !== ''; m = l.match(lead)) l = l.slice(m[0].length)
58
+ return l.trim()
59
+ }
60
+
61
+ /** the first line of the last run that says something: a failure or a
62
+ * warning first, else any line that is not the bare `ok: <id>` header */
63
+ export function findingLine(c: CheckFacts): string {
64
+ const lines = c.lastLines.map(plain).filter((l) => l !== '')
65
+ const loud = lines.filter((l) => /^(FAIL|WARN|✗|⚠)/.test(l))
66
+ const rest = lines.filter((l) => !/^(FAIL|WARN|✗|⚠|ok\b|skip\b)/.test(l))
67
+ for (const l of [...loud, ...rest]) {
68
+ const s = strippedLine(l, c.checkId)
69
+ if (s !== '') return s
70
+ }
71
+ return ''
72
+ }
73
+
74
+ /** how a check is planned, as one short word a column scans: what makes it
75
+ * run again (a file change, a span of time, never again) or how often it
76
+ * runs when it remembers nothing */
77
+ export function planWord(u: CachePop): string {
78
+ if (u.when === 'off') return 'off'
79
+ if ((u.inputs ?? []).length > 0 || u.reads === true) return 'on change'
80
+ if (u.ttl === 'forever') return 'once'
81
+ if (u.ttl != null && u.ttl !== '') {
82
+ try { return `every ${spanShort(parseDuration(u.ttl))}` } catch { return `every ${u.ttl}` }
83
+ }
84
+ return u.when === 'release' ? 'every release' : u.when === 'health' ? 'health pass' : 'every run'
85
+ }
86
+
87
+ export type AskGroupKey = 'open' | 'done'
88
+
89
+ /** a `YYYY-MM-DD` stamp as ms, null when the file carries none */
90
+ export const dayMs = (d: string | null | undefined): number | null => {
91
+ if (d == null) return null
92
+ const t = Date.parse(d)
93
+ return Number.isNaN(t) ? null : t
94
+ }
95
+
96
+ const NOTHING = new Set(['', '-'])
97
+ const blocksOf = (r: Resource): string => r.ask?.blocks ?? '-'
98
+
99
+ /** open asks by what they block (an ask that blocks nothing last), the
100
+ * oldest first inside one; done asks after, the last closed first */
101
+ export function askGroups(rows: readonly Resource[]): TableGroup<AskGroupKey>[] {
102
+ const isDone = (r: Resource): boolean => r.id.startsWith('ask:done/')
103
+ const open = rows.filter((r) => !isDone(r)).sort((a, b) => {
104
+ const na = NOTHING.has(blocksOf(a)) ? 1 : 0
105
+ const nb = NOTHING.has(blocksOf(b)) ? 1 : 0
106
+ return na - nb || blocksOf(a).localeCompare(blocksOf(b)) || (dayMs(a.ask?.created) ?? 0) - (dayMs(b.ask?.created) ?? 0) || a.id.localeCompare(b.id)
107
+ })
108
+ const closedAt = (r: Resource): number => dayMs(r.ask?.closed) ?? dayMs(r.ask?.created) ?? 0
109
+ const done = rows.filter(isDone).sort((a, b) => closedAt(b) - closedAt(a) || a.id.localeCompare(b.id))
110
+ const groups: TableGroup<AskGroupKey>[] = [
111
+ { key: 'open', label: `${open.length} open`, rows: open, foldedByDefault: false },
112
+ { key: 'done', label: `${done.length} done`, rows: done, foldedByDefault: true },
113
+ ]
114
+ return groups.filter((g) => g.rows.length > 0)
115
+ }
@@ -9,6 +9,20 @@ import type { Col, ColMeta, HubProblem, Resource } from './hub-types.ts'
9
9
  import type { PlanRow } from '../../plan/planner.ts'
10
10
  import { readScreen, SCREEN_WRITE_MS, writeScreen } from './screen-cache.ts'
11
11
 
12
+ /** where a viewer's fold choices on the checks and asks tables are kept,
13
+ * one per browser and not per repo: `checks:pass` open is a habit */
14
+ export const TABLE_FOLDS_KEY = 'hub:table-folds'
15
+
16
+ const readFolds = (): Map<string, boolean> => {
17
+ try {
18
+ const v = JSON.parse(globalThis.localStorage?.getItem(TABLE_FOLDS_KEY) ?? '{}') as unknown
19
+ if (v == null || typeof v !== 'object') return new Map()
20
+ return new Map(Object.entries(v).filter((e): e is [string, boolean] => typeof e[1] === 'boolean'))
21
+ } catch {
22
+ return new Map()
23
+ }
24
+ }
25
+
12
26
  /** what /api/resources.json answers */
13
27
  type Answer = { collections: ColMeta[]; resources: Resource[]; planned?: PlanRow[] | null; hub?: HubProblem[] }
14
28
 
@@ -43,6 +57,9 @@ export class ColsSvc {
43
57
  proofDraft = ''
44
58
  /** user fold choices win; default = open when the section holds needs */
45
59
  foldOverride = new Map<string, boolean>()
60
+ /** the viewer's fold choices on the table groups (`checks:pass`,
61
+ * `asks:done`), true = folded; a group with none folds by its default */
62
+ tableFolds = readFolds()
46
63
  private timer: ReturnType<typeof setInterval> | null = null
47
64
 
48
65
  constructor(private st: RepoSt) {
@@ -177,6 +194,17 @@ export class ColsSvc {
177
194
  openSection(c: Col): void {
178
195
  this.foldOverride.set(c.id, true)
179
196
  }
197
+ /** a table group is folded: a live search shows every match, and a group
198
+ * the viewer never touched folds by its default */
199
+ groupFolded(key: string, byDefault: boolean): boolean {
200
+ if (this.st.query.trim() !== '') return false
201
+ return this.tableFolds.get(key) ?? byDefault
202
+ }
203
+ toggleGroup(key: string, byDefault: boolean): void {
204
+ if (this.st.query.trim() !== '') return
205
+ this.tableFolds.set(key, !this.groupFolded(key, byDefault))
206
+ try { globalThis.localStorage?.setItem(TABLE_FOLDS_KEY, JSON.stringify(Object.fromEntries(this.tableFolds))) } catch { /* private window: the choice lasts the tab */ }
207
+ }
180
208
  /** rail red dot — the section holds at least one red item */
181
209
  hasRed(c: Col): boolean {
182
210
  return c.instances.some((i) => i.severity === 'red')
@@ -31,5 +31,5 @@
31
31
  .dockanim>[data-panel]{transition:flex-grow 180ms ease-out}
32
32
  @media (prefers-reduced-motion:reduce){.dockanim>[data-panel]{transition:none}}
33
33
  /* the runs page's record chart: its (?) in its top left corner */
34
- .rchart{position:relative;flex:none;overflow:hidden;margin-bottom:var(--s2)}
34
+ .rchart{position:relative;flex:none;overflow:hidden;margin-top:var(--s3)}
35
35
  .rchelp{position:absolute;top:0;left:0;z-index:4}
@@ -68,7 +68,7 @@ export class HomeSt {
68
68
  planned: PlannedSt
69
69
  /** what every repo committed and holds, polled while it is the page */
70
70
  activity: ActivitySt
71
- /** the one picker, cmd+k */
71
+ /** the one picker, cmd+j */
72
72
  palette: PaletteSt
73
73
  /** the widths of the project list and a repo's sidebar */
74
74
  layout: LayoutSt
@@ -12,12 +12,12 @@
12
12
  .hswatch input::-webkit-color-swatch{border:none;border-radius:2px}
13
13
  .halert{display:inline-flex;align-items:center;font-size:12px;color:var(--errink)}
14
14
  .herr{font-size:12px;color:var(--errink);max-width:40ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
15
- /* the bottom line: its height is reserved whether or not it says anything,
16
- so the daemon or pm going down never moves the page. The strip renders it,
17
- its order puts it last in the page column (hub.css .appcol) */
18
- .hbar{order:1;display:flex;align-items:center;gap:var(--s3);height:22px;flex:none;padding:0 var(--s3);border-top:1px solid var(--line);overflow:hidden;white-space:nowrap;font-size:12px}
19
- .hdown{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--errink)}
20
- .hdown b{color:var(--red);margin-right:var(--s2)}
15
+ /* the daemon or pm down: toasts over the bottom right corner of the page
16
+ column (hub.css .appcol is their box), just above the dock, never taking
17
+ room, so one coming or going moves nothing */
18
+ .htoasts{position:absolute;right:var(--s3);bottom:var(--s3);z-index:25;display:flex;flex-direction:column;align-items:flex-end;gap:var(--s1);max-width:min(60ch,calc(100% - 2 * var(--s3)));pointer-events:none}
19
+ .htoast{max-width:100%;padding:var(--s1) var(--s2);background:var(--panel);border:1px solid var(--red);border-radius:4px;box-shadow:0 6px 20px var(--shadow);font-size:12px;color:var(--errink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:auto}
20
+ .htoast b{color:var(--red);margin-right:var(--s2)}
21
21
  .hdot{display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:var(--s1);background:var(--line)}
22
22
  .hdot.up{background:var(--ok)}.hdot.starting,.hdot.stopping{background:var(--warn)}.hdot.crashed{background:var(--red)}
23
23
  .hres{display:inline-flex;align-items:center;padding:var(--s0) var(--s2);font-size:12px}
@@ -30,7 +30,7 @@
30
30
  /* each widget card exactly as wide as what it holds; a widget's leading
31
31
  <header> joins the title line, dim */
32
32
  .hmon{display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--s2)}
33
- .hwidget{flex:0 1 auto;max-width:100%;padding:var(--s1) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.5;overflow:auto}
33
+ .hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
34
34
  .hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
35
35
  .hwidget h3 .hdim{font-weight:400}
36
36
  /* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
@@ -38,8 +38,13 @@
38
38
  .wmeters td:nth-child(3n){padding-right:var(--s3)}
39
39
  .wmeters td:last-child{padding-right:0}
40
40
  .wmeters .wbar{margin-right:0}
41
+ /* a meter's value: small, its digits one width so a column never jitters */
42
+ .wmeters .wv{font-size:11px;font-variant-numeric:tabular-nums}
41
43
  .hwidget table{border-collapse:collapse}
42
44
  .hwidget td{padding:0 var(--s3) 0 0;white-space:nowrap;vertical-align:middle}
45
+ /* a widget error: in its card where the html goes, or one mark after the cards */
46
+ .hwerr{font-size:12px;color:var(--warn);white-space:nowrap;cursor:help}
47
+ .hwerr .gpwarn{margin:0 var(--s1) 0 0}
43
48
  .hbad{color:var(--errink)}
44
49
  .hwait{flex:1;min-height:0;padding:var(--s4);color:var(--dim);font-size:13px;overflow:auto}
45
50
  .hwait p{margin:0 0 var(--s2)}
@@ -57,7 +62,7 @@
57
62
  sideways */
58
63
  .gpwrap{container-type:inline-size;font-size:13px}
59
64
  .c-gpname{width:180px}.c-gpver{width:56px}.c-gppid{width:64px}.c-gpstate{width:110px}
60
- .c-gpb.take{width:78px}.c-gpb.restart{width:64px}.c-gpb.stop{width:58px}.c-gpb.open{width:58px}
65
+ .c-gpb.take{width:78px}.c-gpb.stop,.c-gpb.restart{width:24px}
61
66
  .gptable td{vertical-align:middle}
62
67
  .gptable td.acname{vertical-align:top}
63
68
  .gpcore{color:var(--ink)}
@@ -78,20 +83,30 @@
78
83
  .gpstate{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums}
79
84
  .gpstate.up{color:var(--ok)}.gpstate.crashed{color:var(--red)}.gpstate.starting{color:var(--warn)}
80
85
  .gptable td.gpb{padding-left:0;padding-right:var(--s1);text-align:right;overflow:visible}
81
- .gpb button{padding:0 var(--s2);font-size:12px}
86
+ .gptable td.gpb.stop,.gptable td.gpb.restart{padding:0;text-align:center}
87
+ /* a small icon button: a process's start or stop, its restart, the open before its url */
88
+ .gpi{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:3px;background:none;color:var(--dim);cursor:pointer;vertical-align:middle}
89
+ .gpi .hico{width:14px;height:14px;color:inherit}
90
+ .gpi:hover:not(:disabled){color:var(--ink);background:var(--panel2)}
91
+ .gpi.go{color:var(--ok)}
92
+ .gpi:disabled{opacity:.35;cursor:default}
93
+ .gphost .gpi{margin-right:var(--s1)}
94
+ /* the app button while its app builds: a spinner, never faded */
95
+ .gpi[aria-busy]:disabled{opacity:1;cursor:progress}
82
96
  .gpb button.hsm{padding:0 var(--s1);font-size:11px}
83
97
  .gpb.act{white-space:nowrap}
84
98
  .gpb.act button + button{margin-left:var(--s1)}
85
- .gphead div{display:flex;justify-content:flex-end;align-items:center;gap:var(--s1)}
99
+ .gphead div{display:flex;align-items:center;gap:var(--s1)}
100
+ .gphead .sp{flex:1}
86
101
  .gphead button.hsm{padding:0 var(--s1);font-size:11px}
87
- .gpnote{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
102
+ /* what a process has to say (its note, a refused take over): a mark after
103
+ its state words, the words on hover, never a line of its own */
104
+ .gpwarn{display:inline-block;vertical-align:middle;width:6px;height:6px;margin-left:var(--s1);border-radius:1px;background:var(--warn)}
105
+ .gpstate span[title]{cursor:help}
88
106
  @container (max-width:620px){.c-gphost,.gphost,.c-gpver,.gpver,.c-gppid,.gppid{display:none}.gptable .c-name{width:96px}.c-gpname{width:110px}.c-gpstate{width:88px}}
89
107
  /* a phone: the state words go too, the dot and the tint still say it, and
90
- only start (stop) and open stay, the label taking what is left */
91
- @container (max-width:440px){.c-gpstate,.gpstate,.c-gpb.act,.gpb.act,.c-gpb.take,.gpb.take,.c-gpb.restart,.gpb.restart,.gphead button.hsm{display:none}.gptable .c-name{width:64px}.c-gpname{width:auto}.c-gpb.stop,.c-gpb.open{width:50px}}
92
- .hopen{margin-left:var(--s2)}
93
- .hopen button{display:inline-flex;align-items:center;padding:var(--s0) var(--s1)}
94
- .hopen .hico{color:inherit}
108
+ only start (stop) stays, the label taking what is left */
109
+ @container (max-width:440px){.c-gpstate,.gpstate,.c-gpb.act,.gpb.act,.c-gpb.take,.gpb.take,.c-gpb.restart,.gpb.restart{display:none}.gptable .c-name{width:64px}.c-gpname{width:auto}}
95
110
  .hsec{min-width:0}
96
111
  .hsec h2{display:flex;align-items:center;gap:var(--s2);height:28px;margin:0 0 var(--s2);padding-bottom:var(--s1);border-bottom:1px solid var(--line);
97
112
  font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600}
@@ -107,7 +122,7 @@
107
122
  .hnote{font-size:12px;color:var(--warn)}
108
123
  /* a filled bar any widget can draw: <span class="wbar" style="--p:51%"><i></i></span>,
109
124
  green below 70%, `mid` from 70%, `hi` from 90% (the widget picks the class) */
110
- .wbar{display:inline-block;vertical-align:middle;width:56px;height:7px;margin-right:var(--s2);border-radius:4px;background:var(--line);overflow:hidden}
125
+ .wbar{display:inline-block;vertical-align:middle;width:40px;height:7px;margin-right:var(--s2);border-radius:4px;background:var(--line);overflow:hidden}
111
126
  .wbar > i{display:block;height:100%;width:var(--p,0%);background:var(--ok)}
112
127
  .wbar.mid > i{background:var(--warn)}
113
128
  .wbar.hi > i{background:var(--red)}