create-pmos 0.29.1 → 0.30.1
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/README.md +61 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +36 -1
- package/workspace/lib/four-panel.js +40 -3
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +6 -1
- package/workspace/public/pm/markdown.js +420 -0
- package/workspace/public/pm/panel-detail.js +25 -1
- package/workspace/public/pm/panel-tabs.js +87 -0
- package/workspace/public/pm/state.js +10 -0
- package/workspace/public/pm/tab-doc.js +84 -33
- package/workspace/server.js +49 -3
package/README.md
CHANGED
|
@@ -333,6 +333,67 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.30.1
|
|
337
|
+
|
|
338
|
+
**Open a markdown file in the workspace and you get the document, not its source. And scrolling
|
|
339
|
+
down stays scrolled down.**
|
|
340
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
341
|
+
|
|
342
|
+
- **The document pane renders markdown properly.** Bold was asterisks, inline code was backticks, a
|
|
343
|
+
table was one paragraph per row with the pipes still in it, and a numbered procedure was a single
|
|
344
|
+
run-on paragraph. An indented sub-bullet was worse than unstyled — it escaped the list it belonged
|
|
345
|
+
inside and reappeared underneath it. `view` now renders headings 1–6, soft-wrapped paragraphs,
|
|
346
|
+
ordered / nested / task lists, GFM tables with per-column alignment, fenced code, blockquotes and
|
|
347
|
+
rules, plus bold, italic, inline code, strikethrough, links and images. Rubrics and skills — the
|
|
348
|
+
documents this workflow is written in — are readable in the pane for the first time.
|
|
349
|
+
- **A `.md` link opens that document.** Click a link to another file in your repo and it opens as a
|
|
350
|
+
tab, resolved against the document you are reading (or the repo root for a `/planning/…` path). A
|
|
351
|
+
link to something the pane cannot open stays as plain text rather than becoming a dead link.
|
|
352
|
+
- **Nothing is fetched because you opened a file.** A repo-relative image is streamed by your own
|
|
353
|
+
local workspace; a remote or `data:` image stays as its alt text and is never requested. The
|
|
354
|
+
renderer builds DOM nodes and never assigns HTML, so a `<script>` in a markdown file is characters
|
|
355
|
+
on the screen, not a script.
|
|
356
|
+
- **Scrolling a document survives a repaint.** The pane rebuilt its scrolling element on every
|
|
357
|
+
redraw, so anything that changed state anywhere — including the four-second session poll, which
|
|
358
|
+
fires whenever an agent beside you is working — took a half-read document back to line 1. The
|
|
359
|
+
scroll element is now kept, per tab and per mode, the same way the edit box keeps your caret.
|
|
360
|
+
Holds for `view`, `raw` and `diff`.
|
|
361
|
+
- **Not in this release, said plainly:** this is a workspace-app fix. The file-mode kit — skills,
|
|
362
|
+
scripts, state layout — is unchanged, so `--update` has nothing new to give you. Syntax
|
|
363
|
+
highlighting inside code fences, footnotes and rendered inline HTML are deliberately not included.
|
|
364
|
+
|
|
365
|
+
## What's new in 0.30.0
|
|
366
|
+
|
|
367
|
+
**Your user stories are on the board, and a story can name the Jira issue, Confluence page or
|
|
368
|
+
Google Doc it corresponds to — in the file itself. The scratchpad drags up and down.**
|
|
369
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
370
|
+
|
|
371
|
+
- **User stories show up on an initiative's artifacts list, in the right place.** The index has
|
|
372
|
+
recognised `*-user-stories.md` for a while and nothing downstream used it: stories sorted *below*
|
|
373
|
+
the PRD and the rubric they decompose, and the role label was twelve characters in a box built for
|
|
374
|
+
eight, so it ran under the file path beside it. Stories now sort directly after the PRD, and the
|
|
375
|
+
chip fits. A file in a `planning/stories/` folder counts as a story too, with no suffix needed in
|
|
376
|
+
its name.
|
|
377
|
+
- **A story can carry its own destination.** Put `jira: ABC-123`, `confluence: SPACE / page` or
|
|
378
|
+
`google_doc: <url>` in a story's frontmatter and the workspace draws the ⇄ pill on its row — no
|
|
379
|
+
clicking, no re-typing on a second machine, and the tie is in git where an agent reading the file
|
|
380
|
+
can see it. It works for any planning document, not only stories.
|
|
381
|
+
- **It says what it knows, which is nothing.** That pill reads *declared in the file · nothing has
|
|
382
|
+
been checked*. PMOS holds no credential, makes no request, and cannot mark a reference resolved —
|
|
383
|
+
only a session carrying your own MCP can do that. A link you record in the panel still wins over
|
|
384
|
+
the one in the file, and `unlink` now survives a reload instead of the file quietly putting the
|
|
385
|
+
pill back.
|
|
386
|
+
- **The scratchpad resizes.** Drag the handle along its top edge — up for taller, down for shorter.
|
|
387
|
+
It cannot be dragged shut, it cannot take more than 70% of the panel (the pane above is often a
|
|
388
|
+
live terminal), the size sticks across reloads, and `↑`/`↓` work when the handle is focused with
|
|
389
|
+
`Home` to reset. Resizing never touches what you have staged in the pad.
|
|
390
|
+
- **Not in this release, said plainly:** the `Story` frontmatter type, the gate rule that enforces
|
|
391
|
+
it and the `--fix` repair are PMOS-self only and are **not** in this payload. The registry they
|
|
392
|
+
enforce is PMOS's own vocabulary, and running it inside your repository would fail files written
|
|
393
|
+
to your conventions. The workspace half above works whatever `type:` your story files declare.
|
|
394
|
+
Nothing here reads status back from a tracker — there is no "3 of 7 done", and the surface does
|
|
395
|
+
not imply there is.
|
|
396
|
+
|
|
336
397
|
## What's new in 0.29.1
|
|
337
398
|
|
|
338
399
|
**0.29.0 could not reach an existing install. This is that release, delivered.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-08-
|
|
1
|
+
kernel-source: pmos@f5b480a
|
|
2
|
+
assembled: 2026-08-05
|
|
3
3
|
mode: vendor
|
|
@@ -57,12 +57,47 @@ The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontma
|
|
|
57
57
|
the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
58
58
|
|
|
59
59
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
60
|
-
- **Planning:** `Initiative`, `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
60
|
+
- **Planning:** `Initiative`, `PRD`, `Story`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
61
61
|
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the
|
|
62
62
|
[Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key —
|
|
63
63
|
not `type:` — so the lane vocabulary and this registry never collide.
|
|
64
|
+
- `Story` is a **user-story document** — the decomposition of a spec into the units of work handed
|
|
65
|
+
to whoever builds them. It is the one type in this registry that is also **checked positionally**
|
|
66
|
+
(see below), and it may carry an optional `jira:` / `confluence:` / `google_doc:` key naming the
|
|
67
|
+
tracker record it corresponds to. That key is a *declaration* the workspace displays, never a
|
|
68
|
+
lookup: PMOS holds no credential and makes no outbound request.
|
|
64
69
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
65
70
|
|
|
71
|
+
### When the path decides the type
|
|
72
|
+
|
|
73
|
+
Most types are the author's to choose. A few document kinds are identifiable from the path alone, and
|
|
74
|
+
for those `type ∈ registry` is not enough — the gate also checks the type is the RIGHT one, because a
|
|
75
|
+
file wearing a plausible wrong type passes the membership test forever.
|
|
76
|
+
|
|
77
|
+
**User stories** are the first such kind. A tracked `.md` whose filename ends `-user-stories.md`,
|
|
78
|
+
`-user-story.md` or `-stories.md`, **or** whose immediate parent directory is `stories/` (or
|
|
79
|
+
`user-stories/`), must declare `type: Story`. These are exactly the shapes the workspace's library
|
|
80
|
+
index calls a story (`ROLE_SUFFIXES` / `ROLE_BY_DIR` in `workspace/server.js`) — the board and the
|
|
81
|
+
gate are held to one definition of what a story is, rather than two that can drift.
|
|
82
|
+
|
|
83
|
+
The boundary is deliberately narrow: `planning/stories/archive/notes.md` is a note filed beside
|
|
84
|
+
stories, not a story, and `planning/research/story-mapping.md` is research. Both directions are
|
|
85
|
+
asserted in [`scripts/tests/frontmatter-story.sh`](/scripts/tests/frontmatter-story.sh).
|
|
86
|
+
|
|
87
|
+
**Retroactive repair (PMOS-self — the kit does not carry this tool).** Story files written before
|
|
88
|
+
`Story` existed declare something else — this repo's own test fixture declared `type: PRD`, because
|
|
89
|
+
nothing else was available. `python3 scripts/frontmatter_lint.py --fix` corrects them, and
|
|
90
|
+
`--root <dir>` points it at another repository so a product repo's stories can be repaired too. It
|
|
91
|
+
is bounded on purpose: story files only, the `type:` key only, and it never invents a `title` or a
|
|
92
|
+
`timestamp` — a story file missing those is reported rather than guessed at.
|
|
93
|
+
|
|
94
|
+
The linter and this registry are **not** in the create-pmos payload, and that is a decision rather
|
|
95
|
+
than an omission: the registry is *PMOS's* vocabulary, and enforcing it inside an adopter's
|
|
96
|
+
repository would fail files written to their own conventions. Whose vocabulary governs an adopter's
|
|
97
|
+
repo is its own question, and it deserves its own initiative. What adopters *do* get is the reading
|
|
98
|
+
half — the workspace draws a story on the artifacts row and shows the destination its frontmatter
|
|
99
|
+
declares, whatever `type:` the file carries.
|
|
100
|
+
|
|
66
101
|
### Extending the vocabulary
|
|
67
102
|
|
|
68
103
|
Add **one line** to `scripts/frontmatter_registry.txt` and a matching row above. A new `type` is a
|
|
@@ -272,7 +272,36 @@ function artifactsFor(index, id) {
|
|
|
272
272
|
const rows = (index && Array.isArray(index.initiatives)) ? index.initiatives : [];
|
|
273
273
|
const hit = rows.find((r) => r && r.id === id);
|
|
274
274
|
if (!hit || !Array.isArray(hit.artifacts)) return [];
|
|
275
|
-
return hit.artifacts.map((a) => ({
|
|
275
|
+
return hit.artifacts.map((a) => ({
|
|
276
|
+
role: a.role, name: a.name, path: a.path, title: a.title || null,
|
|
277
|
+
// The destination the document declares about ITSELF, from its frontmatter (server.js
|
|
278
|
+
// `frontmatterDest`). It is carried, not resolved — see `withFrontmatterDest` for the one rule
|
|
279
|
+
// that decides when the panel is allowed to see it.
|
|
280
|
+
dest: a.dest || null,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Decide, per artifact, whether the file's own declared destination is the one the panel shows.
|
|
286
|
+
*
|
|
287
|
+
* THE RULE: **if the store has spoken about an artifact at all, the store owns it.** The file fills
|
|
288
|
+
* the silence, and only the silence.
|
|
289
|
+
*
|
|
290
|
+
* "At all" includes a `retracted` row, and that clause is the whole reason this function exists
|
|
291
|
+
* rather than a `||` at the call site. `unlink` appends a retraction; `linkView` then correctly
|
|
292
|
+
* reports no link. If the fallback consulted the file at that point, the pill the PM just removed
|
|
293
|
+
* would be back on the next reload and the button would be a button that undoes itself. A
|
|
294
|
+
* retraction is a decision about this artifact, so it counts as the store having spoken.
|
|
295
|
+
*
|
|
296
|
+
* The precedence direction is the other half: a row in the store is the PM's own act in a UI, made
|
|
297
|
+
* later than the file was written, against a destination they were looking at. The file is the
|
|
298
|
+
* artifact's own statement and travels with the repository. When they disagree, the later explicit
|
|
299
|
+
* act wins — and the file is left exactly as it is, because PMOS does not edit the PM's documents.
|
|
300
|
+
*/
|
|
301
|
+
function withFrontmatterDest(artifacts, storeRows) {
|
|
302
|
+
const spoken = new Set();
|
|
303
|
+
for (const r of storeRows || []) if (r && r.artifact_path) spoken.add(r.artifact_path);
|
|
304
|
+
return (artifacts || []).map((a) => (a.dest && spoken.has(a.path) ? { ...a, dest: null } : a));
|
|
276
305
|
}
|
|
277
306
|
|
|
278
307
|
/* ── sessions ───────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -727,11 +756,19 @@ function lookupTask({ kind, subject, hint, profile } = {}) {
|
|
|
727
756
|
*/
|
|
728
757
|
function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, artifactSync, homeDir }) {
|
|
729
758
|
if (!rec.ok) return { ok: false, id, error: rec.error };
|
|
730
|
-
const
|
|
759
|
+
const indexed = artifactsFor(index, id);
|
|
731
760
|
const sess = sessionsFor({ sessions, workspaces, id });
|
|
732
761
|
// linkView LAST, so superseding happens across this initiative's rows only — collapsing the whole
|
|
733
762
|
// store first would let another initiative's link to the same path win a pair that is not its own.
|
|
734
763
|
const mine = (rows, pred) => linkView((rows && rows.ok ? rows.rows.filter(pred) : []));
|
|
764
|
+
// …and `includeRetracted` here, because "the PM withdrew this" is the store having spoken about
|
|
765
|
+
// the artifact. See withFrontmatterDest: without it, unlink would undo itself on the next reload.
|
|
766
|
+
const spokenFor = linkView(
|
|
767
|
+
(artifactLinks && artifactLinks.ok ? artifactLinks.rows : []).filter((r) => r
|
|
768
|
+
&& (r.initiative_id === id || indexed.some((a) => a.path === r.artifact_path))),
|
|
769
|
+
{ includeRetracted: true },
|
|
770
|
+
);
|
|
771
|
+
const artifacts = withFrontmatterDest(indexed, spokenFor);
|
|
735
772
|
return {
|
|
736
773
|
ok: true,
|
|
737
774
|
id,
|
|
@@ -769,7 +806,7 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
|
|
|
769
806
|
module.exports = {
|
|
770
807
|
ARTIFACT_LINKS, INITIATIVE_LINKS, ARTIFACT_SYNC,
|
|
771
808
|
LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND, SYNC_DIRECTIONS, SYNC_STATES,
|
|
772
|
-
validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
|
|
809
|
+
validId, connectors, readRecord, parseTasks, artifactsFor, withFrontmatterDest, sessionsFor, anchorFor,
|
|
773
810
|
storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
|
|
774
811
|
linkView, lookupTask, syncRow, syncView, syncTask,
|
|
775
812
|
};
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -332,7 +332,12 @@ function artsFor(payload) {
|
|
|
332
332
|
sync.set(r.artifact_path + '|' + r.kind, r);
|
|
333
333
|
}
|
|
334
334
|
return (payload.artifacts || []).map((a) => {
|
|
335
|
-
|
|
335
|
+
// A row in the store, else the destination the FILE declares about itself (server.js
|
|
336
|
+
// `frontmatterDest`, gated by `withFrontmatterDest` — the server has already decided which of
|
|
337
|
+
// the two the panel is allowed to see, including for an artifact whose link the PM retracted).
|
|
338
|
+
// This used to return `{role, path}` here and drop `a.dest` on the floor, even though
|
|
339
|
+
// `artifactRow` reads it as its fallback: the reader existed and the producer did not.
|
|
340
|
+
const d = dest.get(a.path) || a.dest || null;
|
|
336
341
|
if (!d) return { role: a.role, path: a.path };
|
|
337
342
|
return { role: a.role, path: a.path, dest: d, sync: sync.get(a.path + '|' + d.kind) || null };
|
|
338
343
|
});
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/* pm/markdown.js — the doc pane's markdown renderer.
|
|
2
|
+
*
|
|
3
|
+
* The doc tab's `view` mode used to render through `fixture.js`'s `mdParse`, which is a
|
|
4
|
+
* TRANSCRIPTION of the design prototype's parser — four branches (`# `, `## `, `- `, paragraph) and
|
|
5
|
+
* no inline pass at all. That is exactly big enough for the prototype's four seed documents and
|
|
6
|
+
* nowhere near big enough for this repository's own artifacts: a rubric IS a GFM table, a skill IS a
|
|
7
|
+
* numbered procedure, and both reached the PM as literal source with the pipes and the asterisks
|
|
8
|
+
* still in them. See planning/explainers/workspace-doc-render-surface-explainer.md.
|
|
9
|
+
*
|
|
10
|
+
* `mdParse` is not touched — `fixture-parity.test.mjs` pins it byte-for-byte against the prototype
|
|
11
|
+
* source, and that transcription proof is the reason the design comparator can be trusted. This is a
|
|
12
|
+
* separate renderer, and `tab-doc.js` chooses it for `view`.
|
|
13
|
+
*
|
|
14
|
+
* ── THE PARITY CONTRACT ────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* The fixture documents contain only `#`, `##`, `- ` and paragraphs, and `design-parity.js` diffs
|
|
16
|
+
* the app against the prototype on text nodes, computed style, geometry and pixels. So for those
|
|
17
|
+
* four constructs this renderer emits NODE-FOR-NODE what the old path emitted, including the
|
|
18
|
+
* wrapping <span> that `iv()` put around unmarked text: an inline run with no markup in it comes
|
|
19
|
+
* back as exactly one span holding the whole run. A failed marker match does NOT flush the text
|
|
20
|
+
* buffer — `planning/initiatives/*.md` is one span, not three.
|
|
21
|
+
*
|
|
22
|
+
* ── WHAT IT NEVER DOES ─────────────────────────────────────────────────────────────────────────
|
|
23
|
+
* It builds DOM NODES. No HTML string is constructed anywhere in this file and `innerHTML` is never
|
|
24
|
+
* assigned, so a `<script>` line in a document is a `<script>` on the screen, as characters, by
|
|
25
|
+
* construction rather than by remembering to escape (DOC7). And an image is fetched only when it is
|
|
26
|
+
* repo-relative, through the guarded /api/asset — a remote or `data:` source stays alt text and is
|
|
27
|
+
* never requested, which is R4: opening a document must not tell anyone it was opened.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { el } from './dom.js';
|
|
31
|
+
|
|
32
|
+
/* ── styles ─────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
* H2, P, UL and LI are the prototype's strings, verbatim, and must stay that way — they are what
|
|
34
|
+
* the parity diff measures. Everything below them is new surface, styled in the same idiom: the
|
|
35
|
+
* semantic tokens only, no raw colour anywhere. */
|
|
36
|
+
const ST = {
|
|
37
|
+
h1: 'font-size:19px;font-weight:600;line-height:1.3;margin:26px 0 10px;text-wrap:balance;font-family:var(--sans)',
|
|
38
|
+
h2: 'font-size:16px;font-weight:600;line-height:1.35;margin:24px 0 8px;padding-bottom:5px;border-bottom:1px solid var(--border)',
|
|
39
|
+
h3: 'font-size:14px;font-weight:600;line-height:1.4;margin:20px 0 7px',
|
|
40
|
+
h4: 'font-size:13px;font-weight:600;line-height:1.4;margin:18px 0 6px',
|
|
41
|
+
h5: 'font-size:12.5px;font-weight:600;line-height:1.4;margin:16px 0 6px;color:var(--muted-foreground)',
|
|
42
|
+
h6: 'font-size:12px;font-weight:600;line-height:1.4;margin:16px 0 6px;color:var(--muted-foreground);text-transform:uppercase;letter-spacing:0.04em',
|
|
43
|
+
p: 'margin:0 0 12px',
|
|
44
|
+
ul: 'margin:0 0 12px;padding-left:22px',
|
|
45
|
+
ol: 'margin:0 0 12px;padding-left:24px',
|
|
46
|
+
subList: 'margin:5px 0 0;padding-left:22px',
|
|
47
|
+
li: 'margin:0 0 5px;padding-left:2px',
|
|
48
|
+
taskUl: 'margin:0 0 12px;padding-left:2px;list-style:none',
|
|
49
|
+
taskLi: 'margin:0 0 5px;display:flex;gap:8px;align-items:baseline',
|
|
50
|
+
box: 'font-family:var(--mono);font-size:12px;color:var(--muted-foreground);flex:none',
|
|
51
|
+
boxDone: 'font-family:var(--mono);font-size:12px;color:var(--success);flex:none',
|
|
52
|
+
code: 'font-family:var(--mono);font-size:0.88em;background:var(--muted);padding:1px 4px;overflow-wrap:anywhere',
|
|
53
|
+
pre: 'margin:0 0 14px;background:var(--card);border:1px solid var(--border);padding:10px 12px;overflow-x:auto',
|
|
54
|
+
preCode: 'font-family:var(--mono);font-size:12px;line-height:1.6;white-space:pre;display:block',
|
|
55
|
+
lang: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground);margin-bottom:6px;letter-spacing:0.04em',
|
|
56
|
+
quote: 'margin:0 0 14px;padding:2px 0 2px 14px;border-left:2px solid var(--border);color:var(--muted-foreground)',
|
|
57
|
+
hr: 'border:0;border-top:1px solid var(--border);margin:22px 0',
|
|
58
|
+
tableWrap: 'margin:0 0 14px;overflow-x:auto;border:1px solid var(--border)',
|
|
59
|
+
table: 'border-collapse:collapse;width:100%;font-size:12.5px',
|
|
60
|
+
th: 'font-weight:600;padding:7px 10px;border-bottom:1px solid var(--border);background:var(--card);vertical-align:top',
|
|
61
|
+
td: 'padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top',
|
|
62
|
+
a: 'color:var(--brand);text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere',
|
|
63
|
+
img: 'max-width:100%;height:auto;display:block;margin:0 0 14px;border:1px solid var(--border)',
|
|
64
|
+
imgAlt: 'color:var(--muted-foreground)',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const HEAD_ST = [ST.h1, ST.h2, ST.h3, ST.h4, ST.h5, ST.h6];
|
|
68
|
+
|
|
69
|
+
/* ── inline ─────────────────────────────────────────────────────────────────────────────────── */
|
|
70
|
+
|
|
71
|
+
const RE = {
|
|
72
|
+
code: /^(`+)([\s\S]*?[^`])\1(?!`)/,
|
|
73
|
+
image: /^!\[([^\]]*)\]\(\s*([^)\s]*)(?:\s+"[^"]*")?\s*\)/,
|
|
74
|
+
link: /^\[([^\]]+)\]\(\s*([^)\s]*)(?:\s+"[^"]*")?\s*\)/,
|
|
75
|
+
autolink: /^<((?:https?:\/\/|mailto:)[^>\s]+)>/,
|
|
76
|
+
strongStar: /^\*\*(?=\S)([\s\S]*?\S)\*\*/,
|
|
77
|
+
strongUnder: /^__(?=\S)([\s\S]*?\S)__/,
|
|
78
|
+
emStar: /^\*(?=[^\s*])((?:[^*]|\*\*)+?)\*(?!\*)/,
|
|
79
|
+
emUnder: /^_(?=[^\s_])([^_]+?)_(?!\w)/,
|
|
80
|
+
del: /^~~(?=\S)([\s\S]*?\S)~~/,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isWord = (c) => !!c && /[A-Za-z0-9]/.test(c);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One line (or cell) of markdown → an array of DOM nodes.
|
|
87
|
+
*
|
|
88
|
+
* The scan is left-to-right and a marker that does not resolve is just a character: the plain-text
|
|
89
|
+
* buffer is flushed ONLY when a real node is emitted, which is what keeps an unmarked run a single
|
|
90
|
+
* text span (the parity contract) and what keeps `a/*.md` and `pre_run_contract` intact.
|
|
91
|
+
*/
|
|
92
|
+
export function inline(src, ctx = {}) {
|
|
93
|
+
const s = String(src == null ? '' : src);
|
|
94
|
+
const out = [];
|
|
95
|
+
let buf = '';
|
|
96
|
+
const flush = () => { if (buf) { out.push(el('span', null, buf)); buf = ''; } };
|
|
97
|
+
const push = (node) => { flush(); out.push(node); };
|
|
98
|
+
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < s.length) {
|
|
101
|
+
const c = s[i];
|
|
102
|
+
// Nothing can start here — the common case, and the reason a plain paragraph stays one span.
|
|
103
|
+
if (c !== '`' && c !== '[' && c !== '!' && c !== '*' && c !== '_' && c !== '~' && c !== '<') {
|
|
104
|
+
buf += c; i++; continue;
|
|
105
|
+
}
|
|
106
|
+
const rest = s.slice(i);
|
|
107
|
+
const prev = i > 0 ? s[i - 1] : '';
|
|
108
|
+
let m;
|
|
109
|
+
|
|
110
|
+
if (c === '`' && (m = RE.code.exec(rest))) {
|
|
111
|
+
push(el('code', { style: ST.code }, m[2].replace(/^ (.*) $/, '$1')));
|
|
112
|
+
i += m[0].length; continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '!' && (m = RE.image.exec(rest))) {
|
|
115
|
+
push(mdImage(m[1], m[2], ctx));
|
|
116
|
+
i += m[0].length; continue;
|
|
117
|
+
}
|
|
118
|
+
if (c === '[' && (m = RE.link.exec(rest))) {
|
|
119
|
+
const node = mdLink(m[1], m[2], ctx);
|
|
120
|
+
if (node) { push(node); i += m[0].length; continue; }
|
|
121
|
+
// A target that cannot be opened is not offered as a link — its TEXT stays, in the buffer,
|
|
122
|
+
// so it does not fragment the run around it.
|
|
123
|
+
buf += m[1]; i += m[0].length; continue;
|
|
124
|
+
}
|
|
125
|
+
if (c === '<' && (m = RE.autolink.exec(rest))) {
|
|
126
|
+
push(mdLink(m[1], m[1], ctx) || el('span', null, m[1]));
|
|
127
|
+
i += m[0].length; continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === '~' && (m = RE.del.exec(rest))) {
|
|
130
|
+
push(el('del', null, inline(m[1], ctx)));
|
|
131
|
+
i += m[0].length; continue;
|
|
132
|
+
}
|
|
133
|
+
if (c === '*' && (m = RE.strongStar.exec(rest))) {
|
|
134
|
+
push(el('strong', { style: 'font-weight:600' }, inline(m[1], ctx)));
|
|
135
|
+
i += m[0].length; continue;
|
|
136
|
+
}
|
|
137
|
+
/* `__` and `_` are word-boundary-only, deliberately. Intraword underscores are how this
|
|
138
|
+
* repository writes `pre_run_contract` and `parent_kr`, and a renderer that italicised the
|
|
139
|
+
* middle of them would be a new defect shipped with the fix for an old one. */
|
|
140
|
+
if (c === '_' && !isWord(prev) && (m = RE.strongUnder.exec(rest))) {
|
|
141
|
+
const after = s[i + m[0].length];
|
|
142
|
+
if (!isWord(after)) { push(el('strong', { style: 'font-weight:600' }, inline(m[1], ctx))); i += m[0].length; continue; }
|
|
143
|
+
}
|
|
144
|
+
if (c === '*' && !isWord(prev) && (m = RE.emStar.exec(rest))) {
|
|
145
|
+
push(el('em', null, inline(m[1], ctx)));
|
|
146
|
+
i += m[0].length; continue;
|
|
147
|
+
}
|
|
148
|
+
if (c === '_' && !isWord(prev) && (m = RE.emUnder.exec(rest))) {
|
|
149
|
+
const after = s[i + m[0].length];
|
|
150
|
+
if (!isWord(after)) { push(el('em', null, inline(m[1], ctx))); i += m[0].length; continue; }
|
|
151
|
+
}
|
|
152
|
+
buf += c; i++; // a marker that resolved to nothing is just a character
|
|
153
|
+
}
|
|
154
|
+
flush();
|
|
155
|
+
return out.length ? out : [el('span', null, '')];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** bundle-relative (`/planning/…`, the OKF convention) or relative to the open document. */
|
|
159
|
+
export function resolvePath(dir, u) {
|
|
160
|
+
const clean = String(u || '').split('#')[0].split('?')[0];
|
|
161
|
+
const stack = clean.startsWith('/') ? [] : [...(dir || [])];
|
|
162
|
+
for (const p of clean.split('/')) {
|
|
163
|
+
if (!p || p === '.') continue;
|
|
164
|
+
if (p === '..') stack.pop(); else stack.push(p);
|
|
165
|
+
}
|
|
166
|
+
return stack.join('/');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const REMOTE = /^(?:https?:)?\/\//i;
|
|
170
|
+
const MAILTO = /^mailto:/i;
|
|
171
|
+
/* Every extension `previewKind` calls a document, plus the JSONL ledgers panel 3 links to. A link
|
|
172
|
+
* to something the doc tab cannot open is not made clickable — a dead link is worse than text. */
|
|
173
|
+
const OPENABLE = /\.(md|markdown|mdown|mkd|mdx|skill|txt|json|jsonl|ya?ml|toml|csv|js|mjs|cjs|ts|tsx|py|sh|css|html?)(#.*)?$/i;
|
|
174
|
+
|
|
175
|
+
function mdLink(txt, url, ctx) {
|
|
176
|
+
const u = String(url || '').trim();
|
|
177
|
+
if (!u) return null;
|
|
178
|
+
if (REMOTE.test(u) || MAILTO.test(u)) {
|
|
179
|
+
return el('a', {
|
|
180
|
+
href: u, target: '_blank', rel: 'noopener noreferrer', style: ST.a,
|
|
181
|
+
}, inline(txt, ctx));
|
|
182
|
+
}
|
|
183
|
+
if (u.startsWith('#')) return null; // an in-page anchor has nowhere to go here
|
|
184
|
+
if (!OPENABLE.test(u)) return null;
|
|
185
|
+
const path = resolvePath(ctx.dir, u);
|
|
186
|
+
if (!path || !ctx.onDoc) return null;
|
|
187
|
+
/* Opening a document from inside a document is the one navigation affordance the retired reader
|
|
188
|
+
* column had and the doc tab never got. `href="#"` keeps it a real link for the keyboard; the
|
|
189
|
+
* click never navigates the shell. */
|
|
190
|
+
return el('a', {
|
|
191
|
+
href: '#', style: ST.a, title: path,
|
|
192
|
+
onClick: (e) => { e.preventDefault(); ctx.onDoc(path); },
|
|
193
|
+
}, inline(txt, ctx));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* R4, the no-beacon invariant. A repo-relative source is streamed by the workspace's own guarded
|
|
198
|
+
* /api/asset; ANYTHING else — remote, protocol-relative, data:, any scheme — is never fetched and
|
|
199
|
+
* renders as its alt text. The classifier is `preview-kind.js`'s, the one both sides of the app
|
|
200
|
+
* already share, so "what counts as repo-relative" cannot drift; if it did not load, nothing is
|
|
201
|
+
* fetched at all.
|
|
202
|
+
*/
|
|
203
|
+
function mdImage(alt, src, ctx) {
|
|
204
|
+
const label = alt || 'image';
|
|
205
|
+
const PK = typeof window !== 'undefined' ? window.PreviewKind : null;
|
|
206
|
+
const repoRel = PK && typeof PK.isRepoRelativeSrc === 'function' ? PK.isRepoRelativeSrc(src) : false;
|
|
207
|
+
const path = repoRel ? resolvePath(ctx.dir, src) : '';
|
|
208
|
+
if (!path) return el('em', { style: ST.imgAlt }, label);
|
|
209
|
+
return el('img', {
|
|
210
|
+
style: ST.img, alt: label, loading: 'lazy',
|
|
211
|
+
src: '/api/asset?ws=' + encodeURIComponent(ctx.ws || '') + '&path=' + encodeURIComponent(path),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ── blocks ─────────────────────────────────────────────────────────────────────────────────── */
|
|
216
|
+
|
|
217
|
+
const RE_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
|
218
|
+
const RE_HR = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
219
|
+
const RE_HEAD = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
220
|
+
const RE_FENCE = /^\s*(`{3,}|~{3,})\s*([^\s`]*)/;
|
|
221
|
+
const RE_QUOTE = /^\s{0,3}>/;
|
|
222
|
+
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
|
|
223
|
+
const RE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
224
|
+
|
|
225
|
+
const cells = (row) => String(row).trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/)
|
|
226
|
+
.map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
227
|
+
|
|
228
|
+
const isTableAt = (lines, i) => lines[i].includes('|')
|
|
229
|
+
&& RE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
|
|
230
|
+
|
|
231
|
+
/** Frontmatter → `[{k, v}]`, exactly as the pane has always read it. */
|
|
232
|
+
function frontmatter(lines) {
|
|
233
|
+
const fm = [];
|
|
234
|
+
if (lines[0] !== '---') return { fm, next: 0 };
|
|
235
|
+
let i = 1;
|
|
236
|
+
while (i < lines.length && lines[i] !== '---') {
|
|
237
|
+
const ix = lines[i].indexOf(':');
|
|
238
|
+
if (ix > 0) fm.push({ k: lines[i].slice(0, ix).trim(), v: lines[i].slice(ix + 1).trim() });
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
return { fm, next: i + 1 };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The document → `{ fm, title, titleText, blocks }`.
|
|
246
|
+
*
|
|
247
|
+
* `title` is the inline nodes of the FIRST `# ` line — the pane draws it as the document's heading,
|
|
248
|
+
* the way it always has. A second `# ` is a real h1 in the body rather than silently replacing the
|
|
249
|
+
* title, which is what the old parser did (last one wins, the earlier ones vanish).
|
|
250
|
+
*
|
|
251
|
+
* `ctx` carries what links and images need: `{ dir, ws, onDoc }`.
|
|
252
|
+
*/
|
|
253
|
+
export function mdRender(text, ctx = {}) {
|
|
254
|
+
const lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
|
|
255
|
+
const { fm, next } = frontmatter(lines);
|
|
256
|
+
const blocks = [];
|
|
257
|
+
let title = null, titleText = '';
|
|
258
|
+
let i = next;
|
|
259
|
+
|
|
260
|
+
while (i < lines.length) {
|
|
261
|
+
const ln = lines[i];
|
|
262
|
+
if (!ln.trim()) { i++; continue; }
|
|
263
|
+
let m;
|
|
264
|
+
|
|
265
|
+
// fenced code — verbatim, never parsed, and an unterminated fence takes the rest of the file
|
|
266
|
+
if ((m = RE_FENCE.exec(ln))) {
|
|
267
|
+
const close = new RegExp('^\\s*' + m[1][0] + '{' + m[1].length + ',}\\s*$');
|
|
268
|
+
const body = [];
|
|
269
|
+
i++;
|
|
270
|
+
while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
|
|
271
|
+
i++;
|
|
272
|
+
blocks.push(el('pre', { style: ST.pre },
|
|
273
|
+
m[2] ? el('span', { style: ST.lang }, m[2]) : null,
|
|
274
|
+
el('code', { style: ST.preCode }, body.join('\n'))));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if ((m = RE_HEAD.exec(ln))) {
|
|
279
|
+
const n = m[1].length;
|
|
280
|
+
const nodes = inline(m[2], ctx);
|
|
281
|
+
if (n === 1 && title === null) { title = nodes; titleText = m[2]; }
|
|
282
|
+
else blocks.push(el('h' + n, { style: HEAD_ST[n - 1] }, nodes));
|
|
283
|
+
i++; continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (RE_HR.test(ln)) { blocks.push(el('hr', { style: ST.hr })); i++; continue; }
|
|
287
|
+
|
|
288
|
+
// blockquote — its body is markdown, so it is rendered by the same pass
|
|
289
|
+
if (RE_QUOTE.test(ln)) {
|
|
290
|
+
const body = [];
|
|
291
|
+
while (i < lines.length && (RE_QUOTE.test(lines[i])
|
|
292
|
+
|| (body.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HEAD.test(lines[i])))) {
|
|
293
|
+
body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
|
|
294
|
+
}
|
|
295
|
+
const inner = mdRender(body.join('\n'), ctx);
|
|
296
|
+
blocks.push(el('blockquote', { style: ST.quote },
|
|
297
|
+
inner.title ? el('h3', { style: ST.h3 }, inner.title) : null, inner.blocks));
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// GFM table: a header row followed by a delimiter row
|
|
302
|
+
if (isTableAt(lines, i)) {
|
|
303
|
+
const head = cells(ln);
|
|
304
|
+
const align = cells(lines[i + 1]).map((c) => (/^:.*:$/.test(c) ? 'center' : /:$/.test(c) ? 'right' : /^:/.test(c) ? 'left' : ''));
|
|
305
|
+
i += 2;
|
|
306
|
+
const rows = [];
|
|
307
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(cells(lines[i++]));
|
|
308
|
+
// Written for every column, not only the aligned ones: a `text-align` that is present on some
|
|
309
|
+
// cells and inherited on others is how a column silently follows its neighbour's alignment.
|
|
310
|
+
const al = (n) => ';text-align:' + (align[n] || 'left');
|
|
311
|
+
blocks.push(el('div', { style: ST.tableWrap },
|
|
312
|
+
el('table', { style: ST.table },
|
|
313
|
+
el('thead', null, el('tr', null,
|
|
314
|
+
head.map((c, n) => el('th', { style: ST.th + al(n) }, inline(c, ctx))))),
|
|
315
|
+
el('tbody', null, rows.map((r) => el('tr', null,
|
|
316
|
+
head.map((_, n) => el('td', { style: ST.td + al(n) }, inline(r[n] || '', ctx)))))))));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (RE_ITEM.test(ln)) {
|
|
321
|
+
const { items, next: after } = listBlock(lines, i);
|
|
322
|
+
blocks.push(renderLists(items, 0, ctx, true).node);
|
|
323
|
+
i = after; continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* paragraph — consecutive lines are ONE paragraph. The join is what makes a soft-wrapped
|
|
327
|
+
* sentence read as a sentence; the guards are what stop it swallowing the block that follows. */
|
|
328
|
+
const para = [];
|
|
329
|
+
while (i < lines.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HR.test(lines[i])
|
|
330
|
+
&& !RE_QUOTE.test(lines[i]) && !RE_HEAD.test(lines[i]) && !RE_FENCE.test(lines[i])
|
|
331
|
+
&& !isTableAt(lines, i)) {
|
|
332
|
+
para.push(lines[i]); i++;
|
|
333
|
+
}
|
|
334
|
+
if (para.length) blocks.push(el('p', { style: ST.p }, inline(para.join(' '), ctx)));
|
|
335
|
+
else i++; // nothing consumed — never spin
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { fm, title, titleText, blocks };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Collect one list block: its items, their indents, and their wrapped continuation lines. */
|
|
342
|
+
function listBlock(lines, start) {
|
|
343
|
+
const items = [];
|
|
344
|
+
let i = start;
|
|
345
|
+
while (i < lines.length) {
|
|
346
|
+
const ln = lines[i];
|
|
347
|
+
const m = RE_ITEM.exec(ln);
|
|
348
|
+
if (m) {
|
|
349
|
+
items.push({ indent: m[1].replace(/\t/g, ' ').length, ordered: /\d/.test(m[2]), lines: [m[3]] });
|
|
350
|
+
i++; continue;
|
|
351
|
+
}
|
|
352
|
+
if (!ln.trim()) {
|
|
353
|
+
const nx = lines[i + 1] || '';
|
|
354
|
+
if (RE_ITEM.test(nx) || /^\s{2,}\S/.test(nx)) { i++; continue; } // a loose list
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
/* A fence indented under an item ENDS the list rather than being folded into the item's text.
|
|
358
|
+
* PMOS's own PRDs write a numbered step and then indent a block of output under it; joining
|
|
359
|
+
* those lines into the item would put the ``` markers on screen as characters — the exact
|
|
360
|
+
* defect this renderer exists to remove. The fence then renders as its own block, after the
|
|
361
|
+
* list: degraded placement, never degraded content. */
|
|
362
|
+
if (RE_FENCE.test(ln)) break;
|
|
363
|
+
if (items.length && /^\s+\S/.test(ln)) { items[items.length - 1].lines.push(ln.trim()); i++; continue; }
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
return { items, next: i };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Every list at one indent level, as siblings. Returns one node when there is only one list. */
|
|
370
|
+
function renderLists(items, start, ctx, top) {
|
|
371
|
+
const base = items[start].indent;
|
|
372
|
+
const nodes = [];
|
|
373
|
+
let i = start;
|
|
374
|
+
while (i < items.length && items[i].indent >= base) {
|
|
375
|
+
const r = renderList(items, i, ctx, top);
|
|
376
|
+
nodes.push(r.node);
|
|
377
|
+
if (r.next === i) break; // never spin
|
|
378
|
+
i = r.next;
|
|
379
|
+
}
|
|
380
|
+
return { node: nodes.length === 1 ? nodes[0] : el('div', null, nodes), next: i };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* One list. A change of marker type at the same indent — bullet ↔ number, or the first `- [ ]` —
|
|
385
|
+
* starts a sibling list, the way every markdown renderer does it. A deeper indent is nested INSIDE
|
|
386
|
+
* the item above it, which is the case the old parser got visibly wrong: an indented sub-bullet did
|
|
387
|
+
* not match `- `, fell through to the paragraph branch, and was emitted after the list it belonged
|
|
388
|
+
* inside.
|
|
389
|
+
*/
|
|
390
|
+
function renderList(items, start, ctx, top) {
|
|
391
|
+
const base = items[start].indent;
|
|
392
|
+
const { ordered } = items[start];
|
|
393
|
+
const task = RE_TASK.test(items[start].lines[0]);
|
|
394
|
+
const lis = [];
|
|
395
|
+
let i = start;
|
|
396
|
+
while (i < items.length && items[i].indent >= base) {
|
|
397
|
+
if (items[i].indent === base
|
|
398
|
+
&& (items[i].ordered !== ordered || RE_TASK.test(items[i].lines[0]) !== task)) break;
|
|
399
|
+
if (items[i].indent > base) {
|
|
400
|
+
const sub = renderLists(items, i, ctx, false);
|
|
401
|
+
if (lis.length) lis[lis.length - 1].appendChild(sub.node);
|
|
402
|
+
else lis.push(el('li', { style: ST.li }, sub.node));
|
|
403
|
+
i = sub.next; continue;
|
|
404
|
+
}
|
|
405
|
+
const body = items[i].lines.join(' ');
|
|
406
|
+
const t = RE_TASK.exec(body);
|
|
407
|
+
if (t) {
|
|
408
|
+
lis.push(el('li', { style: ST.taskLi },
|
|
409
|
+
el('span', { style: t[1] === ' ' ? ST.box : ST.boxDone, 'aria-hidden': 'true' }, t[1] === ' ' ? '☐' : '☑'),
|
|
410
|
+
el('span', null, inline(t[2], ctx))));
|
|
411
|
+
} else {
|
|
412
|
+
lis.push(el('li', { style: ST.li }, inline(body, ctx)));
|
|
413
|
+
}
|
|
414
|
+
i++;
|
|
415
|
+
}
|
|
416
|
+
const style = task ? ST.taskUl : (ordered ? ST.ol : (top ? ST.ul : ST.subList));
|
|
417
|
+
return { node: el(ordered ? 'ol' : 'ul', { style }, lis), next: i };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export default mdRender;
|
|
@@ -679,6 +679,20 @@ function syncSummary(sy, dest) {
|
|
|
679
679
|
+ '. the task below is what performs it, and no session has reported back.';
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
/* ── the role chip's short form ───────────────────────────────────────────────────────────────
|
|
683
|
+
* The chip is a fixed 52px box — roughly eight characters of 10px mono with `.1em` tracking — and
|
|
684
|
+
* the design drew it that way. The index's canonical role for a user-story document is
|
|
685
|
+
* `user stories`, which is twelve, so it ran under the path beside it.
|
|
686
|
+
*
|
|
687
|
+
* The canonical role is NOT renamed. It is asserted as `user stories` in two suites, and it is the
|
|
688
|
+
* string the search results (`artifact · user stories`) and a document tab's label read — both
|
|
689
|
+
* places where the longer, clearer word is the right one. Only the CHIP is abbreviated, and only
|
|
690
|
+
* where a role is genuinely too long for the box the design specified. The prototype's own stories
|
|
691
|
+
* row says `STORIES`, so this is the design's word, shortened to the case the real index uses.
|
|
692
|
+
*/
|
|
693
|
+
const ROLE_CHIP = { 'user stories': 'stories' };
|
|
694
|
+
function roleChip(role) { return ROLE_CHIP[role] || role; }
|
|
695
|
+
|
|
682
696
|
function artifactRow(slug, a) {
|
|
683
697
|
const key = FX.artKey(slug, a.path);
|
|
684
698
|
// `hasOwnProperty`, not `||`. An unlinked artifact records `null` under its key, and a falsy check
|
|
@@ -693,7 +707,7 @@ function artifactRow(slug, a) {
|
|
|
693
707
|
style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px',
|
|
694
708
|
hover: 'background:var(--accent)',
|
|
695
709
|
},
|
|
696
|
-
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(a.role)),
|
|
710
|
+
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(roleChip(a.role))),
|
|
697
711
|
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
|
|
698
712
|
);
|
|
699
713
|
|
|
@@ -795,6 +809,16 @@ function artifactRow(slug, a) {
|
|
|
795
809
|
const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
|
|
796
810
|
el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
|
|
797
811
|
'⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
|
|
812
|
+
/* WHERE THE CLAIM CAME FROM. A destination read out of the file's own frontmatter is exactly as
|
|
813
|
+
* unverified as one the PM typed — PMOS has not looked at either — but it arrived without
|
|
814
|
+
* anybody in this window doing anything, so the row has to say so or it reads as something the
|
|
815
|
+
* app established. This region already lost that argument once: its predecessor existed because
|
|
816
|
+
* a row said `connected` about a record nobody had checked. Real mode only; the prototype has
|
|
817
|
+
* no such row, and the parity comparator measures the fixture surface against it. */
|
|
818
|
+
(!FIXTURE && dest.origin === 'frontmatter')
|
|
819
|
+
? el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
820
|
+
'declared in the file · nothing has been checked')
|
|
821
|
+
: null,
|
|
798
822
|
el('button', {
|
|
799
823
|
onClick: () => setState((s) => ({ artSyncOpen: { ...s.artSyncOpen, [key]: !s.artSyncOpen[key] } })),
|
|
800
824
|
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
|
|
@@ -361,6 +361,84 @@ const scratchTa = el('textarea', {
|
|
|
361
361
|
onInput: (e) => onScratchInput(e.target.value),
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
/* ── the drawer's height ────────────────────────────────────────────────────────────────────
|
|
365
|
+
* 96px — four lines of the 12px mono face — was a literal in the style string above, and it was the
|
|
366
|
+
* pad's only size. The pad's job (see the block comment above) is to stage the next thing to type
|
|
367
|
+
* into a particular agent, and a staged instruction is routinely longer than four lines, so the PM
|
|
368
|
+
* composed in a box that scrolled internally while a large empty pane sat above it.
|
|
369
|
+
*
|
|
370
|
+
* The handle is REAL MODE ONLY. The parity comparator renders the app at `?fixture=proto` against
|
|
371
|
+
* the PM's own prototype and diffs text nodes, computed styles, geometry and pixels; the prototype
|
|
372
|
+
* has no such control, so drawing one in fixture mode would be a difference the comparator is right
|
|
373
|
+
* to report. This module already draws that line for the placeholder string, and for the same
|
|
374
|
+
* reason.
|
|
375
|
+
*
|
|
376
|
+
* The clamps are the two ways this could damage the surface it sits in. `SCRATCH_MIN` keeps two
|
|
377
|
+
* lines and the button row reachable — a drawer that can be dragged shut is a drawer with two
|
|
378
|
+
* different closed states, one of which has no way back. The 70% ceiling keeps the pane above from
|
|
379
|
+
* being driven to nothing, which matters more than it sounds: that pane is frequently a live PTY.
|
|
380
|
+
*
|
|
381
|
+
* NOTHING HERE TOUCHES THE PAD. Resizing does not read or write `state.scratchText`, does not call
|
|
382
|
+
* `syncPad` or `flushPad`, and cannot change `state.scratchKey`. The height is a property of the
|
|
383
|
+
* window, the text is a property of the session, and the two stores stay separate.
|
|
384
|
+
*/
|
|
385
|
+
const SCRATCH_MIN = 56;
|
|
386
|
+
const SCRATCH_DEFAULT = 96;
|
|
387
|
+
const SCRATCH_MAX_FRACTION = 0.7;
|
|
388
|
+
const SCRATCH_STEP = 16;
|
|
389
|
+
|
|
390
|
+
/** The tallest the pad may be right now: 70% of panel 4, never below the minimum. */
|
|
391
|
+
function scratchMax() {
|
|
392
|
+
const host = scratchBlock && scratchBlock.parentElement;
|
|
393
|
+
const h = host && host.clientHeight ? host.clientHeight : 0;
|
|
394
|
+
// No host to measure (the drawer is not mounted yet) — fall back to the viewport, and to a plain
|
|
395
|
+
// number if there is no window either, so this is safe to call from a test harness.
|
|
396
|
+
const basis = h || (typeof window !== 'undefined' && window.innerHeight) || 600;
|
|
397
|
+
return Math.max(SCRATCH_MIN, Math.round(basis * SCRATCH_MAX_FRACTION));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function clampScratchH(v) {
|
|
401
|
+
const n = Math.round(Number(v));
|
|
402
|
+
if (!Number.isFinite(n)) return SCRATCH_DEFAULT;
|
|
403
|
+
return Math.min(scratchMax(), Math.max(SCRATCH_MIN, n));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setScratchH(v) {
|
|
407
|
+
const h = clampScratchH(v);
|
|
408
|
+
if (h === state.scratchH) return;
|
|
409
|
+
setState({ scratchH: h });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let dragFrom = null; // {y, h} while the handle is held; null otherwise
|
|
413
|
+
|
|
414
|
+
const scratchGrip = el('div', {
|
|
415
|
+
tabindex: '0',
|
|
416
|
+
role: 'separator',
|
|
417
|
+
'aria-orientation': 'horizontal',
|
|
418
|
+
'aria-label': 'resize the scratchpad — drag, or ↑ / ↓',
|
|
419
|
+
title: 'drag to resize · ↑ / ↓ to nudge · double-click to reset',
|
|
420
|
+
style: 'flex:none;height:7px;cursor:ns-resize;background:oklch(0.11 0.004 228.8);'
|
|
421
|
+
+ 'border-top:1px solid oklch(1 0 0 / 10%);touch-action:none',
|
|
422
|
+
hover: 'background:oklch(1 0 0 / 10%)',
|
|
423
|
+
onPointerdown: (e) => {
|
|
424
|
+
dragFrom = { y: e.clientY, h: state.scratchH || SCRATCH_DEFAULT };
|
|
425
|
+
try { e.target.setPointerCapture(e.pointerId); } catch { /* not captured — the move still works */ }
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
},
|
|
428
|
+
// Up is taller: the drawer grows toward the top edge the PM is pulling, which is the direction
|
|
429
|
+
// the handle itself moves. `clientY` decreases as the pointer rises, hence `from.y - clientY`.
|
|
430
|
+
onPointermove: (e) => { if (dragFrom) setScratchH(dragFrom.h + (dragFrom.y - e.clientY)); },
|
|
431
|
+
onPointerup: () => { dragFrom = null; },
|
|
432
|
+
onPointercancel: () => { dragFrom = null; },
|
|
433
|
+
onDblclick: () => setScratchH(SCRATCH_DEFAULT),
|
|
434
|
+
onKeydown: (e) => {
|
|
435
|
+
const h = state.scratchH || SCRATCH_DEFAULT;
|
|
436
|
+
if (e.key === 'ArrowUp') { setScratchH(h + SCRATCH_STEP); e.preventDefault(); }
|
|
437
|
+
else if (e.key === 'ArrowDown') { setScratchH(h - SCRATCH_STEP); e.preventDefault(); }
|
|
438
|
+
else if (e.key === 'Home') { setScratchH(SCRATCH_DEFAULT); e.preventDefault(); }
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
364
442
|
/* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
|
|
365
443
|
|
|
366
444
|
const padLoading = new Set(); // keys with a read in flight — one read per key, ever
|
|
@@ -467,6 +545,7 @@ const scratchSendBtn = el('button', {
|
|
|
467
545
|
const scratchBlock = el('div', {
|
|
468
546
|
style: 'display:flex;flex-direction:column;border-top:1px solid oklch(1 0 0 / 10%)',
|
|
469
547
|
},
|
|
548
|
+
FIXTURE ? null : scratchGrip,
|
|
470
549
|
scratchTa,
|
|
471
550
|
el('div', { style: 'display:flex;gap:6px;padding:5px 12px 7px;background:oklch(0.11 0.004 228.8)' },
|
|
472
551
|
scratchCopyBtn, scratchSendBtn),
|
|
@@ -814,6 +893,14 @@ export function renderTabs(host) {
|
|
|
814
893
|
const scratchText = state.scratchText || '';
|
|
815
894
|
if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
|
|
816
895
|
if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
|
|
896
|
+
/* The drawer's height, real mode only. The style ATTRIBUTE keeps the prototype's string; only the
|
|
897
|
+
* resolved `height` property is written, so the fixture render — which never reaches here — stays
|
|
898
|
+
* byte-identical to the design. Written on every paint rather than only on resize, because the
|
|
899
|
+
* restored value has to land on the first one. */
|
|
900
|
+
if (!FIXTURE) {
|
|
901
|
+
const h = clampScratchH(state.scratchH || SCRATCH_DEFAULT) + 'px';
|
|
902
|
+
if (scratchTa.style.height !== h) scratchTa.style.height = h;
|
|
903
|
+
}
|
|
817
904
|
scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
|
|
818
905
|
const canSend = !!(act && act.kind === 'term');
|
|
819
906
|
scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
|
|
@@ -88,6 +88,10 @@ function initial() {
|
|
|
88
88
|
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
89
89
|
newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
|
|
90
90
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
91
|
+
/* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
|
|
92
|
+
* the default; the PM can drag it, and the drag is clamped there rather than here — this store
|
|
93
|
+
* only has to refuse a value that could not have come from the clamp. */
|
|
94
|
+
scratchH: 96,
|
|
91
95
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
92
96
|
savedDocs: {}, scratchCopied: false,
|
|
93
97
|
/* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
|
|
@@ -169,6 +173,12 @@ const PERSIST = {
|
|
|
169
173
|
active: isSlug,
|
|
170
174
|
splitId: isSlug,
|
|
171
175
|
scratchOpen: isBool,
|
|
176
|
+
/* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
|
|
177
|
+
* hand-edited record must not be able to render a drawer with no textarea in it, or one taller
|
|
178
|
+
* than any window it could be restored into. The upper bound here is deliberately generous (the
|
|
179
|
+
* real ceiling is 70% of panel 4, which this module cannot measure) — panel-tabs.js clamps the
|
|
180
|
+
* value again against the live panel before it reaches the DOM. */
|
|
181
|
+
scratchH: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 56 && v <= 4000,
|
|
172
182
|
};
|
|
173
183
|
|
|
174
184
|
/** What is WRITTEN for a key, when it is not the live value verbatim. */
|
|
@@ -22,11 +22,24 @@
|
|
|
22
22
|
* Every keystroke is a state change, and a state change repaints the panel. A textarea rebuilt on
|
|
23
23
|
* each keystroke loses the caret, so the element is created once per tab and kept; its text is
|
|
24
24
|
* written back only when it actually differs from what the box already holds.
|
|
25
|
+
*
|
|
26
|
+
* ── …and why the SCROLLING ELEMENT does too ─────────────────────────────────────────────────
|
|
27
|
+
* A scroll offset is the same class of state as a caret, and it was not getting the same treatment:
|
|
28
|
+
* `view`, `raw` and `diff` each built a fresh scrolling div on every paint, so any repaint — the
|
|
29
|
+
* 4-second session poll included — took a half-read document back to the top. `scrollPane` keeps one
|
|
30
|
+
* element per tab and mode; see the block comment above it.
|
|
31
|
+
*
|
|
32
|
+
* ── What `view` renders it through ──────────────────────────────────────────────────────────
|
|
33
|
+
* `markdown.js`, not `fixture.js`'s `mdParse`. The latter is a transcription of the design
|
|
34
|
+
* prototype's four-branch stand-in (`# `/`## `/`- `/paragraph) pinned byte-for-byte by
|
|
35
|
+
* `fixture-parity.test.mjs`; it stays exactly as it is, and it is not a markdown renderer.
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
import { el, setChildren } from './dom.js';
|
|
28
39
|
import { state, setState, FIXTURE } from './state.js';
|
|
29
40
|
import * as FX from './fixture.js';
|
|
41
|
+
import { mdRender } from './markdown.js';
|
|
42
|
+
import { openDoc } from './panel-initiatives.js';
|
|
30
43
|
|
|
31
44
|
/** `{{ expr }}` — one interpolated value in its own span, as the prototype's runtime emits it. */
|
|
32
45
|
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
@@ -152,36 +165,68 @@ function revert(tab) {
|
|
|
152
165
|
const FM_K = 'font-size:10.5px;color:var(--muted-foreground);padding-top:2px;font-family:var(--mono)';
|
|
153
166
|
const FM_V = 'overflow-wrap:anywhere;font-family:var(--mono);font-size:11.5px';
|
|
154
167
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
const VIEW_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65';
|
|
169
|
+
const RAW_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7';
|
|
170
|
+
const DIFF_ST = 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7';
|
|
171
|
+
|
|
172
|
+
/* ── THE SCROLL POSITION IS STATE, AND IT LIVES ON A NODE ──────────────────────────────────────
|
|
173
|
+
*
|
|
174
|
+
* Every `setState` anywhere in the app repaints all four panels, and panel 4 calls `mountDoc` on
|
|
175
|
+
* every paint. Each body used to be built from scratch, and `setChildren` reconciles by node
|
|
176
|
+
* IDENTITY — so the div `scrollTop` lives on was removed and a fresh one inserted, and a half-read
|
|
177
|
+
* document went back to line 1. With a live agent session beside it that fires on the session poll,
|
|
178
|
+
* at four-second intervals the PM neither caused nor can predict.
|
|
179
|
+
*
|
|
180
|
+
* This is the same problem the edit textarea has, and it gets the same answer: the element is
|
|
181
|
+
* created once per tab and per mode and KEPT. A repaint whose text has not changed hands back the
|
|
182
|
+
* identical node, which is what `setChildren` needs in order to leave it alone; when the text really
|
|
183
|
+
* does change the children are rebuilt INSIDE that element and the offset is restored, because the
|
|
184
|
+
* document being read is still the document being read.
|
|
185
|
+
*
|
|
186
|
+
* Keyed on the rendered text, not on the tab alone — that is what makes "changed nothing" decidable
|
|
187
|
+
* without diffing DOM.
|
|
188
|
+
*/
|
|
189
|
+
const scrollers = new Map(); // tab id -> { view|raw|diff: { key, node } }
|
|
190
|
+
|
|
191
|
+
function scrollPane(tab, mode, key, style, build) {
|
|
192
|
+
let per = scrollers.get(tab.id);
|
|
193
|
+
if (!per) { per = {}; scrollers.set(tab.id, per); }
|
|
194
|
+
const cur = per[mode];
|
|
195
|
+
if (cur && cur.key === key) return cur.node; // nothing changed — the very same element
|
|
196
|
+
const node = (cur && cur.node) || el('div', { style });
|
|
197
|
+
const top = node.scrollTop;
|
|
198
|
+
setChildren(node, build());
|
|
199
|
+
if (top) node.scrollTop = top;
|
|
200
|
+
per[mode] = { key, node };
|
|
201
|
+
return node;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What links and images in this document resolve against. `onDoc` opens a .md link as a doc tab. */
|
|
205
|
+
const linkCtx = (tab) => ({
|
|
206
|
+
dir: String(tab.path || '').split('/').slice(0, -1),
|
|
207
|
+
ws: wsId(),
|
|
208
|
+
onDoc: (p) => openDoc(tab.slug, p, 'FILE'),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
function viewBody(tab, text) {
|
|
212
|
+
return scrollPane(tab, 'view', text, VIEW_ST, () => {
|
|
213
|
+
const doc = mdRender(text, linkCtx(tab));
|
|
214
|
+
const fm = [];
|
|
215
|
+
doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
|
|
216
|
+
return [el('div', { style: 'max-width:72ch' },
|
|
163
217
|
el('div', {
|
|
164
218
|
style: 'border:1px solid var(--border);background:var(--card);padding:10px 12px;margin-bottom:18px;display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px',
|
|
165
219
|
}, fm),
|
|
166
220
|
el('h1', {
|
|
167
221
|
style: 'font-size:21px;font-weight:600;line-height:1.3;margin:4px 0 12px;text-wrap:balance;font-family:var(--sans)',
|
|
168
|
-
},
|
|
169
|
-
doc.blocks.map((bk) => el('div', null,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}, iv(bk.h)) : null,
|
|
173
|
-
bk.isP ? el('p', { style: 'margin:0 0 12px' }, iv(bk.p)) : null,
|
|
174
|
-
bk.isList ? el('ul', { style: 'margin:0 0 12px;padding-left:22px' },
|
|
175
|
-
bk.items.map((li) => el('li', { style: 'margin:0 0 5px;padding-left:2px' }, iv(li)))) : null,
|
|
176
|
-
)),
|
|
177
|
-
),
|
|
178
|
-
);
|
|
222
|
+
}, doc.title || iv('')),
|
|
223
|
+
doc.blocks.map((bk) => el('div', null, bk)),
|
|
224
|
+
)];
|
|
225
|
+
});
|
|
179
226
|
}
|
|
180
227
|
|
|
181
|
-
function rawBody(text) {
|
|
182
|
-
return
|
|
183
|
-
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
184
|
-
}, text.split('\n').map((l) => {
|
|
228
|
+
function rawBody(tab, text) {
|
|
229
|
+
return scrollPane(tab, 'raw', text, RAW_ST, () => text.split('\n').map((l) => {
|
|
185
230
|
const ln = FX.rawLine(l);
|
|
186
231
|
return el('div', {
|
|
187
232
|
style: 'color:' + ln.c + ';font-weight:' + ln.w + ';white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
|
|
@@ -254,13 +299,18 @@ function htmlFrame(tab, text) {
|
|
|
254
299
|
return node;
|
|
255
300
|
}
|
|
256
301
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
302
|
+
/* Two texts joined into one key. The separator is a character no document can contain — the same
|
|
303
|
+
* reasoning `sessionProjection` uses in data.js — so a change that only moves the boundary between
|
|
304
|
+
* them cannot fold into an identical key and suppress a rebuild that was real. */
|
|
305
|
+
const SEP = '\u0000';
|
|
306
|
+
|
|
307
|
+
function diffBody(tab, raw, text, dirty) {
|
|
308
|
+
return scrollPane(tab, 'diff', dirty ? raw + SEP + text : '', DIFF_ST, () => {
|
|
309
|
+
const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
|
|
310
|
+
return rows.map((dr) => el('div', {
|
|
311
|
+
style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
|
|
312
|
+
}, iv(dr.t)));
|
|
313
|
+
});
|
|
264
314
|
}
|
|
265
315
|
|
|
266
316
|
/* ── per-tab state that is DOM, not data ───────────────────────────────────────────────────── */
|
|
@@ -287,6 +337,7 @@ function editBox(tab) {
|
|
|
287
337
|
export function disposeDoc(id) {
|
|
288
338
|
boxes.delete(id);
|
|
289
339
|
frames.delete(id);
|
|
340
|
+
scrollers.delete(id);
|
|
290
341
|
}
|
|
291
342
|
|
|
292
343
|
/* ── mount ─────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -335,8 +386,8 @@ export function mountDoc(host, tab) {
|
|
|
335
386
|
);
|
|
336
387
|
|
|
337
388
|
let body;
|
|
338
|
-
if (dm === 'raw') body = rawBody(text);
|
|
339
|
-
else if (dm === 'diff') body = diffBody(raw, text, dirty);
|
|
389
|
+
if (dm === 'raw') body = rawBody(tab, text);
|
|
390
|
+
else if (dm === 'diff') body = diffBody(tab, raw, text, dirty);
|
|
340
391
|
else if (dm === 'edit') {
|
|
341
392
|
body = editBox(tab);
|
|
342
393
|
/* `defaultValue` IS the textarea's child text node, and the prototype's runtime keeps it in
|
|
@@ -345,7 +396,7 @@ export function mountDoc(host, tab) {
|
|
|
345
396
|
* genuinely differs, which is what keeps the caret still mid-sentence. */
|
|
346
397
|
if (body.defaultValue !== text) body.defaultValue = text;
|
|
347
398
|
if (body.value !== text) body.value = text;
|
|
348
|
-
} else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(text);
|
|
399
|
+
} else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(tab, text);
|
|
349
400
|
|
|
350
401
|
setChildren(host, [modeBar, body]);
|
|
351
402
|
return host;
|
package/workspace/server.js
CHANGED
|
@@ -1051,6 +1051,11 @@ const ROLE_BY_DIR = {
|
|
|
1051
1051
|
// the only artifact that exists for work backfilled from a merge. Before it was a role, a record
|
|
1052
1052
|
// with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
|
|
1053
1053
|
initiatives: 'record',
|
|
1054
|
+
// A story file that does NOT wear one of the suffixes above — `planning/stories/checkout.md` —
|
|
1055
|
+
// was role `doc` and therefore indistinguishable from a stray note. These two directory names are
|
|
1056
|
+
// the same set `scripts/frontmatter_lint.py` holds to `type: Story`; the board and the gate are
|
|
1057
|
+
// deliberately one definition of what a story is.
|
|
1058
|
+
stories: 'user stories', 'user-stories': 'user stories',
|
|
1054
1059
|
};
|
|
1055
1060
|
// ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
|
|
1056
1061
|
// The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
|
|
@@ -1120,8 +1125,44 @@ function parseHead(full) {
|
|
|
1120
1125
|
recordId: rid ? rid[1] : null,
|
|
1121
1126
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
1122
1127
|
kr: clean(kr), lane: clean(lane),
|
|
1128
|
+
dest: frontmatterDest(head),
|
|
1123
1129
|
};
|
|
1124
1130
|
}
|
|
1131
|
+
|
|
1132
|
+
/* ── the destination a document declares about ITSELF ────────────────────────────────────────
|
|
1133
|
+
* A user story is written to be handed to a tracker, and until now the only place that tie could
|
|
1134
|
+
* live was `artifact-links.jsonl` in the workspace's runtime state directory — outside the
|
|
1135
|
+
* repository, invisible to git, invisible to an agent reading the file, and absent on every other
|
|
1136
|
+
* machine that opens the same repo. A key in the file's own frontmatter puts the fact where the
|
|
1137
|
+
* artifact is.
|
|
1138
|
+
*
|
|
1139
|
+
* IT IS A DECLARATION AND NOTHING MORE. This is the same claim `state: 'declared'` makes in the
|
|
1140
|
+
* store: the author says this file corresponds to that record. PMOS has not looked, holds no
|
|
1141
|
+
* credential, and makes no request — only a session carrying the PM's own MCP can produce
|
|
1142
|
+
* `resolved` (D53), and nothing on this path can. The panel attributes the row to the file so the
|
|
1143
|
+
* two are never confused, which is the whole lesson of the predecessor initiative: a row that said
|
|
1144
|
+
* `connected` about a record nobody had checked.
|
|
1145
|
+
*
|
|
1146
|
+
* ONE destination, in LINK_KINDS order, because the artifact row draws one pill. A file declaring
|
|
1147
|
+
* two has declared the first; that is a smaller surprise than silently drawing the second.
|
|
1148
|
+
* The target is capped at 300 characters — the same cap the store's own validator applies to a
|
|
1149
|
+
* typed one — and is rendered as text, never as a link target.
|
|
1150
|
+
*/
|
|
1151
|
+
const FRONTMATTER_DEST = [
|
|
1152
|
+
['jira', /^\s*[-*]?\s*jira\s*:\s*(.+?)\s*$/mi],
|
|
1153
|
+
['confluence', /^\s*[-*]?\s*confluence\s*:\s*(.+?)\s*$/mi],
|
|
1154
|
+
['google', /^\s*[-*]?\s*(?:google_doc|google-doc|gdoc)\s*:\s*(.+?)\s*$/mi],
|
|
1155
|
+
];
|
|
1156
|
+
function frontmatterDest(head) {
|
|
1157
|
+
for (const [kind, re] of FRONTMATTER_DEST) {
|
|
1158
|
+
const m = String(head || '').match(re);
|
|
1159
|
+
if (!m) continue;
|
|
1160
|
+
const target = m[1].replace(/^["']|["']$/g, '').trim().slice(0, 300);
|
|
1161
|
+
if (!target) continue;
|
|
1162
|
+
return { kind, target, origin: 'frontmatter', state: 'declared' };
|
|
1163
|
+
}
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1125
1166
|
function roleAndStem(rel, base) {
|
|
1126
1167
|
const stem = base.replace(/\.md$/, '');
|
|
1127
1168
|
for (const [suf, role] of ROLE_SUFFIXES) {
|
|
@@ -1145,6 +1186,7 @@ function docMeta(root, full) {
|
|
|
1145
1186
|
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
1146
1187
|
stem, title: head.title || null,
|
|
1147
1188
|
kr: head.kr || null, lane: head.lane || null,
|
|
1189
|
+
dest: head.dest || null,
|
|
1148
1190
|
};
|
|
1149
1191
|
metaCache.set(full, meta);
|
|
1150
1192
|
return meta;
|
|
@@ -1230,7 +1272,7 @@ function initiativeIndex(root) {
|
|
|
1230
1272
|
};
|
|
1231
1273
|
for (const m of metas) {
|
|
1232
1274
|
const k = keyFor(m);
|
|
1233
|
-
const art = { name: m.name, path: m.path, role: m.role, title: m.title };
|
|
1275
|
+
const art = { name: m.name, path: m.path, role: m.role, title: m.title, dest: m.dest || null };
|
|
1234
1276
|
if (!k) { ungrouped.push(art); continue; }
|
|
1235
1277
|
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
1236
1278
|
const g = groups.get(k);
|
|
@@ -1242,7 +1284,11 @@ function initiativeIndex(root) {
|
|
|
1242
1284
|
if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
|
|
1243
1285
|
if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
|
|
1244
1286
|
}
|
|
1245
|
-
|
|
1287
|
+
// `user stories` sits directly after `PRD` because that is what it IS: the spec decomposed into
|
|
1288
|
+
// the units of work someone builds. It was absent from this list, so `indexOf` returned -1, the
|
|
1289
|
+
// comparator mapped it to 99, and an initiative's stories sorted below every artifact they
|
|
1290
|
+
// decompose — into the trailing alphabetical block with `doc` and `research`.
|
|
1291
|
+
const ROLE_ORDER = ['record', 'PRD', 'user stories', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
1246
1292
|
const initiatives = [...groups.values()].map((g) => {
|
|
1247
1293
|
g.artifacts.sort((a, b) => {
|
|
1248
1294
|
const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
|
|
@@ -1468,7 +1514,7 @@ function surfaceInitiatives(root, w) {
|
|
|
1468
1514
|
accepted: state === 'accepted',
|
|
1469
1515
|
delivered: state === 'accepted' || state === 'delivered',
|
|
1470
1516
|
delivery_evidence: deliveredBy,
|
|
1471
|
-
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
|
|
1517
|
+
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path, dest: a2.dest || null })),
|
|
1472
1518
|
// NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
|
|
1473
1519
|
rice: null, effort: null, milestones: null, progress: null, tracked: false,
|
|
1474
1520
|
};
|