@uniqbit/mate-core 0.18.5 → 1.0.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/package.json +10 -2
  4. package/src/brand/art.generated.ts +69 -0
  5. package/src/brand/index.ts +12 -0
  6. package/src/brand/palette.ts +19 -0
  7. package/src/brand/svg.generated.ts +8 -0
  8. package/src/brand/terminal.ts +83 -0
  9. package/src/brand/types.ts +33 -0
  10. package/src/cli/commands/artifact/artifact.ts +8 -0
  11. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  12. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  13. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  14. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  15. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  16. package/src/cli/commands/artifact/finish/command.ts +33 -3
  17. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  18. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  19. package/src/cli/commands/artifact/finish/openspec.ts +177 -138
  20. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  21. package/src/cli/commands/artifact/pending/command.ts +31 -3
  22. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  23. package/src/cli/commands/cap/graphify.ts +52 -3
  24. package/src/cli/commands/cap/index-cmd.ts +8 -1
  25. package/src/cli/commands/cap/openspec.ts +13 -25
  26. package/src/cli/commands/companion/open.ts +56 -39
  27. package/src/cli/commands/companion/update.ts +75 -42
  28. package/src/cli/commands/doctor.ts +176 -3
  29. package/src/cli/commands/plugin/install.ts +9 -4
  30. package/src/cli/commands/plugin/plugin.ts +3 -2
  31. package/src/cli/commands/report/adapter.ts +11 -2
  32. package/src/cli/commands/report/contract.ts +21 -0
  33. package/src/cli/commands/report/highlight.ts +34 -4
  34. package/src/cli/commands/report/markdown.ts +110 -0
  35. package/src/cli/commands/report/palette.ts +118 -0
  36. package/src/cli/commands/report/renderer.ts +456 -153
  37. package/src/cli/commands/report/types.ts +16 -0
  38. package/src/cli/commands/setup.ts +7 -15
  39. package/src/cli/commands/studio/client-asset.ts +16 -0
  40. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  41. package/src/cli/commands/studio/routes.ts +2 -0
  42. package/src/cli/commands/studio/selection.ts +19 -1
  43. package/src/cli/commands/studio/server.ts +42 -1
  44. package/src/cli/commands/studio/views/client.ts +6 -616
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  46. package/src/cli/commands/studio/views/document.tsx +137 -69
  47. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  48. package/src/cli/commands/studio/views/palette.ts +134 -0
  49. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  50. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  51. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  52. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  53. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  54. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  55. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  56. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  57. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  58. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  59. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  60. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  61. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  62. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  63. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  64. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  65. package/src/cli/commands/studio/views/styles.ts +217 -206
  66. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  67. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  68. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  69. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  70. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  71. package/src/cli/commands/wrap.ts +5 -13
  72. package/src/cli/main.ts +4 -1
  73. package/src/cli/parse-flags.ts +25 -7
  74. package/src/cli/setup-selector.tsx +38 -162
  75. package/src/cli/usage.ts +4 -0
  76. package/src/hooks/companion-instructions.ts +190 -0
  77. package/src/hooks/hook-io.ts +63 -0
  78. package/src/hooks/session-banner.ts +21 -29
  79. package/src/hooks/session-guidance.ts +9 -17
  80. package/src/hooks/session-prepare.ts +115 -28
  81. package/src/hooks/validate-artifact-path.ts +82 -24
  82. package/src/index.ts +4 -0
  83. package/src/lib/change-layout.ts +154 -0
  84. package/src/lib/components/wizard-header.tsx +3 -1
  85. package/src/lib/context-mode-package.ts +24 -11
  86. package/src/lib/delivery/archive-change.ts +154 -0
  87. package/src/lib/delivery/branches.ts +108 -0
  88. package/src/lib/delivery/environment.ts +29 -0
  89. package/src/lib/delivery/forge.ts +279 -0
  90. package/src/lib/delivery/overlap.ts +202 -0
  91. package/src/lib/delivery/record-delivery.ts +196 -0
  92. package/src/lib/delivery/record.ts +343 -0
  93. package/src/lib/delivery/refresh.ts +174 -0
  94. package/src/lib/delivery/repository-access.ts +97 -0
  95. package/src/lib/delivery/stages.ts +111 -0
  96. package/src/lib/delivery/token.ts +58 -0
  97. package/src/lib/frontmatter.ts +18 -0
  98. package/src/lib/opencode-plugin-package.ts +5 -5
  99. package/src/lib/openspec-cli.ts +116 -0
  100. package/src/lib/openspec-scopes.ts +86 -0
  101. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  102. package/src/lib/orchestrator/adapters/claude.ts +48 -40
  103. package/src/lib/orchestrator/adapters/opencode.ts +2 -0
  104. package/src/lib/orchestrator/editor.ts +9 -9
  105. package/src/lib/orchestrator/openspec-store.ts +14 -54
  106. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  107. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  108. package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
  109. package/src/lib/orchestrator/projection-entries.ts +45 -14
  110. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  111. package/src/lib/orchestrator/projection-types.ts +3 -0
  112. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  113. package/src/lib/orchestrator/session-prepare.ts +45 -5
  114. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  115. package/src/lib/orchestrator/types.ts +2 -2
  116. package/src/lib/orchestrator/wrap-pass.ts +22 -1
  117. package/src/lib/package-paths.ts +8 -0
  118. package/src/opencode/companion-hooks.ts +64 -5
  119. package/src/opencode/companion-policy.ts +37 -18
  120. package/src/opencode/projected-guidance.ts +4 -1
  121. package/src/opencode/session-prepare.ts +2 -1
  122. package/src/opencode/tui.tsx +4 -2
  123. package/src/runtime/artifact-refusal.ts +63 -0
  124. package/src/runtime/claude-delivery-plan.ts +89 -7
  125. package/src/runtime/commit-attribution-guard.ts +181 -0
  126. package/src/runtime/companion-guidance.ts +15 -3
  127. package/src/runtime/companion-instructions.ts +340 -0
  128. package/src/runtime/error-message.ts +4 -0
  129. package/src/runtime/index.ts +5 -0
  130. package/src/runtime/managed-block.ts +117 -0
  131. package/src/runtime/markdown-scan.ts +266 -0
  132. package/src/runtime/policy.ts +15 -1
  133. package/src/runtime/projected-flag.ts +7 -0
  134. package/src/runtime/projected-guidance.ts +1 -0
  135. package/src/runtime/runtime-document-manifest.ts +62 -11
  136. package/src/runtime/secret-guard.ts +148 -0
  137. package/src/runtime/secret-patterns.ts +164 -0
  138. package/src/runtime/secret-scan.ts +90 -0
  139. package/src/runtime/session-kind.ts +13 -0
  140. package/src/runtime/shell-heredoc.ts +81 -0
  141. package/src/runtime/wrap-state.ts +36 -2
  142. package/src/runtime/write-atomic.ts +64 -0
  143. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  144. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  145. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  146. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  147. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  148. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  149. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  150. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  151. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  152. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  153. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  154. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  155. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  156. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  157. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  158. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  159. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  160. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  161. package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
  162. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  163. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  164. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  165. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  166. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  167. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  168. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  169. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  170. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  171. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  172. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  173. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  174. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  175. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  176. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  177. package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
  178. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  179. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  180. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  181. package/src/templates/mate-skills/openspec-references.ts +140 -0
  182. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  183. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  184. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  185. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  186. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  187. package/src/tools/setup/capabilities/graphify.ts +12 -0
  188. package/src/tools/setup/capabilities/openspec.ts +151 -270
  189. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  190. package/src/tools/setup/context-services.ts +9 -17
  191. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  192. package/src/tools/setup/engine.ts +97 -21
  193. package/src/tools/setup/managed-output.ts +51 -0
  194. package/src/tools/setup/mate.ts +86 -11
  195. package/src/tools/setup/plugin.ts +35 -0
  196. package/src/tools/setup/plugins/guidance.ts +7 -4
  197. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  198. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  199. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  200. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  201. package/src/tools/setup/providers/claude.ts +284 -8
  202. package/src/tools/setup/providers/opencode.ts +180 -1
  203. package/src/tools/setup/utils.ts +14 -4
  204. package/src/tools/setup.ts +126 -10
  205. package/wrappers/bin/graphify +52 -4
  206. package/wrappers/bin/openspec +33 -9
  207. package/wrappers/lib/graphify-gate.mjs +8 -0
  208. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  209. package/src/tools/setup/runtime-documents.ts +0 -291
@@ -10,13 +10,22 @@ import {
10
10
  type StudioView,
11
11
  switchView,
12
12
  toHref,
13
+ withMode,
13
14
  } from "../selection";
14
- import { REPORTS_ROUTE, VAULT_CHANGES_ROUTE, VAULT_DIR_ROUTE, VAULT_FILTER_ROUTE } from "../routes";
15
- import { STUDIO_CLIENT_SCRIPT, STUDIO_PREPAINT_SCRIPT } from "./client";
15
+ import {
16
+ REPORTS_ROUTE,
17
+ VAULT_CHANGES_ROUTE,
18
+ VAULT_DIR_ROUTE,
19
+ VAULT_FILTER_ROUTE,
20
+ VAULT_PANE_ROUTE,
21
+ } from "../routes";
22
+ import { STUDIO_CLIENT_ROUTE } from "../client-asset";
23
+ import { STUDIO_PREPAINT_SCRIPT } from "./client";
16
24
  import { CompanionPicker } from "./companion-picker";
17
25
  import { CompanionSelector } from "./companion-selector";
18
26
  import { Dashboard } from "./dashboard/index";
19
27
  import { CompanionError } from "./error";
28
+ import { MarkdownView } from "./markdown";
20
29
  import { formatCollectedAt, type StudioPage, type StudioVaultPage } from "./model";
21
30
  import type { VaultTreeNode } from "../vault";
22
31
  import { Reports } from "./reports/index";
@@ -34,35 +43,18 @@ import {
34
43
  TerminalSidebar,
35
44
  } from "./terminal";
36
45
  import { TERMINAL_SCRIPTS, TERMINAL_STYLESHEET } from "../terminal-assets";
46
+ import { BRAND_SVGS } from "../../../../brand/svg.generated";
37
47
 
38
48
  const STUDIO_TITLE = "Mate Studio";
39
49
 
40
- const VIEW_DETAILS = {
41
- dashboard: {
42
- eyebrow: "Companion lookup",
43
- title: "Overview",
44
- },
45
- specs: {
46
- eyebrow: "Companion lookup",
47
- title: "Specs",
48
- },
49
- workflow: {
50
- eyebrow: "Companion lookup",
51
- title: "Workflow",
52
- },
53
- skills: {
54
- eyebrow: "Agent guidance",
55
- title: "Skills",
56
- },
57
- vault: {
58
- eyebrow: "Companion files",
59
- title: "Vault",
60
- },
61
- reports: {
62
- eyebrow: "Agent output",
63
- title: "Reports",
64
- },
65
- } as const;
50
+ /** Base64 rather than URI-encoded, so the SVG namespace URL never reads as a page reference. */
51
+ function svgDataUri(svg: string): string {
52
+ return `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`;
53
+ }
54
+
55
+ const FAVICON_URI = svgDataUri(BRAND_SVGS.favicon);
56
+ const SYMBOL_COLOR_URI = svgDataUri(BRAND_SVGS.symbolColor);
57
+ const SYMBOL_ON_DARK_URI = svgDataUri(BRAND_SVGS.symbolOnDark);
66
58
 
67
59
  /**
68
60
  * `<style>` and `<script>` are HTML raw-text elements: a character reference
@@ -85,11 +77,12 @@ export function renderStudioDocument(page: StudioPage): string {
85
77
  <meta charset="utf-8">
86
78
  <meta name="viewport" content="width=device-width, initial-scale=1">
87
79
  <title>${STUDIO_TITLE}</title>
80
+ <link rel="icon" type="image/svg+xml" href="${FAVICON_URI}">
88
81
  <style>${STUDIO_STYLES}</style>
89
82
  <script>${STUDIO_PREPAINT_SCRIPT}</script>${terminalHead}
90
83
  </head>
91
84
  <body>${String(<StudioShell page={page} />)}
92
- <script>${STUDIO_CLIENT_SCRIPT}</script>${terminalBody}
85
+ <script src="${STUDIO_CLIENT_ROUTE}"></script>${terminalBody}
93
86
  </body>
94
87
  </html>
95
88
  `;
@@ -174,10 +167,11 @@ function Sidebar({ page }: { page: StudioPage }) {
174
167
  return (
175
168
  <aside className="rail studio-sidebar" id="studio-rail">
176
169
  <div className="sidebar-brand">
177
- <span className="sidebar-brand-mark">M</span>
170
+ {/* Both versions ship; the stylesheet shows the one matching the appearance. */}
171
+ <img className="sidebar-brand-mark brand-symbol-color" src={SYMBOL_COLOR_URI} alt="" />
172
+ <img className="sidebar-brand-mark brand-symbol-on-dark" src={SYMBOL_ON_DARK_URI} alt="" />
178
173
  <div className="sidebar-brand-copy">
179
174
  <strong>{STUDIO_TITLE}</strong>
180
- <span>Developer lookup</span>
181
175
  </div>
182
176
  </div>
183
177
  <CompanionSelector inventory={page.inventory} selection={page.selection} />
@@ -191,13 +185,10 @@ function Sidebar({ page }: { page: StudioPage }) {
191
185
  function PageHeader({ page }: { page: StudioPage }) {
192
186
  if (!page.companion) return null;
193
187
 
194
- const details = VIEW_DETAILS[page.selection.view];
195
-
196
188
  return (
197
189
  <header className="page-header">
198
190
  <div className="page-heading">
199
- <p className="page-eyebrow">{details.eyebrow}</p>
200
- <h1>{details.title}</h1>
191
+ <h1>{VIEW_TITLES[page.selection.view]}</h1>
201
192
  </div>
202
193
  <div className="page-context">
203
194
  <span className="page-context-label">Companion Repository</span>
@@ -222,6 +213,9 @@ const VIEW_NAV: readonly (readonly [StudioView, string])[] = [
222
213
  ["reports", "Reports"],
223
214
  ];
224
215
 
216
+ /** A view's page title is its navigation label. */
217
+ const VIEW_TITLES = Object.fromEntries(VIEW_NAV) as Record<StudioView, string>;
218
+
225
219
  /**
226
220
  * One GET form per view, so switching a view is a navigation to the URL naming
227
221
  * it and the browser's history moves between rendered states.
@@ -306,6 +300,18 @@ export function renderVaultView(page: StudioPage): string {
306
300
  );
307
301
  }
308
302
 
303
+ /** The open file's breadcrumb and pane, for the page to swap in without a reload. */
304
+ export function renderVaultPane(page: StudioPage): string {
305
+ const { selection, vault } = page;
306
+ if (!vault || selection.view !== "vault") return "";
307
+ return String(
308
+ <>
309
+ <VaultBreadcrumb page={page} selection={selection} />
310
+ <VaultPane page={page} selection={selection} vault={vault} />
311
+ </>,
312
+ );
313
+ }
314
+
309
315
  /** A folder's entries, for a folder the page did not expand; `null` when the listed tree lacks it. */
310
316
  export function renderVaultChildren(page: StudioPage): string | null {
311
317
  const { selection, vault } = page;
@@ -437,11 +443,12 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
437
443
  <p className="empty">No vault state collected.</p>
438
444
  </section>
439
445
  );
440
- const showListing = !vault.open && !vault.refusal;
441
446
  return (
442
447
  <section
443
448
  className="vault-layout"
444
449
  data-vault-layout
450
+ data-vault-pane-route={VAULT_PANE_ROUTE}
451
+ data-vault-scope={selection.companionDigest ?? ""}
445
452
  data-vault-view={vault.tree === null ? toHref(selection, "/api/vault/view") : undefined}
446
453
  data-vault-refresh-url={toHref(refresh(selection), "/api/vault/view")}
447
454
  data-vault-filter-url={toHref(plain(selection), VAULT_FILTER_ROUTE)}
@@ -467,7 +474,7 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
467
474
  <div id="vault-filter-results" data-vault-filter-results hidden />
468
475
  <VaultTreeSlot vault={vault} selection={selection} />
469
476
  </aside>
470
- <div className="vault-main">
477
+ <div className="vault-main" data-vault-main>
471
478
  <div className="vault-bar">
472
479
  <button
473
480
  type="button"
@@ -481,8 +488,7 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
481
488
  </button>
482
489
  <VaultBreadcrumb page={page} selection={selection} />
483
490
  </div>
484
- {showListing ? <VaultListingSlot selection={selection} vault={vault} /> : null}
485
- {showListing ? null : <VaultEditor page={page} selection={selection} vault={vault} />}
491
+ <VaultPane page={page} selection={selection} vault={vault} />
486
492
  </div>
487
493
  </section>
488
494
  );
@@ -647,7 +653,7 @@ function VaultBreadcrumb({
647
653
  ? (segments(page.companion.path).at(-1) ?? "companion")
648
654
  : "companion";
649
655
  return (
650
- <nav className="vault-breadcrumb" aria-label="Breadcrumb">
656
+ <nav className="vault-breadcrumb" aria-label="Breadcrumb" data-vault-crumb>
651
657
  <ol>
652
658
  <li>
653
659
  {parts.length === 0 ? (
@@ -768,7 +774,7 @@ function VaultListingEntry({
768
774
  );
769
775
  }
770
776
  return (
771
- <form method="get" action="/" className="vault-row" data-studio-navigation>
777
+ <form method="get" action="/" className="vault-row" data-studio-navigation data-vault-open>
772
778
  <SelectionFields selection={openFile(selection, node.path)} />
773
779
  <button type="submit">
774
780
  <FileIcon />
@@ -778,7 +784,29 @@ function VaultListingEntry({
778
784
  );
779
785
  }
780
786
 
781
- function VaultEditor({
787
+ /** What the in-place navigation swaps: the listing, or the open file read or edited. */
788
+ function VaultPane({
789
+ page,
790
+ selection,
791
+ vault,
792
+ }: {
793
+ page: StudioPage;
794
+ selection: StudioVaultSelection;
795
+ vault: StudioVaultPage;
796
+ }) {
797
+ const showListing = !vault.open && !vault.refusal && !selection.openPath;
798
+ return (
799
+ <div data-vault-pane>
800
+ {showListing ? (
801
+ <VaultListingSlot selection={selection} vault={vault} />
802
+ ) : (
803
+ <VaultFile page={page} selection={selection} vault={vault} />
804
+ )}
805
+ </div>
806
+ );
807
+ }
808
+
809
+ function VaultFile({
782
810
  page,
783
811
  selection,
784
812
  vault,
@@ -797,46 +825,86 @@ function VaultEditor({
797
825
  </div>
798
826
  );
799
827
  }
828
+ const { open } = vault;
829
+ // Without the write path a file is only ever read, whatever the address names.
830
+ const editing = page.writable && selection.mode === "edit";
831
+ const readAddress = withMode(selection, "read");
800
832
  return (
801
833
  <div
802
834
  className="panel vault-editor-panel"
803
835
  data-vault-root
836
+ data-vault-mode={editing ? "edit" : "read"}
804
837
  data-vault-writable={page.writable ? "true" : "false"}
838
+ data-vault-path={open.path}
839
+ data-vault-events={toHref(openFile(selection, open.path), "/api/vault/events")}
840
+ data-vault-pane-url={toHref(readAddress, VAULT_PANE_ROUTE)}
841
+ data-vault-read-href={toHref(readAddress)}
805
842
  >
806
843
  <div className="section-header">
807
844
  <div>
808
- <h3>{segments(vault.open.path).at(-1) ?? vault.open.path}</h3>
845
+ <h3>{segments(open.path).at(-1) ?? open.path}</h3>
809
846
  <p className="section-note">
810
- {page.writable
811
- ? "Changes stay in the editor until you save."
812
- : "Read-only mode. Start Studio with --writable to save."}
847
+ {!page.writable
848
+ ? "Read-only mode. Start Studio with --writable to edit."
849
+ : editing
850
+ ? "Changes stay in the editor until you save."
851
+ : "Rendered view."}
813
852
  </p>
814
853
  </div>
815
- {page.writable ? (
816
- <button type="button" id="vault-save">
817
- Save
818
- </button>
819
- ) : null}
854
+ <div className="vault-actions">
855
+ {editing ? (
856
+ <>
857
+ <button type="button" id="vault-save">
858
+ Save
859
+ </button>
860
+ <a
861
+ className="vault-mode-link"
862
+ id="vault-mode-read"
863
+ href={toHref(readAddress)}
864
+ data-vault-mode-link
865
+ >
866
+ Read
867
+ </a>
868
+ </>
869
+ ) : page.writable ? (
870
+ <a
871
+ className="vault-mode-link"
872
+ id="vault-mode-edit"
873
+ href={toHref(withMode(selection, "edit"))}
874
+ data-vault-mode-link
875
+ >
876
+ Edit
877
+ </a>
878
+ ) : null}
879
+ </div>
820
880
  </div>
821
- <textarea
822
- id="vault-editor"
823
- data-vault-editor
824
- data-vault-path={vault.open.path}
825
- data-vault-events={toHref(openFile(selection, vault.open.path), "/api/vault/events")}
826
- data-vault-token={vault.open.token}
827
- {...(page.writable ? {} : { readOnly: true })}
828
- spellCheck={false}
829
- >
830
- {vault.open.content}
831
- </textarea>
881
+ {editing ? (
882
+ <textarea
883
+ id="vault-editor"
884
+ data-vault-editor
885
+ data-vault-path={open.path}
886
+ data-vault-token={open.token}
887
+ spellCheck={false}
888
+ >
889
+ {open.content}
890
+ </textarea>
891
+ ) : (
892
+ <MarkdownView
893
+ source={open.content}
894
+ path={open.path}
895
+ hrefFor={(target) => toHref(openFile(selection, target))}
896
+ />
897
+ )}
832
898
  <p id="vault-status" className="note" aria-live="polite" />
833
- <div id="vault-incoming" className="vault-incoming" hidden>
834
- <strong id="vault-incoming-title">Incoming version</strong>
835
- <pre id="vault-incoming-content" />
836
- <button type="button" id="vault-recover">
837
- Put incoming content back
838
- </button>
839
- </div>
899
+ {editing ? (
900
+ <div id="vault-incoming" className="vault-incoming" hidden>
901
+ <strong id="vault-incoming-title">Incoming version</strong>
902
+ <pre id="vault-incoming-content" />
903
+ <button type="button" id="vault-recover">
904
+ Put incoming content back
905
+ </button>
906
+ </div>
907
+ ) : null}
840
908
  </div>
841
909
  );
842
910
  }
@@ -0,0 +1,250 @@
1
+ /** @jsxImportSource hono/jsx */
2
+
3
+ import { type Token, type Tokens, Lexer } from "marked";
4
+
5
+ import { highlightNodes } from "../../report/highlight";
6
+
7
+ type Child = unknown;
8
+
9
+ export interface MarkdownViewProps {
10
+ source: string;
11
+ /** The open file's companion-relative path; relative links resolve against its folder. */
12
+ path: string;
13
+ /** The vault address of a companion-relative markdown path. */
14
+ hrefFor: (path: string) => string;
15
+ }
16
+
17
+ const MARKDOWN_TARGET = /\.(md|markdown)$/i;
18
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
19
+
20
+ /**
21
+ * A relative link as a companion-relative markdown path, or `null` when it is anything
22
+ * else. Textual only: nothing is looked up on disk, so a missing note is the vault's to refuse.
23
+ */
24
+ export function resolveNoteLink(target: string, openPath: string): string | null {
25
+ if (!target || SCHEME.test(target) || target.startsWith("//") || target.startsWith("#")) {
26
+ return null;
27
+ }
28
+ let pathname = target.split(/[?#]/, 1)[0] ?? "";
29
+ try {
30
+ pathname = decodeURIComponent(pathname);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (pathname.includes("\\") || !MARKDOWN_TARGET.test(pathname)) return null;
35
+ const folder = pathname.startsWith("/") ? [] : openPath.split("/").filter(Boolean).slice(0, -1);
36
+ for (const part of pathname.split("/")) {
37
+ if (part === "" || part === ".") continue;
38
+ if (part === "..") {
39
+ if (folder.length === 0) return null;
40
+ folder.pop();
41
+ } else {
42
+ folder.push(part);
43
+ }
44
+ }
45
+ return folder.length > 0 ? folder.join("/") : null;
46
+ }
47
+
48
+ function isWebAddress(target: string): boolean {
49
+ return /^https?:\/\//i.test(target);
50
+ }
51
+
52
+ function Highlighted({ code, language }: { code: string; language: string | undefined }) {
53
+ const render = (node: ReturnType<typeof highlightNodes>[number], key: number): Child =>
54
+ node.type === "text" ? (
55
+ node.value
56
+ ) : (
57
+ <span key={key} className={node.classNames.join(" ")}>
58
+ {node.children.map(render)}
59
+ </span>
60
+ );
61
+ return <>{highlightNodes(code, language).map(render)}</>;
62
+ }
63
+
64
+ /** Plain text of inline tokens, for an image's alternative text. */
65
+ function plainText(tokens: Token[] | undefined, fallback: string): string {
66
+ if (!tokens) return fallback;
67
+ return tokens
68
+ .map((token) => ("tokens" in token && token.tokens ? plainText(token.tokens, "") : token.raw))
69
+ .join("");
70
+ }
71
+
72
+ class Mapper {
73
+ constructor(private readonly props: MarkdownViewProps) {}
74
+
75
+ blocks(tokens: Token[] | undefined): Child[] {
76
+ return (tokens ?? []).map((token, index) => this.block(token, index));
77
+ }
78
+
79
+ inline(tokens: Token[] | undefined, fallback = ""): Child[] {
80
+ if (!tokens) return [fallback];
81
+ return tokens.map((token, index) => this.span(token, index));
82
+ }
83
+
84
+ private block(token: Token, key: number): Child {
85
+ switch (token.type) {
86
+ case "space":
87
+ case "def":
88
+ return null;
89
+ case "heading": {
90
+ const heading = token as Tokens.Heading;
91
+ const Tag = `h${Math.min(Math.max(heading.depth, 1), 6)}` as "h1";
92
+ return <Tag key={key}>{this.inline(heading.tokens, heading.text)}</Tag>;
93
+ }
94
+ case "paragraph": {
95
+ const paragraph = token as Tokens.Paragraph;
96
+ return <p key={key}>{this.inline(paragraph.tokens, paragraph.text)}</p>;
97
+ }
98
+ case "text": {
99
+ const text = token as Tokens.Text;
100
+ return <p key={key}>{this.inline(text.tokens, text.text)}</p>;
101
+ }
102
+ case "code": {
103
+ const code = token as Tokens.Code;
104
+ const language = code.lang?.trim().split(/\s+/, 1)[0] || undefined;
105
+ return (
106
+ <pre key={key} className="md-fence">
107
+ <code>
108
+ <Highlighted code={code.text} language={language} />
109
+ </code>
110
+ </pre>
111
+ );
112
+ }
113
+ case "blockquote":
114
+ return (
115
+ <blockquote key={key}>{this.blocks((token as Tokens.Blockquote).tokens)}</blockquote>
116
+ );
117
+ case "hr":
118
+ return <hr key={key} />;
119
+ case "list":
120
+ return this.list(token as Tokens.List, key);
121
+ case "table":
122
+ return this.table(token as Tokens.Table, key);
123
+ case "html":
124
+ // Raw markup is shown as the text it is, never inserted.
125
+ return (
126
+ <p key={key} className="md-raw">
127
+ {(token as Tokens.HTML).raw.trim()}
128
+ </p>
129
+ );
130
+ default:
131
+ return <p key={key}>{token.raw}</p>;
132
+ }
133
+ }
134
+
135
+ private list(list: Tokens.List, key: number): Child {
136
+ const Tag = list.ordered ? "ol" : "ul";
137
+ const start =
138
+ list.ordered && typeof list.start === "number" && list.start !== 1 ? list.start : undefined;
139
+ return (
140
+ <Tag key={key} start={start}>
141
+ {list.items.map((item, index) => {
142
+ const body = item.tokens.filter((token) => token.type !== "checkbox");
143
+ return (
144
+ <li key={index} className={item.task ? "md-task" : undefined}>
145
+ {item.task ? (
146
+ <input type="checkbox" checked={!!item.checked} disabled aria-label="Task" />
147
+ ) : null}
148
+ {item.loose ? this.blocks(body) : this.tight(body)}
149
+ </li>
150
+ );
151
+ })}
152
+ </Tag>
153
+ );
154
+ }
155
+
156
+ /** A tight item's text sits in the item, not in a paragraph of its own. */
157
+ private tight(tokens: Token[]): Child[] {
158
+ return tokens.map((token, index) =>
159
+ token.type === "text"
160
+ ? this.inline((token as Tokens.Text).tokens, (token as Tokens.Text).text)
161
+ : this.block(token, index),
162
+ );
163
+ }
164
+
165
+ private table(table: Tokens.Table, key: number): Child {
166
+ const align = (cell: Tokens.TableCell) => (cell.align ? { textAlign: cell.align } : undefined);
167
+ return (
168
+ <div key={key} className="md-table">
169
+ <table>
170
+ <thead>
171
+ <tr>
172
+ {table.header.map((cell, index) => (
173
+ <th key={index} style={align(cell)}>
174
+ {this.inline(cell.tokens, cell.text)}
175
+ </th>
176
+ ))}
177
+ </tr>
178
+ </thead>
179
+ <tbody>
180
+ {table.rows.map((row, rowIndex) => (
181
+ <tr key={rowIndex}>
182
+ {row.map((cell, index) => (
183
+ <td key={index} style={align(cell)}>
184
+ {this.inline(cell.tokens, cell.text)}
185
+ </td>
186
+ ))}
187
+ </tr>
188
+ ))}
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ private span(token: Token, key: number): Child {
196
+ switch (token.type) {
197
+ case "text": {
198
+ const text = token as Tokens.Text;
199
+ return text.tokens ? <span key={key}>{this.inline(text.tokens)}</span> : text.text;
200
+ }
201
+ case "escape":
202
+ return (token as Tokens.Escape).text;
203
+ case "strong":
204
+ return <strong key={key}>{this.inline((token as Tokens.Strong).tokens)}</strong>;
205
+ case "em":
206
+ return <em key={key}>{this.inline((token as Tokens.Em).tokens)}</em>;
207
+ case "del":
208
+ return <del key={key}>{this.inline((token as Tokens.Del).tokens)}</del>;
209
+ case "codespan":
210
+ return <code key={key}>{(token as Tokens.Codespan).text}</code>;
211
+ case "br":
212
+ return <br key={key} />;
213
+ case "link":
214
+ return this.link(token as Tokens.Link, key);
215
+ case "image": {
216
+ // Not requested or shown: only what the author said the image is.
217
+ const image = token as Tokens.Image;
218
+ return image.text || plainText(image.tokens, "");
219
+ }
220
+ case "html":
221
+ return (token as Tokens.HTML).raw;
222
+ default:
223
+ return token.raw;
224
+ }
225
+ }
226
+
227
+ private link(link: Tokens.Link, key: number): Child {
228
+ const label = this.inline(link.tokens, link.text);
229
+ if (isWebAddress(link.href)) {
230
+ return (
231
+ <a key={key} href={link.href} target="_blank" rel="noopener noreferrer">
232
+ {label}
233
+ </a>
234
+ );
235
+ }
236
+ const note = resolveNoteLink(link.href, this.props.path);
237
+ if (note === null) return <span key={key}>{label}</span>;
238
+ return (
239
+ <a key={key} href={this.props.hrefFor(note)} data-vault-entry={note}>
240
+ {label}
241
+ </a>
242
+ );
243
+ }
244
+ }
245
+
246
+ /** Rendered from lexer tokens to elements, so no part of the file is ever inserted as markup. */
247
+ export function MarkdownView(props: MarkdownViewProps) {
248
+ const tokens = new Lexer({ gfm: true }).lex(props.source);
249
+ return <div className="markdown-body">{new Mapper(props).blocks(tokens)}</div>;
250
+ }