rvlib-shipkit 1.1.0 → 1.2.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/CHANGELOG.md +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
package/src/repo-config.ts
CHANGED
|
@@ -59,6 +59,27 @@ export type ServeConfig = {
|
|
|
59
59
|
* prints beside localhost and rides on every ping message, because a
|
|
60
60
|
* phone cannot resolve localhost */
|
|
61
61
|
publicHost?: string
|
|
62
|
+
/** the long running processes this repo needs (a dev server, a watcher).
|
|
63
|
+
* The hub supervises them: a switch each on its page, a crash restarts,
|
|
64
|
+
* and they outlive a hub restart */
|
|
65
|
+
processes?: ProcessDecl[]
|
|
66
|
+
/** the ids of the global processes this repo needs (any house repo's
|
|
67
|
+
* `globalProcess` unit files). The home rail marks the repo while one is off */
|
|
68
|
+
uses?: string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** one long running process a repo needs, supervised by its hub */
|
|
72
|
+
export type ProcessDecl = {
|
|
73
|
+
/** kebab-case, unique in this repo */
|
|
74
|
+
id: string
|
|
75
|
+
/** the command as a list of words, run from the repo root */
|
|
76
|
+
cmd: string[]
|
|
77
|
+
/** where it runs, repo-relative (default the repo root) */
|
|
78
|
+
cwd?: string
|
|
79
|
+
/** the url it serves, probed for up and shown as a link */
|
|
80
|
+
url?: string
|
|
81
|
+
/** starts with the hub. Default false: it starts from its switch */
|
|
82
|
+
auto?: boolean
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
export type RepoConfig = {
|
|
@@ -250,13 +271,34 @@ export type RepoConfig = {
|
|
|
250
271
|
}
|
|
251
272
|
/** the `shipkit serve` hub: port + optional graph hooks */
|
|
252
273
|
serve?: ServeConfig
|
|
274
|
+
/** the repo's own colours, one #rrggbb hex each: colorLight reads on a
|
|
275
|
+
* light background, colorDark on a dark one. Every page that shows the
|
|
276
|
+
* repo draws its name and its rail tile in them. Rule SK024 warns when
|
|
277
|
+
* they are missing, and its fix derives them from the repo icon */
|
|
278
|
+
identity?: {
|
|
279
|
+
/** #rrggbb that reads on a light background */
|
|
280
|
+
colorLight: string
|
|
281
|
+
/** #rrggbb that reads on a dark background: the home page draws this one */
|
|
282
|
+
colorDark: string
|
|
283
|
+
}
|
|
253
284
|
/** where a gate run writes its per task logs, repo-relative (default
|
|
254
285
|
* '.shipkit/tmp/shipkit-ci'). Each run owns a subdir of it, so several agents in
|
|
255
286
|
* one checkout never overwrite each other. Keep it gitignored. */
|
|
256
287
|
logDir?: string
|
|
257
288
|
}
|
|
258
289
|
|
|
259
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
290
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
|
|
291
|
+
|
|
292
|
+
const KEBAB = /^[a-z0-9][a-z0-9-]*$/
|
|
293
|
+
const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
|
|
294
|
+
|
|
295
|
+
/** why a process declaration cannot run, or null */
|
|
296
|
+
export function processProblem(d: ProcessDecl): string | null {
|
|
297
|
+
if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
|
|
298
|
+
if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
|
|
299
|
+
if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
260
302
|
|
|
261
303
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
262
304
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -266,11 +308,18 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
266
308
|
// a half-declared reviewer would lint a path like `.claude/agents/.md`
|
|
267
309
|
if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
|
|
268
310
|
throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
|
|
311
|
+
const pids = new Set<string>()
|
|
312
|
+
for (const d of c.serve?.processes ?? []) {
|
|
313
|
+
const why = processProblem(d)
|
|
314
|
+
if (why != null) throw new Error(`defineRepo: ${why}`)
|
|
315
|
+
if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
|
|
316
|
+
pids.add(d.id)
|
|
317
|
+
}
|
|
269
318
|
// unknown keys die loud — a typo'd or removed key silently changing the
|
|
270
319
|
// gate is exactly the never-silence failure mode (A23)
|
|
271
320
|
for (const k of Object.keys(c))
|
|
272
321
|
if (!KNOWN_KEYS.has(k))
|
|
273
|
-
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
|
|
322
|
+
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
|
|
274
323
|
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
275
324
|
for (const u of c.units ?? []) {
|
|
276
325
|
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
|
@@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'
|
|
|
8
8
|
import { CACHED } from '../../core/outcome-words.ts'
|
|
9
9
|
import { durationTone, fmtMs } from './dur.ts'
|
|
10
10
|
import { Help, TERMS } from './help.tsx'
|
|
11
|
-
import type {
|
|
11
|
+
import type { RepoSt } from './repo-st.ts'
|
|
12
12
|
import type { SlowSuite, CheckFacts, RecentRun, WhenAt } from './hub-types.ts'
|
|
13
13
|
import { renderMd, renderMdInline } from './md.ts'
|
|
14
14
|
import { nextRun } from './next-run.ts'
|
|
@@ -89,7 +89,7 @@ export const copy = (text: string): void => {
|
|
|
89
89
|
|
|
90
90
|
/** THE COMMAND, exactly as the gate spawns it from the repo root, or the one
|
|
91
91
|
* line saying its body is a function inside the gate */
|
|
92
|
-
export const CommandLine = ({ app, u }: { app:
|
|
92
|
+
export const CommandLine = ({ app, u }: { app: RepoSt; u: CheckFacts }) => {
|
|
93
93
|
const c = commandOf(u, app.root)
|
|
94
94
|
if (c.kind === 'in-process')
|
|
95
95
|
return (
|
|
@@ -132,7 +132,7 @@ export const CommandLine = ({ app, u }: { app: HubSt; u: CheckFacts }) => {
|
|
|
132
132
|
* through the managed block, and leaves the file dirty: a change of place is
|
|
133
133
|
* a change its owner reviews in a git client, never a commit made on their
|
|
134
134
|
* behalf. Choosing never asks for the reason before it writes. */
|
|
135
|
-
const RunWhen = observer(({ app, u }: { app:
|
|
135
|
+
const RunWhen = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
|
|
136
136
|
const s = app.checks
|
|
137
137
|
const g = s.gate
|
|
138
138
|
const shown = s.whenShown(u)
|
|
@@ -193,7 +193,7 @@ const Bars = ({ runs, ceiling, budget }: { runs: RecentRun[]; ceiling: number; b
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/** how the last answer came: `ran 3m ago in 412ms`, or `cached 16m ago` */
|
|
196
|
-
export const LastRunValue = observer(({ app, u }: { app:
|
|
196
|
+
export const LastRunValue = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
|
|
197
197
|
const l = lastRun(u)
|
|
198
198
|
if (l == null) return <span className="dim">never here</span>
|
|
199
199
|
return (
|
|
@@ -245,7 +245,7 @@ const LogLines = ({ lines }: { lines: string[] }) => (
|
|
|
245
245
|
|
|
246
246
|
/** the lines of the run in flight, then the lines of the last one. It
|
|
247
247
|
* scrolls itself while a run streams, so the newest line is on screen */
|
|
248
|
-
const LastOutput = observer(({ app, lines }: { app:
|
|
248
|
+
const LastOutput = observer(({ app, lines }: { app: RepoSt; lines: string[] }) => {
|
|
249
249
|
const live = app.checks.liveLines
|
|
250
250
|
const box = useRef<HTMLPreElement>(null)
|
|
251
251
|
useEffect(() => { if (box.current != null) box.current.scrollTop = box.current.scrollHeight }, [live.length])
|
|
@@ -263,7 +263,7 @@ const LastOutput = observer(({ app, lines }: { app: HubSt; lines: string[] }) =>
|
|
|
263
263
|
/** the slow suites declared under the test step, each with the case it
|
|
264
264
|
* argued. Moving a suite between the tiers is the SAME managed block write,
|
|
265
265
|
* and it demands both answers exactly as `defineRepo` does. */
|
|
266
|
-
const SlowSuites = observer(({ app }: { app:
|
|
266
|
+
const SlowSuites = observer(({ app }: { app: RepoSt }) => {
|
|
267
267
|
const s = app.checks
|
|
268
268
|
const declared: SlowSuite[] = s.gate?.tests.slow ?? []
|
|
269
269
|
return (
|
|
@@ -294,7 +294,7 @@ const SlowSuites = observer(({ app }: { app: HubSt }) => {
|
|
|
294
294
|
|
|
295
295
|
// ── the pages ────────────────────────────────────────────────────────────
|
|
296
296
|
|
|
297
|
-
export const CheckCard = observer(({ app, id }: { app:
|
|
297
|
+
export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
298
298
|
const i = app.cols.find('checks', id)
|
|
299
299
|
if (i == null) return <div className="ph">check vanished, the inventory moved on</div>
|
|
300
300
|
const u = i.check
|
|
@@ -325,8 +325,8 @@ export const CheckCard = observer(({ app, id }: { app: HubSt; id: string }) => {
|
|
|
325
325
|
<Remembered u={u} />
|
|
326
326
|
{/* the help never depends on the second, so it reads the wall
|
|
327
327
|
clock and the row does not repaint on every tick */}
|
|
328
|
-
<Row k="next run" help={nextRun(u, app.cols.
|
|
329
|
-
<TimeAhead clock={app.clock} words={(now) => nextRun(u, app.cols.
|
|
328
|
+
<Row k="next run" help={nextRun(u, app.cols.planOf(u.checkId), Date.now()).help}>
|
|
329
|
+
<TimeAhead clock={app.clock} words={(now) => nextRun(u, app.cols.planOf(u.checkId), now)} />
|
|
330
330
|
</Row>
|
|
331
331
|
<Row k="last run" help={u.status == null
|
|
332
332
|
? 'no run of it is on record in this checkout'
|
|
@@ -18,7 +18,7 @@ const list = (xs: string[]): string => (xs.length < 2 ? xs.join('') : `${xs.slic
|
|
|
18
18
|
const CADENCE: Record<WhenAt, string> = {
|
|
19
19
|
always: 'on every run',
|
|
20
20
|
release: 'on every release run',
|
|
21
|
-
health: '
|
|
21
|
+
health: 'each time the plan runs it',
|
|
22
22
|
off: 'nowhere, it is off',
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// dropped when the run ends and the next poll brings the same facts back
|
|
9
9
|
// from the one place that owns them.
|
|
10
10
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
11
|
-
import type {
|
|
11
|
+
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'
|
|
@@ -42,6 +42,10 @@ export class ChecksSvc {
|
|
|
42
42
|
* stay after the run: they ARE the last output until the next one */
|
|
43
43
|
liveLines: string[] = []
|
|
44
44
|
private clearTimer: ReturnType<typeof setTimeout> | null = null
|
|
45
|
+
|
|
46
|
+
dispose(): void {
|
|
47
|
+
if (this.clearTimer != null) clearTimeout(this.clearTimer)
|
|
48
|
+
}
|
|
45
49
|
/** the managed block in repo.config.ts as the server last sent it: the
|
|
46
50
|
* hash the next write hands back, and whether the file is waiting for
|
|
47
51
|
* a commit. null until the first read */
|
|
@@ -56,7 +60,7 @@ export class ChecksSvc {
|
|
|
56
60
|
/** the slow suite form under the test check */
|
|
57
61
|
suite: SlowSuite = { file: '', catches: '', whyNotFast: '' }
|
|
58
62
|
|
|
59
|
-
constructor(private st:
|
|
63
|
+
constructor(private st: RepoSt) {
|
|
60
64
|
makeAutoObservable<this, 'st' | 'clearTimer'>(this, { st: false, clearTimer: false })
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -89,7 +93,7 @@ export class ChecksSvc {
|
|
|
89
93
|
* silent overwrite */
|
|
90
94
|
async loadGate(): Promise<void> {
|
|
91
95
|
try {
|
|
92
|
-
const r = await fetch('/api/checks/when')
|
|
96
|
+
const r = await fetch(this.st.url('/api/checks/when'))
|
|
93
97
|
if (!r.ok) throw new Error(await r.text())
|
|
94
98
|
const b = (await r.json()) as GateBlock
|
|
95
99
|
runInAction(() => { this.gate = b })
|
|
@@ -109,14 +113,14 @@ export class ChecksSvc {
|
|
|
109
113
|
// the hash of the file the page last read: without it a write
|
|
110
114
|
// could land on top of an edit this page never saw
|
|
111
115
|
if (this.gate == null) await this.loadGate()
|
|
112
|
-
const r = await fetch('/api/checks/when', { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
|
|
116
|
+
const r = await fetch(this.st.url('/api/checks/when'), { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
|
|
113
117
|
if (!r.ok) throw new Error(await r.text())
|
|
114
118
|
const b = (await r.json()) as GateBlock
|
|
115
119
|
runInAction(() => { this.gate = b; this.gateNote = said })
|
|
116
120
|
took = true
|
|
117
121
|
// the rows carry `when`, so they are right only after the server
|
|
118
122
|
// has read the config it just wrote
|
|
119
|
-
await this.st.cols.poll()
|
|
123
|
+
await this.st.cols.poll(true)
|
|
120
124
|
} catch (e) {
|
|
121
125
|
runInAction(() => { this.gateNote = `refused: ${(e as Error).message}` })
|
|
122
126
|
// whatever the file really says now, so the next try is not
|
|
@@ -227,7 +231,7 @@ export class ChecksSvc {
|
|
|
227
231
|
this.note = `running ${label}…`
|
|
228
232
|
})
|
|
229
233
|
try {
|
|
230
|
-
const r = await fetch('/api/check/run', { method: 'POST', body: JSON.stringify({ ...(only == null ? {} : { only }), ...(gate ? { gate } : {}) }) })
|
|
234
|
+
const r = await fetch(this.st.url('/api/check/run'), { method: 'POST', body: JSON.stringify({ ...(only == null ? {} : { only }), ...(gate ? { gate } : {}) }) })
|
|
231
235
|
const rd = r.body!.getReader()
|
|
232
236
|
const dec = new TextDecoder()
|
|
233
237
|
let buf = ''
|
|
@@ -270,9 +274,9 @@ export class ChecksSvc {
|
|
|
270
274
|
this.clearTimer = setTimeout(() => runInAction(() => { this.progress = idleRun() }), 5_000)
|
|
271
275
|
// the numbers a run just produced live on the server, in the log it
|
|
272
276
|
// wrote: one poll and the page is reading them rather than a memory
|
|
273
|
-
await this.st.cols.poll()
|
|
277
|
+
await this.st.cols.poll(true)
|
|
274
278
|
void this.st.health.load()
|
|
275
|
-
void this.st.timeline.load()
|
|
279
|
+
void this.st.timeline.load(true)
|
|
276
280
|
// a test page on screen reads the record the run just wrote
|
|
277
281
|
const sel = this.st.sel
|
|
278
282
|
if (sel?.t === 'tests') void this.st.tests.load(sel.check)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// the file wins).
|
|
6
6
|
import { runInAction } from 'mobx'
|
|
7
7
|
import { observer } from 'mobx-react-lite'
|
|
8
|
-
import type {
|
|
8
|
+
import type { RepoSt } from './repo-st.ts'
|
|
9
9
|
import type { Resource } from './hub-types.ts'
|
|
10
10
|
import { renderMd } from './md.ts'
|
|
11
11
|
|
|
@@ -24,7 +24,7 @@ const CredHead = ({ cred }: { cred: ReturnType<typeof credOf> }) => (
|
|
|
24
24
|
</>
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
export const ColCard = observer(({ app, col, id }: { app:
|
|
27
|
+
export const ColCard = observer(({ app, col, id }: { app: RepoSt; col: string; id: string }) => {
|
|
28
28
|
const c = app.cols
|
|
29
29
|
const i = c.find(col, id)
|
|
30
30
|
if (i == null) return <div className="ph">item vanished — the truth file moved on</div>
|
|
@@ -103,7 +103,7 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
|
|
|
103
103
|
|
|
104
104
|
// collection view — one card per visible instance, the stage-side twin of a
|
|
105
105
|
// sidebar section (every section gets both views)
|
|
106
|
-
export const ColGrid = observer(({ app, id }: { app:
|
|
106
|
+
export const ColGrid = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
107
107
|
const c = app.cols
|
|
108
108
|
const col = c.sidebarCols.find((x) => x.id === id)
|
|
109
109
|
if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
|
|
@@ -155,7 +155,7 @@ const wireClass = (l: string): string => {
|
|
|
155
155
|
|
|
156
156
|
// the RAW view of a run: every check has its own page, and this is the one
|
|
157
157
|
// node that keeps the whole text reachable
|
|
158
|
-
export const StatusCard = observer(({ app }: { app:
|
|
158
|
+
export const StatusCard = observer(({ app }: { app: RepoSt }) => {
|
|
159
159
|
const h = app.health
|
|
160
160
|
const body = h.live.length > 0
|
|
161
161
|
? h.live.join('\n')
|
|
@@ -4,20 +4,40 @@
|
|
|
4
4
|
// file through /api/act/*; errors come back as 400 text shown verbatim on
|
|
5
5
|
// the card.
|
|
6
6
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
-
import type {
|
|
8
|
-
import type { Col, ColMeta, HubProblem, Resource
|
|
7
|
+
import type { RepoSt } from './repo-st.ts'
|
|
8
|
+
import type { Col, ColMeta, HubProblem, Resource } from './hub-types.ts'
|
|
9
|
+
import type { PlanRow } from '../../plan/planner.ts'
|
|
10
|
+
import { readScreen, SCREEN_WRITE_MS, writeScreen } from './screen-cache.ts'
|
|
11
|
+
|
|
12
|
+
/** what /api/resources.json answers */
|
|
13
|
+
type Answer = { collections: ColMeta[]; resources: Resource[]; planned?: PlanRow[] | null; hub?: HubProblem[] }
|
|
9
14
|
|
|
10
15
|
export class ColsSvc {
|
|
11
16
|
/** exactly what the server last sent; `resources` is this plus the live
|
|
12
17
|
* results of a run in flight */
|
|
13
18
|
fetched: Resource[] = []
|
|
14
19
|
colMeta: ColMeta[] = []
|
|
15
|
-
/**
|
|
16
|
-
|
|
20
|
+
/** this repo's rows of the plan, null when no plan covers it */
|
|
21
|
+
planned: PlanRow[] | null = null
|
|
17
22
|
/** what the server says is wrong with the hub itself */
|
|
18
23
|
hubProblems: HubProblem[] = []
|
|
19
24
|
/** the last poll failed: the page cannot reach its own server */
|
|
20
25
|
fetchErr: string | null = null
|
|
26
|
+
/** a first answer arrived: until then the tree and the page links show a
|
|
27
|
+
* spinner where they will be, never an empty repo */
|
|
28
|
+
loaded = false
|
|
29
|
+
/** what shows was kept by this browser from an earlier load, and the
|
|
30
|
+
* hub has not answered yet (screen-cache.ts) */
|
|
31
|
+
cached = false
|
|
32
|
+
/** the first media category the kept screen had, until the media
|
|
33
|
+
* heartbeat lists the assets */
|
|
34
|
+
cachedCat: string | null = null
|
|
35
|
+
/** when this repo's screen was last kept, 0 for never */
|
|
36
|
+
private keptAt = 0
|
|
37
|
+
/** the last answer, and the media category last kept with it */
|
|
38
|
+
private last: Answer | null = null
|
|
39
|
+
private keptCat: string | null | undefined = undefined
|
|
40
|
+
private restoring: Promise<void> | null = null
|
|
21
41
|
actErr = ''
|
|
22
42
|
remarkDraft = ''
|
|
23
43
|
proofDraft = ''
|
|
@@ -25,30 +45,89 @@ export class ColsSvc {
|
|
|
25
45
|
foldOverride = new Map<string, boolean>()
|
|
26
46
|
private timer: ReturnType<typeof setInterval> | null = null
|
|
27
47
|
|
|
28
|
-
constructor(private st:
|
|
29
|
-
makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false })
|
|
48
|
+
constructor(private st: RepoSt) {
|
|
49
|
+
makeAutoObservable<this, 'st' | 'timer' | 'keptAt' | 'restoring' | 'readKept' | 'last' | 'keptCat'>(this, { st: false, timer: false, keptAt: false, restoring: false, readKept: false, last: false, keptCat: false })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** one check's row of the plan, null when no plan covers it */
|
|
53
|
+
planOf(check: string): PlanRow | null {
|
|
54
|
+
return this.planned?.find((r) => r.check === check) ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** where this repo's screen is kept: its name, or `self` on a hub of its own */
|
|
58
|
+
private get screenKey(): string {
|
|
59
|
+
return this.st.key === '' ? 'self' : this.st.key
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** paint the screen this browser kept, unless the hub answered first.
|
|
63
|
+
* One read per repo, whoever asks */
|
|
64
|
+
restore(): Promise<void> {
|
|
65
|
+
this.restoring ??= this.readKept()
|
|
66
|
+
return this.restoring
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async readKept(): Promise<void> {
|
|
70
|
+
const s = await readScreen(this.screenKey)
|
|
71
|
+
if (s == null) return
|
|
72
|
+
runInAction(() => {
|
|
73
|
+
if (this.loaded) return
|
|
74
|
+
this.colMeta = s.collections
|
|
75
|
+
this.fetched = s.resources
|
|
76
|
+
this.planned = s.planned
|
|
77
|
+
this.hubProblems = s.hub
|
|
78
|
+
this.cachedCat = s.mediaCat
|
|
79
|
+
this.cached = true
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** keep the screen for the next load: the first answer, then at most
|
|
84
|
+
* once a minute. The answer as parsed, never the observable copy, which
|
|
85
|
+
* IndexedDB cannot clone */
|
|
86
|
+
private keep(j: Answer, cat: string | null = this.st.media.assets[0]?.cat ?? this.cachedCat, force = false): void {
|
|
87
|
+
this.last = j
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
if (!force && now - this.keptAt < SCREEN_WRITE_MS) return
|
|
90
|
+
this.keptAt = now
|
|
91
|
+
this.keptCat = cat
|
|
92
|
+
void writeScreen(this.screenKey, { at: now, collections: j.collections, resources: j.resources, planned: j.planned ?? null, hub: j.hub ?? [], mediaCat: cat })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** the media heartbeat listed the assets: a first category the kept
|
|
96
|
+
* screen does not name yet is kept at once, so the header's media link
|
|
97
|
+
* is there on the next load */
|
|
98
|
+
mediaListed(): void {
|
|
99
|
+
const cat = this.st.media.assets[0]?.cat ?? null
|
|
100
|
+
if (this.last != null && cat !== this.keptCat) this.keep(this.last, cat, true)
|
|
30
101
|
}
|
|
31
102
|
|
|
32
103
|
start(): void {
|
|
104
|
+
if (this.timer != null) return
|
|
33
105
|
void this.poll()
|
|
34
106
|
this.timer = setInterval(() => void this.poll(), 10_000)
|
|
35
107
|
}
|
|
36
|
-
|
|
108
|
+
stop(): void {
|
|
37
109
|
if (this.timer != null) clearInterval(this.timer)
|
|
110
|
+
this.timer = null
|
|
38
111
|
}
|
|
39
112
|
|
|
40
|
-
|
|
113
|
+
/** `fresh`: this page just changed the truth (an act, a run that ended),
|
|
114
|
+
* so the answer waits on a walk that includes it rather than on the one
|
|
115
|
+
* the server keeps warm */
|
|
116
|
+
async poll(fresh = false): Promise<void> {
|
|
41
117
|
try {
|
|
42
|
-
const r = await fetch('/api/resources.json')
|
|
118
|
+
const r = await fetch(this.st.url(fresh ? '/api/resources.json?fresh=1' : '/api/resources.json'))
|
|
43
119
|
if (!r.ok) throw new Error(await r.text())
|
|
44
|
-
const j = (await r.json()) as
|
|
120
|
+
const j = (await r.json()) as Answer
|
|
45
121
|
runInAction(() => {
|
|
46
122
|
this.colMeta = j.collections
|
|
47
123
|
this.fetched = j.resources
|
|
48
|
-
this.
|
|
124
|
+
this.planned = j.planned ?? null
|
|
49
125
|
this.hubProblems = j.hub ?? []
|
|
50
126
|
this.fetchErr = null
|
|
127
|
+
this.loaded = true
|
|
128
|
+
this.cached = false
|
|
51
129
|
})
|
|
130
|
+
this.keep(j)
|
|
52
131
|
} catch (e) {
|
|
53
132
|
// a dead server must SAY so, in the header, never leave a tree
|
|
54
133
|
// that reads like a healthy repo. The last rows stay, dated by it
|
|
@@ -130,9 +209,9 @@ export class ColsSvc {
|
|
|
130
209
|
private async act(path: string, body: Record<string, unknown>): Promise<boolean> {
|
|
131
210
|
this.actErr = ''
|
|
132
211
|
try {
|
|
133
|
-
const r = await fetch(`/api/act/${path}
|
|
212
|
+
const r = await fetch(this.st.url(`/api/act/${path}`), { method: 'POST', body: JSON.stringify(body) })
|
|
134
213
|
if (!r.ok) throw new Error(await r.text())
|
|
135
|
-
await this.poll()
|
|
214
|
+
await this.poll(true)
|
|
136
215
|
return true
|
|
137
216
|
} catch (e) {
|
|
138
217
|
runInAction(() => { this.actErr = (e as Error).message })
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// it is something the panel SAID: the header never reports a state it guessed
|
|
5
5
|
// from how the iframe looks.
|
|
6
6
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
-
import type {
|
|
7
|
+
import type { RepoSt } from './repo-st.ts'
|
|
8
8
|
|
|
9
9
|
export type ComfyBox = { url: string; up: boolean; err: string | null }
|
|
10
10
|
/** what the panel says, narrowed to the messages this host acts on */
|
|
@@ -37,10 +37,16 @@ export class ComfySvc {
|
|
|
37
37
|
private mountedFor = ''
|
|
38
38
|
private frame: HTMLIFrameElement | null = null
|
|
39
39
|
private handshake: ReturnType<typeof setTimeout> | null = null
|
|
40
|
+
private readonly listener = (e: MessageEvent): void => this.onMessage(e)
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
dispose(): void {
|
|
43
|
+
window.removeEventListener('message', this.listener)
|
|
44
|
+
if (this.handshake != null) clearTimeout(this.handshake)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
constructor(private st: RepoSt) {
|
|
48
|
+
makeAutoObservable<this, 'st' | 'frame' | 'handshake' | 'listener'>(this, { st: false, frame: false, handshake: false, listener: false })
|
|
49
|
+
window.addEventListener('message', this.listener)
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
setFrame(el: HTMLIFrameElement | null): void {
|
|
@@ -78,7 +84,7 @@ export class ComfySvc {
|
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
async probeBox(): Promise<void> {
|
|
81
|
-
const box = await fetch('/api/comfy/box')
|
|
87
|
+
const box = await fetch(this.st.url('/api/comfy/box'))
|
|
82
88
|
.then((r) => (r.ok ? (r.json() as Promise<ComfyBox>) : null))
|
|
83
89
|
.catch(() => null)
|
|
84
90
|
runInAction(() => {
|
|
@@ -107,7 +113,7 @@ export class ComfySvc {
|
|
|
107
113
|
})
|
|
108
114
|
void this.probeBox()
|
|
109
115
|
try {
|
|
110
|
-
const r = await fetch('/api/comfy/panel', { method: 'POST', body: JSON.stringify({ restart }) })
|
|
116
|
+
const r = await fetch(this.st.url('/api/comfy/panel'), { method: 'POST', body: JSON.stringify({ restart }) })
|
|
111
117
|
if (!r.ok) throw new Error(await r.text())
|
|
112
118
|
const up = (await r.json()) as { url: string }
|
|
113
119
|
runInAction(() => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { observer } from 'mobx-react-lite'
|
|
5
5
|
import { useEffect } from 'react'
|
|
6
6
|
import type { ComfySvc } from './comfy-svc.ts'
|
|
7
|
-
import type {
|
|
7
|
+
import type { RepoSt } from './repo-st.ts'
|
|
8
8
|
|
|
9
9
|
const PANEL_CHIP: Record<ComfySvc['state'], { label: string; cls: string; title: string }> = {
|
|
10
10
|
idle: { label: 'idle', cls: '', title: 'nothing started yet' },
|
|
@@ -16,7 +16,7 @@ const PANEL_CHIP: Record<ComfySvc['state'], { label: string; cls: string; title:
|
|
|
16
16
|
|
|
17
17
|
// the same panel over an asset page. A popup, so the asset, its rolls and its
|
|
18
18
|
// prompt stay on screen behind it while a value is tried
|
|
19
|
-
export const ComfyOverlay = observer(({ app }: { app:
|
|
19
|
+
export const ComfyOverlay = observer(({ app }: { app: RepoSt }) => {
|
|
20
20
|
const c = app.comfy
|
|
21
21
|
if (!c.overlay) return null
|
|
22
22
|
const chip = PANEL_CHIP[c.state]
|
|
@@ -47,7 +47,7 @@ export const ComfyOverlay = observer(({ app }: { app: HubSt }) => {
|
|
|
47
47
|
)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
|
-
export const ComfyView = observer(({ app, id }: { app:
|
|
50
|
+
export const ComfyView = observer(({ app, id }: { app: RepoSt; id: string }) => {
|
|
51
51
|
const c = app.comfy
|
|
52
52
|
const wf = id.replace(/^comfy:/, '')
|
|
53
53
|
useEffect(() => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// THE way the page shows anything that floats over it: a tooltip, a help
|
|
2
|
+
// bubble, a process log on hover, a hover card. @floating-ui/react places it
|
|
3
|
+
// (flip to stay in the window, shift off its edges, an offset from the
|
|
4
|
+
// trigger), a portal lifts it out of every box that clips, and hover opens
|
|
5
|
+
// it after a short delay, so a pointer passing by never flickers one open.
|
|
6
|
+
// Nothing here is positioned by hand.
|
|
7
|
+
import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
|
|
8
|
+
import { observer, useLocalObservable } from 'mobx-react-lite'
|
|
9
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
/** how long a pointer rests on a trigger before its float opens */
|
|
12
|
+
export const OPEN_MS = 150
|
|
13
|
+
|
|
14
|
+
/** the placement middleware every float shares; `fallbacks` are the sides
|
|
15
|
+
* tried in order when the chosen one does not fit */
|
|
16
|
+
export const middleware = (gap = 6, fallbacks?: Placement[]) => [offset(gap), flip({ padding: 8, fallbackPlacements: fallbacks }), shift({ padding: 8 })]
|
|
17
|
+
|
|
18
|
+
type TipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'content'> & {
|
|
19
|
+
/** what floats; nothing floats while it is null */
|
|
20
|
+
content: ReactNode
|
|
21
|
+
/** the class of the floating box */
|
|
22
|
+
tipClass?: string
|
|
23
|
+
placement?: Placement
|
|
24
|
+
/** the sides tried in order when `placement` does not fit */
|
|
25
|
+
fallbacks?: Placement[]
|
|
26
|
+
/** the pointer can move into it (a log to scroll): it stays open on the way */
|
|
27
|
+
interactive?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** a trigger and what floats over it on hover or focus */
|
|
31
|
+
export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, ...rest }: TipProps) => {
|
|
32
|
+
const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
|
|
33
|
+
const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(6, fallbacks), whileElementsMounted: autoUpdate })
|
|
34
|
+
const hover = useHover(f.context, { delay: { open: OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
|
|
35
|
+
const focus = useFocus(f.context)
|
|
36
|
+
const dismiss = useDismiss(f.context)
|
|
37
|
+
const role = useRole(f.context, { role: 'tooltip' })
|
|
38
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role])
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<span ref={f.refs.setReference} {...getReferenceProps(rest)}>{children}</span>
|
|
42
|
+
{st.open && content != null ? (
|
|
43
|
+
<FloatingPortal>
|
|
44
|
+
<div ref={f.refs.setFloating} className={tipClass} style={f.floatingStyles} {...getFloatingProps()}>{content}</div>
|
|
45
|
+
</FloatingPortal>
|
|
46
|
+
) : null}
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
})
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { runInAction } from 'mobx'
|
|
8
8
|
import { observer } from 'mobx-react-lite'
|
|
9
9
|
import { useRef } from 'react'
|
|
10
|
-
import type {
|
|
10
|
+
import type { RepoSt } from './repo-st.ts'
|
|
11
11
|
import type { Leg } from './hub-types.ts'
|
|
12
12
|
|
|
13
13
|
export type Box = { scale: number; x: number; y: number; w: number; h: number }
|
|
@@ -27,7 +27,7 @@ const LEG_TAGS = ['L·front', 'L·back', 'R·back', 'R·front']
|
|
|
27
27
|
|
|
28
28
|
type DragStart = { px: number; py: number; x: number; y: number; w: number; legs: Leg[]; cy: number; mode: 'move' | 'resize' }
|
|
29
29
|
|
|
30
|
-
const LegBox = observer(({ app, box, i, leg }: { app:
|
|
30
|
+
const LegBox = observer(({ app, box, i, leg }: { app: RepoSt; box: Box; i: number; leg: Leg }) => {
|
|
31
31
|
const m = app.media
|
|
32
32
|
const start = useRef<{ px: number; py: number; legs: Leg[]; mode: 'move' | 'resize' } | null>(null)
|
|
33
33
|
const px = (v: number): number => v * box.scale
|
|
@@ -80,7 +80,7 @@ const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number
|
|
|
80
80
|
)
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
export const GeometryLayer = observer(({ app, box }: { app:
|
|
83
|
+
export const GeometryLayer = observer(({ app, box }: { app: RepoSt; box: Box | null }) => {
|
|
84
84
|
const m = app.media
|
|
85
85
|
const start = useRef<DragStart | null>(null)
|
|
86
86
|
const rect = m.rect
|
|
@@ -137,7 +137,7 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
|
|
|
137
137
|
)
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
export const Rectbar = observer(({ app }: { app:
|
|
140
|
+
export const Rectbar = observer(({ app }: { app: RepoSt }) => {
|
|
141
141
|
const m = app.media
|
|
142
142
|
const rect = m.rect
|
|
143
143
|
const showSize = m.tools.has('size')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// THE ADDRESS of what is on screen, both ways, and nothing else. Pure on
|
|
2
|
-
// purpose: the page's selection rules are worth pinning, and
|
|
2
|
+
// purpose: the page's selection rules are worth pinning, and RepoSt cannot be
|
|
3
3
|
// instantiated without a browser.
|
|
4
4
|
//
|
|
5
5
|
// The media forms are older than the rest and stay byte for byte what they
|