@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,431 @@
1
+ // Default graph workspace shell: one graph, three rooms (constellation, workbench,
2
+ // review), chosen by a work recipe. Coaches can use it as is, restyle it through a
3
+ // theme, change its recipes, or build their own shell from the same rooms.
4
+ //
5
+ // const workspace = mountGraphWorkspace(element, {
6
+ // model, // buildWorkspaceModel(...) with positions (layoutWorkspace)
7
+ // deps, // { Sigma, Graph, EdgeArrowProgram, FA2Layout? }
8
+ // recipes, theme, profile,
9
+ // storage, draftIdentity, // optional: where drafts persist and whose they are
10
+ // })
11
+ //
12
+ // Drafts stay in this tab unless the host names an identity: `draftIdentity`
13
+ // ({ workspace, reader?, ... }) or `profile.id`. With an identity and no storage
14
+ // they persist in this browser; with a host storage port they persist there.
15
+ //
16
+ // One workspace per document (it uses fixed element ids). The host decides what
17
+ // the model contains; nothing here fetches, grants or saves to a source.
18
+ import { createConstellation } from './constellation.mjs'
19
+ import { createWorkbench } from './workbench.mjs'
20
+ import { renderReview } from './review.mjs'
21
+ import { injectGraphWorkspaceStyles } from './styles.mjs'
22
+ import { h, clear } from './dom.mjs'
23
+ import { createDraftStore, readiness, memoryStorage } from '../authoring.mjs'
24
+ import { composeRecipes, availableRecipes } from '../recipes.mjs'
25
+ import { DEFAULT_THEME, validateTheme } from '../theme.mjs'
26
+
27
+ const fmt = n => n.toLocaleString('en-US')
28
+ const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches
29
+ const hasWebGL = () => { try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')) } catch { return false } }
30
+
31
+ export function applyTheme(theme, scheme, target) {
32
+ const problems = validateTheme(theme)
33
+ if (problems.length) throw new TypeError('Theme refused: ' + problems.join('; '))
34
+ const s = theme.schemes[scheme]
35
+ for (const [k, v] of Object.entries(s.vars)) target.style.setProperty(k, v)
36
+ target.dataset.colorScheme = scheme
37
+ return { ...s.graph, zones: [...s.graph.zones], mode: scheme }
38
+ }
39
+
40
+ const PRIMARY_LABELS = { search: 'Search', 'open-evidence': 'Open the evidence', 'next-step': 'Next step', walk: 'Walk to a neighbour', review: 'Review the proposal', pick: 'Choose' }
41
+
42
+ /** Where drafts live. Persistent storage needs an identity so two workspaces never share drafts. */
43
+ function draftStore(options, profile) {
44
+ const identity = options.draftIdentity ?? (profile.id ? { workspace: String(profile.id) } : null)
45
+ if (options.storage && !identity) throw new TypeError('A draft storage port needs draftIdentity or profile.id')
46
+ let storage = options.storage, persistence = options.storage ? (options.persistence ?? 'host') : 'browser'
47
+ if (!storage && identity) { try { storage = window.localStorage; storage.length } catch { storage = null } }
48
+ if (!storage || !identity) { storage = memoryStorage(); persistence = 'tab' }
49
+ const id = identity ?? { workspace: 'this-tab' }
50
+ const key = options.draftKey ?? 'atelier-graph-workspace:draft:' + encodeURIComponent(JSON.stringify(Object.keys(id).sort().map(k => [k, id[k]])))
51
+ return createDraftStore({ storage, key, identity: id, persistence })
52
+ }
53
+
54
+ export function mountGraphWorkspace(root, options) {
55
+ const {
56
+ model, deps = null, theme = DEFAULT_THEME, profile = {}, initialRecipe = null, intro = true,
57
+ webgl = hasWebGL() && !!deps,
58
+ } = options
59
+ const recipesAll = options.recipes ?? composeRecipes().recipes
60
+ const recipes = availableRecipes(recipesAll, model)
61
+ const byRecipe = new Map(recipes.map(r => [r.id, r]))
62
+ const vocab = { record: 'record', records: 'records', zone: 'zone', concept: 'concept', model: 'Concepts', ...(profile.vocabulary ?? {}) }
63
+ injectGraphWorkspaceStyles(root.ownerDocument)
64
+
65
+ const byNode = new Map(model.nodes.map(n => [n.id, n]))
66
+ const state = { recipe: null, room: 'constellation', selected: null, lens: null, questionId: null, conceptId: null, zoneId: null, textView: !webgl, scheme: options.scheme ?? theme.defaultScheme, zonesOn: true, linksOn: true, labelBoost: 1, savedCamera: null }
67
+ const counters = { dives: 0 }
68
+ let palette = null, constellation = null
69
+
70
+ // ── DOM ──
71
+ const el = {}
72
+ const make = (key, tag, props, ...children) => (el[key] = h(tag, props, ...children))
73
+ clear(root)
74
+ root.classList.add('atelier-gw')
75
+ const stage = h('div', { class: 'stage' })
76
+ stage.append(
77
+ make('constellation', 'div', { class: 'constellation', role: 'img', 'aria-label': 'Constellation of every record you can read. Use search, the selection card or Text view to move through it by keyboard.' }),
78
+ h('header', { class: 'top' },
79
+ h('div', { class: 'menu-wrap', 'data-graph-reserve': '' },
80
+ make('recipeBtn', 'button', { class: 'pill', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, h('span', { class: 'emblem', 'aria-hidden': 'true', text: profile.emblem ?? '◌' }), make('recipeLabel', 'span', { text: '' }), h('span', { class: 'caret', 'aria-hidden': 'true', text: '▾' })),
81
+ make('recipeMenu', 'div', { class: 'menu', role: 'menu', 'aria-label': 'What are you doing?', hidden: true }),
82
+ make('primaryBtn', 'button', { class: 'pill primary-action', type: 'button', 'data-gw': 'primary' }, '')),
83
+ make('commandWrap', 'div', { class: 'command-wrap', role: 'search', 'data-graph-reserve': '' },
84
+ h('label', { class: 'sr-only', for: 'gw-command', text: 'Search or type a command' }),
85
+ make('command', 'input', { id: 'gw-command', type: 'search', placeholder: 'Search or type a command', autocomplete: 'off', 'aria-autocomplete': 'list' }),
86
+ h('kbd', { 'aria-hidden': 'true', text: '⌘K' }),
87
+ make('results', 'ul', { class: 'command-results', role: 'listbox', 'aria-label': 'Results', hidden: true })),
88
+ h('div', { class: 'top-right', 'data-graph-reserve': '' },
89
+ make('textToggle', 'button', { class: 'pill', type: 'button', 'aria-pressed': 'false', disabled: !webgl }, 'Text view'),
90
+ h('div', { class: 'menu-wrap' },
91
+ make('displayBtn', 'button', { class: 'pill icon', type: 'button', 'aria-haspopup': 'true', 'aria-expanded': 'false', 'aria-label': 'Display' }, '◐'),
92
+ make('display', 'div', { class: 'menu display', hidden: true },
93
+ h('p', { class: 'menu-label', text: 'Display' }),
94
+ make('schemeBtn', 'button', { class: 'menu-item', type: 'button' }, ''),
95
+ h('label', { class: 'row' }, make('zonesToggle', 'input', { type: 'checkbox', checked: true }), ' Zones'),
96
+ h('label', { class: 'row' }, make('linksToggle', 'input', { type: 'checkbox', checked: true }), ' Link-only edges'),
97
+ h('label', { class: 'row col' }, 'Label density ', make('labelsRange', 'input', { type: 'range', min: '0.5', max: '2', step: '0.1', value: '1' })),
98
+ h('label', { class: 'row' }, make('physicsToggle', 'input', { type: 'checkbox', disabled: reduced() || !deps?.FA2Layout }), ' Let it move'))))),
99
+ h('footer', { class: 'corner', 'data-graph-reserve': '' }, h('strong', { text: profile.name ?? 'Workspace' }), make('coverage', 'span', {}), make('provenance', 'span', { class: 'provenance', text: profile.provenance ?? '' })),
100
+ h('div', { class: 'zoom', role: 'group', 'aria-label': 'Zoom', 'data-graph-reserve': '' },
101
+ make('zoomIn', 'button', { type: 'button', 'aria-label': 'Zoom in' }, '+'), make('zoomOut', 'button', { type: 'button', 'aria-label': 'Zoom out' }, '−'), make('fit', 'button', { type: 'button', 'aria-label': 'Fit the whole graph' }, '⤢')),
102
+ make('card', 'section', { class: 'card', 'aria-labelledby': 'card-title', hidden: true, 'data-graph-reserve': '' }),
103
+ make('questionCard', 'section', { class: 'question-card', 'data-gw': 'lens-card', hidden: true, 'data-graph-reserve': '' }),
104
+ make('textView', 'section', { class: 'text-view', 'aria-label': 'Text view' }),
105
+ make('workbench', 'section', { class: 'workbench', 'aria-label': 'Workbench', hidden: true }),
106
+ make('review', 'section', { class: 'review-room', 'aria-label': 'Review', hidden: true }),
107
+ make('toast', 'div', { class: 'toast', role: 'status', hidden: true }),
108
+ make('live', 'p', { class: 'sr-only', role: 'status', 'aria-live': 'polite' }))
109
+ root.append(stage)
110
+ if (!webgl) root.classList.add('text-mode', 'no-webgl')
111
+
112
+ const store = draftStore(options, profile)
113
+ const announce = text => { el.live.textContent = text }
114
+ let toastTimer = null
115
+ const toast = text => { el.toast.textContent = text; el.toast.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.toast.hidden = true }, 3200) }
116
+
117
+ function applyAppearance() {
118
+ palette = applyTheme(theme, state.scheme, root)
119
+ constellation?.setPalette(palette)
120
+ el.schemeBtn.textContent = state.scheme === 'dark' ? 'Light appearance' : 'Dark appearance'
121
+ }
122
+ applyAppearance()
123
+
124
+ const declared = model.edges.filter(e => e.kind === 'relation').length
125
+ el.coverage.textContent = `${fmt(model.nodes.length)} ${vocab.records} · ${model.zones.length} ${vocab.zone}s · ${fmt(model.edges.length)} links (${fmt(declared)} declared)${model.coverage.linksOutsideReadableSet ? ` · ${fmt(model.coverage.linksOutsideReadableSet)} point outside what you can read` : ''}`
126
+
127
+ const font = getComputedStyle(root).getPropertyValue('--font-sans').trim() || 'system-ui'
128
+ if (webgl) {
129
+ constellation = createConstellation({ container: el.constellation, model, palette, font, deps, onSelect: id => select(id), onOpen: id => openWorkbench({ kind: 'record', id }) })
130
+ if (intro) constellation.intro()
131
+ }
132
+ const workbench = createWorkbench({ root: el.workbench, model, palette, store, vocab, onBack: backToConstellation, onReview: () => openReview({ from: 'workbench' }), onShowOnMap: id => backToConstellation().then(() => { select(id); constellation?.showNode(id) }) })
133
+ el.workbench.hidden = true
134
+
135
+ // ── Recipes ──
136
+ function setRecipe(id) {
137
+ const recipe = byRecipe.get(id) ?? recipes[0]
138
+ if (!recipe) return
139
+ state.recipe = recipe.id
140
+ el.recipeLabel.textContent = recipe.label
141
+ el.primaryBtn.textContent = PRIMARY_LABELS[recipe.primary]
142
+ root.dataset.recipe = recipe.id
143
+ root.dataset.primary = recipe.primary
144
+ closeMenus()
145
+ state.lens = recipe.room === 'constellation' ? recipe.lens : null
146
+ renderLensCard()
147
+ if (recipe.room === 'workbench') {
148
+ // In the workbench the primary action is where focus lands (the top bar sits behind it).
149
+ const subject = recipe.subject === 'concept' ? { kind: 'concept', id: model.plan.concepts[0].id }
150
+ : recipe.subject === 'relation' ? { kind: 'relation', id: model.plan.relations[0].id }
151
+ : { kind: 'record', id: state.selected ?? [...model.nodes].sort((a, b) => b.relationDegree - a.relationDegree || b.degree - a.degree)[0].id }
152
+ const opened = openWorkbench(subject)
153
+ if (mounted) opened.then(() => focusPrimary(recipe.primary))
154
+ } else if (recipe.room === 'review') openReview({ from: 'recipe' })
155
+ else if (state.room !== 'constellation') backToConstellation()
156
+ announce(`${recipe.label}. ${recipe.description}`)
157
+ }
158
+ function renderLensCard() {
159
+ el.questionCard.hidden = !state.lens
160
+ el.questionCard.dataset.lens = state.lens ?? ''
161
+ if (state.lens !== 'zone') constellation?.setFocusZone(null)
162
+ if (state.lens === 'question') renderQuestionCard()
163
+ else if (state.lens === 'concept') renderConceptLens()
164
+ else if (state.lens === 'zone') renderZoneLens()
165
+ else { clear(el.questionCard); constellation?.setLens(null) }
166
+ }
167
+ function renderConceptLens() {
168
+ clear(el.questionCard)
169
+ const plan = model.plan
170
+ const c = plan.concepts.find(x => x.id === state.conceptId) ?? plan.concepts[0]
171
+ state.conceptId = c.id
172
+ const ids = c.declaredBy.filter(id => byNode.has(id))
173
+ constellation?.setLens(ids)
174
+ el.questionCard.append(h('p', { class: 'eyebrow', text: `The ${vocab.concept} in view` }),
175
+ h('select', { 'aria-label': vocab.concept, onchange: e => { state.conceptId = e.target.value; renderConceptLens() } }, plan.concepts.map(x => h('option', { value: x.id, selected: x.id === c.id, text: x.title }))),
176
+ h('p', { class: 'q-lit', 'data-gw': 'lens-lit', text: ids.length ? `${fmt(ids.length)} ${ids.length === 1 ? vocab.record : vocab.records} declare it.` : 'No record you can read declares it yet.' }),
177
+ h('button', { class: 'secondary small', type: 'button', onclick: () => openWorkbench({ kind: 'concept', id: c.id }) }, `Refine “${c.title}”`))
178
+ }
179
+ function renderZoneLens() {
180
+ clear(el.questionCard)
181
+ const z = model.zones.find(x => x.id === state.zoneId) ?? model.zones[0]
182
+ if (!z) return
183
+ state.zoneId = z.id
184
+ constellation?.setLens(null)
185
+ constellation?.setFocusZone(z.id)
186
+ el.questionCard.append(h('p', { class: 'eyebrow', text: `The ${vocab.zone} in view` }),
187
+ h('select', { 'aria-label': vocab.zone, onchange: e => { state.zoneId = e.target.value; renderZoneLens() } }, model.zones.map(x => h('option', { value: x.id, selected: x.id === z.id, text: x.label }))),
188
+ h('p', { class: 'q-lit', 'data-gw': 'lens-lit', text: `${fmt(z.count)} ${z.count === 1 ? vocab.record : vocab.records}. Others are dimmed, not removed.` }))
189
+ }
190
+ function runPrimary() {
191
+ const recipe = byRecipe.get(state.recipe)
192
+ if (!recipe) return
193
+ const a = recipe.primary
194
+ if (a === 'search') el.command.focus()
195
+ else if (a === 'pick') el.questionCard.querySelector('select')?.focus()
196
+ else if (a === 'review') openReview({ from: state.room === 'workbench' ? 'workbench' : 'recipe' })
197
+ else if (a === 'open-evidence') {
198
+ const lit = state.lens === 'question' ? questionLit() : state.lens === 'concept' ? (model.plan.concepts.find(c => c.id === state.conceptId)?.declaredBy ?? []).filter(id => byNode.has(id)) : []
199
+ const first = state.selected && lit.includes(state.selected) ? state.selected : lit[0]
200
+ if (first) openWorkbench({ kind: 'record', id: first }); else toast('Nothing lit to open yet.')
201
+ } else if (a === 'next-step' || a === 'walk') {
202
+ if (state.room === 'workbench') focusPrimary(a)
203
+ else if (state.selected) openWorkbench({ kind: 'record', id: state.selected }).then(() => focusPrimary(a))
204
+ else setRecipe(recipe.id)
205
+ }
206
+ }
207
+ function focusPrimary(action) {
208
+ if (action === 'walk') (el.workbench.querySelector('.wb-neighbour') ?? el.workbench.querySelector('#wb-title'))?.focus()
209
+ else if (action === 'next-step') el.workbench.querySelector('.wb-sheet textarea, .wb-sheet input, .wb-sheet button.primary')?.focus()
210
+ }
211
+ function questionLit() {
212
+ const plan = model.plan
213
+ const q = plan.questions.find(x => x.id === state.questionId) ?? plan.questions[0]
214
+ return [...new Set(q.concepts.flatMap(cid => plan.concepts.find(c => c.id === cid)?.declaredBy ?? []))].filter(id => byNode.has(id))
215
+ }
216
+ function renderQuestionCard() {
217
+ clear(el.questionCard)
218
+ const plan = model.plan
219
+ const q = plan.questions.find(x => x.id === state.questionId) ?? plan.questions[0]
220
+ state.questionId = q.id
221
+ const ids = new Set(questionLit())
222
+ constellation?.setLens([...ids])
223
+ const zones = new Set([...ids].map(id => byNode.get(id).zoneLabel))
224
+ el.questionCard.append(h('p', { class: 'eyebrow', text: 'The question on the table' }),
225
+ h('select', { 'aria-label': 'Question', onchange: e => { state.questionId = e.target.value; renderQuestionCard() } }, plan.questions.map(x => h('option', { value: x.id, selected: x.id === q.id, text: x.question }))),
226
+ h('p', { class: 'q-lit', 'data-gw': 'question-lit', text: ids.size ? `${fmt(ids.size)} ${ids.size === 1 ? vocab.record : vocab.records} declare its concepts, across ${zones.size} ${vocab.zone}${zones.size === 1 ? '' : 's'}.` : 'No record declares its concepts yet. That is an honest unknown, not an empty answer.' }),
227
+ h('p', { class: 'q-note', text: `Concepts: ${q.concepts.map(cid => plan.concepts.find(c => c.id === cid)?.title).join(', ') || 'none named'}. Lit records carry the concept tag themselves.` }),
228
+ ids.size && q.concepts[0] ? h('button', { class: 'secondary small', type: 'button', onclick: () => openWorkbench({ kind: 'concept', id: q.concepts[0] }) }, `Refine “${plan.concepts.find(c => c.id === q.concepts[0]).title}”`) : null)
229
+ }
230
+
231
+ // ── Selection and rooms ──
232
+ function select(id) {
233
+ state.selected = id
234
+ constellation?.select(id)
235
+ renderCard()
236
+ if (id) announce(`Selected ${model.nodes.find(n => n.id === id)?.title}.`)
237
+ }
238
+ function renderCard() {
239
+ clear(el.card)
240
+ const n = state.selected && model.nodes.find(x => x.id === state.selected)
241
+ el.card.hidden = !n
242
+ if (!n) return
243
+ const color = palette.zones[(model.zones.find(z => z.id === n.zone)?.index ?? 0) % palette.zones.length]
244
+ const near = []
245
+ const seen = new Set()
246
+ for (const e of model.edges) { const other = e.source === n.id ? e.target : e.target === n.id ? e.source : null; if (other && !seen.has(other)) { seen.add(other); near.push({ id: other, relation: e.kind === 'relation' }) } }
247
+ near.sort((a, b) => Number(b.relation) - Number(a.relation))
248
+ el.card.append(
249
+ h('button', { class: 'card-close', type: 'button', 'aria-label': 'Clear selection', onclick: () => select(null) }, '×'),
250
+ h('p', { class: 'eyebrow' }, h('i', { class: 'dot', style: `background:${color}` }), `${n.zoneLabel} · ${n.type}`),
251
+ h('h2', { id: 'card-title', text: n.title }),
252
+ n.summary ? h('p', { class: 'card-summary', text: n.summary }) : null,
253
+ h('p', { class: 'card-meta', text: `${n.degree} links · ${n.relationDegree} declared${n.concepts.length ? ' · declares ' + n.concepts.join(', ') : ''}` }),
254
+ h('div', { class: 'card-actions' },
255
+ h('button', { class: 'primary', type: 'button', 'data-gw': 'open-selected', onclick: () => openWorkbench({ kind: 'record', id: n.id }) }, 'Open in the workbench'),
256
+ constellation ? h('button', { class: 'quiet', type: 'button', onclick: () => constellation.showNode(n.id) }, 'Show on map') : null),
257
+ near.length ? h('details', { class: 'card-neighbours' }, h('summary', { text: `Walk to a neighbour (${near.length})` }),
258
+ h('ul', {}, near.slice(0, 12).map(x => h('li', {}, h('button', { class: 'link-row', type: 'button', onclick: () => { select(x.id); root.querySelector('#card-title')?.focus() } }, model.nodes.find(m => m.id === x.id).title))))) : null)
259
+ }
260
+ async function openWorkbench(subject) {
261
+ closeMenus()
262
+ let from = null
263
+ if (state.room === 'constellation' && constellation) {
264
+ state.savedCamera = constellation.camera.getState()
265
+ if (subject.kind === 'record') {
266
+ const p = constellation.nodeViewport(subject.id), r = el.constellation.getBoundingClientRect()
267
+ from = p ? { x: r.left + p.x, y: r.top + p.y } : null
268
+ root.classList.add('diving')
269
+ await constellation.dive(subject.id)
270
+ }
271
+ }
272
+ counters.dives++
273
+ state.room = 'workbench'
274
+ root.dataset.room = 'workbench'
275
+ root.classList.remove('diving')
276
+ el.review.hidden = true
277
+ workbench.open(subject, { from })
278
+ }
279
+ async function backToConstellation() {
280
+ workbench.close()
281
+ el.review.hidden = true
282
+ state.room = 'constellation'
283
+ root.dataset.room = 'constellation'
284
+ if (constellation && state.savedCamera) await constellation.restore(state.savedCamera)
285
+ constellation?.refresh()
286
+ }
287
+ function openReview({ from = 'workbench' } = {}) {
288
+ closeMenus()
289
+ if (state.room === 'workbench' && from !== 'workbench') workbench.close()
290
+ state.room = 'review'
291
+ root.dataset.room = 'review'
292
+ const toWorkbench = () => { el.review.hidden = true; state.room = 'workbench'; root.dataset.room = 'workbench' }
293
+ renderReview(el.review, {
294
+ model: { ...model, systemId: profile.id ?? 'workspace' }, store,
295
+ backLabel: from === 'workbench' ? '← Back to the workbench' : '← Back to the constellation',
296
+ onBack: () => { if (from === 'workbench') { toWorkbench(); workbench.refresh() } else { el.review.hidden = true; backToConstellation() } },
297
+ onOpen: s => { toWorkbench(); workbench.open(s) },
298
+ })
299
+ }
300
+
301
+ // ── Command bar ──
302
+ function commandResults(query) {
303
+ const q = query.trim().toLowerCase()
304
+ const commands = [
305
+ ...recipes.map(r => ({ kind: 'recipe', id: r.id, label: r.label, hint: r.description })),
306
+ ...(webgl ? [{ kind: 'command', id: 'text', label: state.textView ? 'Show the constellation' : 'Text view', hint: 'Every record as a structured list' }] : []),
307
+ { kind: 'command', id: 'scheme', label: state.scheme === 'dark' ? 'Light appearance' : 'Dark appearance', hint: 'Switch appearance' },
308
+ ...(constellation ? [{ kind: 'command', id: 'fit', label: 'Fit the whole graph', hint: 'Zoom out to everything' }] : []),
309
+ ]
310
+ if (!q) return commands.slice(0, 6)
311
+ const out = commands.filter(c => c.label.toLowerCase().includes(q))
312
+ for (const c of model.plan?.concepts ?? []) if (c.title.toLowerCase().includes(q)) out.push({ kind: 'concept', id: c.id, label: c.title, hint: 'Concept · refine in the workbench' })
313
+ for (const x of model.plan?.questions ?? []) if (x.question.toLowerCase().includes(q)) out.push({ kind: 'question', id: x.id, label: x.question, hint: 'Question · light its evidence' })
314
+ const scored = []
315
+ for (const n of model.nodes) { const t = n.title.toLowerCase(), at = t.indexOf(q); if (at >= 0) scored.push({ n, score: (at === 0 ? 0 : t[at - 1] === ' ' ? 1 : 2) * 1e6 - n.degree }) }
316
+ scored.sort((a, b) => a.score - b.score)
317
+ for (const { n } of scored.slice(0, 8)) out.push({ kind: 'record', id: n.id, label: n.title, hint: n.zoneLabel })
318
+ return out.slice(0, 10)
319
+ }
320
+ function renderCommand() {
321
+ clear(el.results)
322
+ const results = commandResults(el.command.value)
323
+ el.results.hidden = !results.length
324
+ results.forEach((r, i) => el.results.append(h('li', { role: 'option', 'aria-selected': String(i === 0) }, h('button', { type: 'button', class: 'cmd', dataset: { kind: r.kind, id: r.id }, onclick: () => runCommand(r) },
325
+ h('span', { class: 'cmd-label', text: r.label }), h('span', { class: 'cmd-hint', text: r.hint })))))
326
+ }
327
+ function runCommand(r) {
328
+ el.command.value = ''
329
+ el.results.hidden = true
330
+ if (r.kind === 'recipe') setRecipe(r.id)
331
+ else if (r.kind === 'concept') openWorkbench({ kind: 'concept', id: r.id })
332
+ else if (r.kind === 'question') { state.questionId = r.id; const q = recipes.find(x => x.lens === 'question'); if (q) setRecipe(q.id) }
333
+ else if (r.kind === 'zone') { state.zoneId = r.id; const z = recipes.find(x => x.lens === 'zone'); if (z) setRecipe(z.id) }
334
+ else if (r.kind === 'record') { if (state.room !== 'constellation') backToConstellation().then(() => select(r.id)); else select(r.id); root.querySelector('#card-title')?.focus() }
335
+ else if (r.id === 'text') toggleText()
336
+ else if (r.id === 'scheme') toggleScheme()
337
+ else if (r.id === 'fit') constellation?.fit()
338
+ }
339
+
340
+ // ── Text view ──
341
+ function renderTextView() {
342
+ clear(el.textView)
343
+ const filter = h('input', { type: 'search', 'data-gw': 'text-filter', placeholder: `Filter ${fmt(model.nodes.length)} ${vocab.records}…`, 'aria-label': `Filter ${vocab.records}` })
344
+ const lists = h('div', { class: 'tv-lists' })
345
+ const draw = () => {
346
+ clear(lists)
347
+ const q = filter.value.trim().toLowerCase()
348
+ if (model.plan?.concepts.length) {
349
+ lists.append(h('h3', { text: vocab.model }), h('ul', { class: 'tv-list' }, model.plan.concepts.filter(c => !q || c.title.toLowerCase().includes(q)).map(c => h('li', {}, h('button', { type: 'button', class: 'tv-item', onclick: () => openWorkbench({ kind: 'concept', id: c.id }) }, h('i', { class: 'dot-concept' }), c.title, h('small', { text: `${c.declaredBy.length} declaring` }))))))
350
+ }
351
+ for (const z of model.zones) {
352
+ const members = model.nodes.filter(n => n.zone === z.id && (!q || n.title.toLowerCase().includes(q)))
353
+ if (!members.length) continue
354
+ const d = h('details', { class: 'tv-zone', open: !!q || members.length <= 40 || z.index === 0 })
355
+ d.append(h('summary', {}, h('i', { class: 'dot', style: `background:${palette.zones[z.index % palette.zones.length]}` }), `${z.label} · ${fmt(members.length)}`))
356
+ d.append(h('ul', { class: 'tv-list' }, members.map(n => h('li', {}, h('button', { type: 'button', class: 'tv-item', dataset: { id: n.id }, onclick: () => select(n.id) }, n.title, h('small', { text: `${n.type} · ${n.degree} links` }))))))
357
+ lists.append(d)
358
+ }
359
+ }
360
+ filter.addEventListener('input', draw)
361
+ el.textView.append(h('h2', { class: 'tv-title', text: 'Text view' }), filter, lists)
362
+ draw()
363
+ }
364
+ function toggleText() {
365
+ if (!webgl) return
366
+ state.textView = !state.textView
367
+ root.classList.toggle('text-mode', state.textView)
368
+ el.textToggle.setAttribute('aria-pressed', String(state.textView))
369
+ if (state.textView) el.textView.querySelector('input')?.focus(); else constellation?.refresh()
370
+ }
371
+ function toggleScheme() { state.scheme = state.scheme === 'dark' ? 'light' : 'dark'; applyAppearance(); renderTextView(); renderCard(); if (state.room === 'workbench') workbench.refresh() }
372
+
373
+ // ── Menus and keys ──
374
+ function closeMenus() { el.recipeMenu.hidden = true; el.display.hidden = true; el.recipeBtn.setAttribute('aria-expanded', 'false'); el.displayBtn.setAttribute('aria-expanded', 'false') }
375
+ function toggle(menu, button) { const open = menu.hidden; closeMenus(); menu.hidden = !open; button.setAttribute('aria-expanded', String(open)); if (open) menu.querySelector('button, input')?.focus() }
376
+ function renderRecipeMenu() {
377
+ clear(el.recipeMenu)
378
+ for (const r of recipes) el.recipeMenu.append(h('button', { type: 'button', class: 'menu-item', role: 'menuitemradio', 'aria-checked': String(state.recipe === r.id), dataset: { recipe: r.id }, onclick: () => setRecipe(r.id) }, h('span', { text: r.label }), h('small', { text: r.description })))
379
+ }
380
+ const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); cleanups.push(() => target.removeEventListener(type, fn, opts)) }
381
+ const cleanups = []
382
+ on(el.recipeBtn, 'click', () => { renderRecipeMenu(); toggle(el.recipeMenu, el.recipeBtn) })
383
+ on(el.primaryBtn, 'click', runPrimary)
384
+ on(el.displayBtn, 'click', () => toggle(el.display, el.displayBtn))
385
+ on(el.textToggle, 'click', toggleText)
386
+ on(el.schemeBtn, 'click', toggleScheme)
387
+ on(el.zonesToggle, 'change', e => { state.zonesOn = e.target.checked; constellation?.setOption('zonesOn', state.zonesOn) })
388
+ on(el.linksToggle, 'change', e => { state.linksOn = e.target.checked; constellation?.setOption('linksOn', state.linksOn) })
389
+ on(el.labelsRange, 'input', e => { state.labelBoost = +e.target.value; constellation?.setOption('labelBoost', state.labelBoost) })
390
+ on(el.physicsToggle, 'change', e => { e.target.checked = !!constellation?.setPhysics(e.target.checked) })
391
+ on(el.fit, 'click', () => constellation?.fit())
392
+ on(el.zoomIn, 'click', () => constellation?.zoom(1.5))
393
+ on(el.zoomOut, 'click', () => constellation?.zoom(1 / 1.5))
394
+ on(el.command, 'focus', renderCommand)
395
+ on(el.command, 'input', renderCommand)
396
+ on(el.command, 'keydown', e => {
397
+ const buttons = [...el.results.querySelectorAll('button')]
398
+ if (e.key === 'Enter') { e.preventDefault(); buttons[0]?.click() }
399
+ if (e.key === 'ArrowDown') { e.preventDefault(); buttons[0]?.focus() }
400
+ if (e.key === 'Escape') { el.command.value = ''; el.results.hidden = true; el.command.blur() }
401
+ })
402
+ on(el.results, 'keydown', e => {
403
+ const buttons = [...el.results.querySelectorAll('button')], i = buttons.indexOf(document.activeElement)
404
+ if (e.key === 'ArrowDown') { e.preventDefault(); buttons[Math.min(buttons.length - 1, i + 1)]?.focus() }
405
+ if (e.key === 'ArrowUp') { e.preventDefault(); (i <= 0 ? el.command : buttons[i - 1]).focus() }
406
+ if (e.key === 'Escape') { el.results.hidden = true; el.command.focus() }
407
+ })
408
+ on(root.ownerDocument, 'click', e => { if (!el.commandWrap.contains(e.target)) el.results.hidden = true; if (!e.target.closest('.atelier-gw .menu-wrap')) closeMenus() })
409
+ on(root.ownerDocument, 'keydown', e => {
410
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); el.command.focus(); return }
411
+ if (e.target.closest?.('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return
412
+ if (e.key === '/') { e.preventDefault(); el.command.focus() }
413
+ else if (e.key === 'Escape') { if (!el.recipeMenu.hidden || !el.display.hidden) closeMenus(); else if (state.room === 'review') root.querySelector('#review-back')?.click(); else if (state.room === 'workbench') backToConstellation(); else select(null) }
414
+ else if (e.key === 't') { e.preventDefault(); toggleText() }
415
+ else if (e.key === 'o' && state.selected && state.room === 'constellation') openWorkbench({ kind: 'record', id: state.selected })
416
+ })
417
+
418
+ let mounted = false
419
+ renderTextView()
420
+ setRecipe(byRecipe.has(initialRecipe) ? initialRecipe : recipes[0]?.id)
421
+ mounted = true
422
+
423
+ return {
424
+ get state() { return { ...state, savedCamera: undefined, counters: { ...counters }, webgl, records: model.nodes.length, links: model.edges.length, workbench: workbench.subject, camera: constellation ? constellation.camera.getState() : null, draftStatus: store.status, draftPersistence: store.persistence } },
425
+ recipes, store, constellation, workbench,
426
+ select, openWorkbench, openReview, setRecipe, runPrimary, backToConstellation, toggleText,
427
+ setScheme(scheme) { state.scheme = scheme; applyAppearance() },
428
+ readiness: () => readiness(model.plan, store, new Set(model.nodes.map(n => n.id))).status,
429
+ destroy() { for (const fn of cleanups.splice(0)) fn(); constellation?.kill(); clear(root); root.classList.remove('atelier-gw') },
430
+ }
431
+ }
@@ -0,0 +1,39 @@
1
+ // Shippable-content gate for layered systems. When a coach or owner builds on top of
2
+ // private workspaces, the shareable layer must not carry anything from them.
3
+ // fingerprintsFrom() derives distinctive strings from a private knowledge graph and
4
+ // plan (ids, paths, long titles, summary openings, plan wording, names);
5
+ // scanForFingerprints() reports where any of them occurs in shareable text.
6
+ // Findings name the file and a masked sample, never the full matched content.
7
+
8
+ export function fingerprintsFrom({ graph = null, plan = null, names = [], extra = [] } = {}) {
9
+ const out = new Set()
10
+ const add = v => { if (typeof v === 'string' && v.trim().length >= 8) out.add(v.trim()) }
11
+ const distinctive = t => typeof t === 'string' && (t.length >= 24 || t.trim().split(/\s+/).length >= 4)
12
+ for (const n of graph?.nodes ?? []) {
13
+ add(n.id); add(n.path)
14
+ if (distinctive(n.title)) add(n.title)
15
+ const first = (n.summary ?? '').split('\n')[0]
16
+ if (first.length >= 40) add(first.slice(0, 60))
17
+ }
18
+ for (const c of plan?.concepts ?? []) for (const v of [c.definition, c.identityRule]) if ((v ?? '').length >= 30) add(v.slice(0, 60))
19
+ for (const q of plan?.questions ?? []) add(q.question ?? q.title)
20
+ for (const v of names) add(v)
21
+ for (const v of extra) if (typeof v === 'string' && v.length >= 4) out.add(v)
22
+ return [...out]
23
+ }
24
+
25
+ const mask = f => f.length > 10 ? `${f.slice(0, 4)}…${f.slice(-3)} (${f.length} chars)` : '(short value)'
26
+
27
+ /** files: [{ path, text }]. Returns { ok, scanned, findings: [{ path, sample }] }. */
28
+ export function scanForFingerprints(files, fingerprints) {
29
+ const byPrefix = new Map()
30
+ for (const f of fingerprints) { const k = f.slice(0, 6); if (!byPrefix.has(k)) byPrefix.set(k, []); byPrefix.get(k).push(f) }
31
+ const findings = []
32
+ for (const file of files) {
33
+ for (const [prefix, list] of byPrefix) {
34
+ if (!file.text.includes(prefix)) continue
35
+ for (const f of list) if (file.text.includes(f)) findings.push({ path: file.path, sample: mask(f) })
36
+ }
37
+ }
38
+ return { ok: findings.length === 0, scanned: files.length, findings }
39
+ }
@@ -0,0 +1,78 @@
1
+ // Presentation geometry for the constellation. Positions are presentation only:
2
+ // distance never asserts a relation. The force layout itself is injected so this
3
+ // package carries no layout dependency; any ForceAtlas2-compatible function works.
4
+
5
+ function prng(seed) { let s = seed >>> 0; return () => { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } }
6
+
7
+ /** Zone-seeded starting positions, deterministic for a seed. */
8
+ export function seedPositions(model, { seed = 3 } = {}) {
9
+ const rnd = prng(seed)
10
+ const n = Math.max(1, model.zones.length)
11
+ const index = new Map(model.zones.map(z => [z.id, z.index]))
12
+ const out = new Map()
13
+ for (const node of model.nodes) {
14
+ const zi = index.get(node.zone) ?? 0
15
+ const angle = (zi / n) * Math.PI * 2
16
+ const r = 60 + zi * 6
17
+ out.set(node.id, { x: Math.cos(angle) * r + (rnd() - 0.5) * 40, y: Math.sin(angle) * r + (rnd() - 0.5) * 40 })
18
+ }
19
+ return out
20
+ }
21
+
22
+ /** Edge weights for a force layout: declared relations pull hardest, written links least. */
23
+ export const LAYOUT_WEIGHTS = Object.freeze({ relation: 1, containment: 0.45, link: 0.22 })
24
+
25
+ /**
26
+ * Run an injected force layout and write normalised x/y onto model nodes.
27
+ * `Graph` is a graphology constructor; `forceAtlas2` the graphology-layout-forceatlas2 default export.
28
+ */
29
+ export function layoutWorkspace(model, { Graph, forceAtlas2, seed = 3, iterations = 700, settings = {} }) {
30
+ if (typeof Graph !== 'function' || typeof forceAtlas2?.assign !== 'function') throw new TypeError('Inject a graphology Graph and a ForceAtlas2 implementation')
31
+ const graph = new Graph({ type: 'directed', multi: true, allowSelfLoops: false })
32
+ const start = seedPositions(model, { seed })
33
+ for (const node of model.nodes) graph.addNode(node.id, { ...start.get(node.id), size: 1 + Math.sqrt(node.degree) })
34
+ for (const e of model.edges) graph.addEdge(e.source, e.target, { weight: LAYOUT_WEIGHTS[e.kind] ?? 0.22 })
35
+ const resolved = { ...forceAtlas2.inferSettings(graph), linLogMode: true, outboundAttractionDistribution: true, adjustSizes: false, barnesHutOptimize: graph.order > 600, barnesHutTheta: 0.6, gravity: 0.12, scalingRatio: 14, slowDown: 2, edgeWeightInfluence: 1, ...settings }
36
+ forceAtlas2.assign(graph, { iterations, settings: resolved, getEdgeWeight: 'weight' })
37
+ const positions = new Map()
38
+ graph.forEachNode((id, a) => positions.set(id, { x: a.x, y: a.y }))
39
+ normalizePositions(model, positions)
40
+ return { algorithm: 'forceatlas2-linlog', iterations, settings: resolved }
41
+ }
42
+
43
+ /** Fit positions into a stable 1000-unit frame centred on the origin and write them onto nodes. */
44
+ export function normalizePositions(model, positions) {
45
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
46
+ for (const p of positions.values()) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y) }
47
+ const scale = 1000 / Math.max(maxX - minX, maxY - minY, 1)
48
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
49
+ for (const node of model.nodes) {
50
+ const p = positions.get(node.id) ?? { x: cx, y: cy }
51
+ node.x = Math.round((p.x - cx) * scale * 10) / 10
52
+ node.y = Math.round((p.y - cy) * scale * 10) / 10
53
+ }
54
+ return model
55
+ }
56
+
57
+ /** Zone glow and label geometry: a coarse density grid per zone and an anchor on its densest area. */
58
+ export function zoneGeometry(model, { cell = 55 } = {}) {
59
+ const out = []
60
+ for (const zone of model.zones) {
61
+ const members = model.nodes.filter(n => n.zone === zone.id && Number.isFinite(n.x) && Number.isFinite(n.y))
62
+ if (!members.length) continue
63
+ const cx = members.reduce((s, n) => s + n.x, 0) / members.length
64
+ const cy = members.reduce((s, n) => s + n.y, 0) / members.length
65
+ const grid = new Map()
66
+ for (const n of members) {
67
+ const k = Math.round(n.x / cell) + ',' + Math.round(n.y / cell)
68
+ const g = grid.get(k) ?? { x: 0, y: 0, count: 0 }
69
+ g.x += n.x; g.y += n.y; g.count++
70
+ grid.set(k, g)
71
+ }
72
+ const blobs = [...grid.values()].map(g => ({ x: Math.round(g.x / g.count), y: Math.round(g.y / g.count), weight: g.count }))
73
+ const score = b => b.weight / (1 + Math.hypot(b.x - cx, b.y - cy) / 120)
74
+ const anchor = blobs.reduce((best, b) => score(b) > score(best) ? b : best, blobs[0])
75
+ out.push({ id: zone.id, label: zone.label, count: zone.count, index: zone.index, anchor: { x: anchor.x, y: anchor.y }, blobs })
76
+ }
77
+ return out
78
+ }
@@ -0,0 +1,11 @@
1
+ // Graph workspace primitives: the read model, scale and layout helpers, label
2
+ // placement, layered recipes and themes, focused authoring and a disclosure gate.
3
+ // Pure modules only; browser rooms live under ./browser/index.mjs.
4
+ export { buildWorkspaceModel, zoneOf, ROOT_ZONE, validateZoneRule, edgeClass, EDGE_CLASSES, neighbours } from './model.mjs'
5
+ export { seedPositions, layoutWorkspace, normalizePositions, zoneGeometry, LAYOUT_WEIGHTS } from './geometry.mjs'
6
+ export { planConstellation, aggregateZones, zoneSlice, rankRecords, DEFAULT_BUDGET } from './scale.mjs'
7
+ export { placeLabels, labelThreshold, labelBudget, overlaps } from './labels.mjs'
8
+ export { DEFAULT_RECIPES, composeRecipes, validateRecipe, availableRecipes, ROOMS, LENSES, SUBJECTS, LAYERS, PRIMARY_ACTIONS, primaryActionsFor } from './recipes.mjs'
9
+ export { DEFAULT_THEME, composeTheme, validateTheme, contrastRatio } from './theme.mjs'
10
+ export { conceptSteps, relationSteps, guideConcept, guideRelation, stepState, createDraftStore, memoryStorage, readiness } from './authoring.mjs'
11
+ export { fingerprintsFrom, scanForFingerprints } from './disclosure.mjs'
@@ -0,0 +1,50 @@
1
+ // Label placement for dense graphs. Pure: give it candidates, a text measurer and
2
+ // the space available; it returns where each label goes, or leaves it out.
3
+ // - importance first; zoom and a budget decide how deep into the list to go,
4
+ // - four slots per label (right, left, above, below), first free slot wins,
5
+ // - nothing overlaps another label, a reserved rectangle (zone names, chrome) or the edge,
6
+ // - secondary labels are left out rather than shrunk.
7
+
8
+ export const overlaps = (a, b, gap = 0) => a.left < b.right + gap && a.right + gap > b.left && a.top < b.bottom + gap && a.bottom + gap > b.top
9
+
10
+ /**
11
+ * candidates: [{ id, x, y, r, priority, text, size }] in viewport pixels (any order).
12
+ * measure(text, size, emphasis) → width in pixels.
13
+ * Returns placed labels [{ id, text, size, x, y, left, right, top, bottom }].
14
+ */
15
+ export function placeLabels({ candidates, measure, width, height, reserved = [], budget = 25, threshold = 0, margin = 4, cell = 80, minSize = 11 }) {
16
+ if (typeof measure !== 'function') throw new TypeError('A text measurer is required')
17
+ const ordered = candidates.filter(c => c.priority >= threshold && Number.isFinite(c.x) && Number.isFinite(c.y)).sort((a, b) => b.priority - a.priority)
18
+ const grid = new Map()
19
+ const cellsFor = r => { const out = []; for (let x = Math.floor(r.left / cell); x <= Math.floor(r.right / cell); x++) for (let y = Math.floor(r.top / cell); y <= Math.floor(r.bottom / cell); y++) out.push(x + ',' + y); return out }
20
+ const placed = []
21
+ for (const c of ordered) {
22
+ if (placed.length >= budget) break
23
+ const size = Math.max(minSize, c.size ?? 12)
24
+ const w = measure(c.text, size, !!c.emphasis)
25
+ const half = size * 0.72
26
+ const r = c.r ?? 4
27
+ const slots = [[c.x + r + 5, c.y], [c.x - r - 5 - w, c.y], [c.x - w / 2, c.y - r - size * 0.9], [c.x - w / 2, c.y + r + size * 0.9]]
28
+ for (const [sx, sy] of slots) {
29
+ const rect = { left: sx - 3, right: sx + w + 3, top: sy - half, bottom: sy + half }
30
+ if (rect.left < margin || rect.top < margin || rect.right > width - margin || rect.bottom > height - margin) continue
31
+ const cells = cellsFor(rect)
32
+ if (cells.some(k => (grid.get(k) ?? []).some(o => overlaps(o, rect, 3)))) continue
33
+ if (reserved.some(o => overlaps(o, rect, 4))) continue
34
+ for (const k of cells) { if (!grid.has(k)) grid.set(k, []); grid.get(k).push(rect) }
35
+ placed.push({ id: c.id, text: c.text, size, x: sx, y: sy, ...rect })
36
+ break
37
+ }
38
+ }
39
+ return placed
40
+ }
41
+
42
+ /** Rank-based label threshold for a camera ratio (1 = whole graph fitted, smaller = closer). */
43
+ export function labelThreshold(ratio, boost = 1) {
44
+ return Math.max(0, Math.min(0.94, 0.8 * ratio - 0.04)) / Math.max(0.1, boost)
45
+ }
46
+
47
+ /** Label budget for a viewport: about 20–25 at a fitted desktop view, more as you zoom in. */
48
+ export function labelBudget(width, height, ratio, boost = 1, cap = 45) {
49
+ return Math.round(Math.min(cap, Math.max(10, (width * height) / 52000 / Math.max(0.35, Math.min(1, ratio)))) * boost)
50
+ }