@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,109 @@
1
+ // Workspace model: the shared read model behind every graph-workspace room.
2
+ //
3
+ // Input is an Atelier knowledge graph (`mnstry.atelier-knowledge-graph@v1`) that a
4
+ // host has already limited to what the reader may see, plus an optional knowledge
5
+ // plan. Output is plain data the browser rooms can draw. This module reads no files,
6
+ // grants nothing and infers no relation:
7
+ // - zones come only from the owner's folder structure (a zone rule),
8
+ // - concept membership comes only from a record's own `concept:<id>` tag,
9
+ // - edges keep their declared direction and fall into three classes.
10
+
11
+ const SCHEMA = 'mnstry.atelier-knowledge-graph@v1'
12
+ export const EDGE_CLASSES = Object.freeze(['link', 'containment', 'relation'])
13
+
14
+ /** `related` is a written link, `belongs_to` is folder containment, anything else is a declared relation. */
15
+ export function edgeClass(type) {
16
+ if (type === 'related') return 'link'
17
+ if (type === 'belongs_to') return 'containment'
18
+ return 'relation'
19
+ }
20
+
21
+ const humanize = folder => folder.replace(/^[_\d-]+/, '').replace(/[-_]+/g, ' ').replace(/^\w/, c => c.toUpperCase()) || 'Other'
22
+
23
+ export function validateZoneRule(rule = {}) {
24
+ const prefix = rule.prefix ?? ''
25
+ const depth = rule.depth ?? 1
26
+ if (typeof prefix !== 'string' || prefix.includes('..') || prefix.startsWith('/')) throw new TypeError('Zone rule prefix must be a relative path prefix')
27
+ if (!Number.isInteger(depth) || depth < 1 || depth > 4) throw new TypeError('Zone rule depth must be an integer from 1 to 4')
28
+ const labels = rule.labels ?? {}
29
+ if (typeof labels !== 'object' || Array.isArray(labels) || Object.values(labels).some(v => typeof v !== 'string' || v.length > 80)) throw new TypeError('Zone rule labels must map folders to short names')
30
+ return { prefix, depth, labels: Object.assign(Object.create(null), labels), rootLabel: typeof rule.rootLabel === 'string' ? rule.rootLabel.slice(0, 80) : 'Workspace root' }
31
+ }
32
+
33
+ /** The zone of records outside the folder rule. No folder id can be '/'. */
34
+ export const ROOT_ZONE = '/'
35
+
36
+ /** The owner-configured zone of a record path. Records outside the prefix share one root zone. */
37
+ export function zoneOf(path, rule) {
38
+ const r = validateZoneRule(rule)
39
+ if (typeof path !== 'string' || !path.startsWith(r.prefix)) return { id: ROOT_ZONE, label: r.rootLabel }
40
+ const parts = path.slice(r.prefix.length).split('/')
41
+ if (parts.length <= r.depth) return { id: ROOT_ZONE, label: r.rootLabel }
42
+ const folder = parts.slice(0, r.depth).join('/')
43
+ return { id: folder, label: Object.hasOwn(r.labels, folder) ? r.labels[folder] : humanize(folder.split('/').at(-1)) }
44
+ }
45
+
46
+ export function buildWorkspaceModel({ graph, plan = null, zoneRule = {}, summaryLength = 280 }) {
47
+ if (graph?.schema !== SCHEMA || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) throw new TypeError('An Atelier knowledge graph v1 is required')
48
+ const rule = validateZoneRule(zoneRule)
49
+ const seenIds = new Set()
50
+ const nodes = graph.nodes.map(n => {
51
+ if (typeof n.id !== 'string' || !n.id || seenIds.has(n.id)) throw new TypeError('Knowledge graph node ids must be unique non-empty strings')
52
+ seenIds.add(n.id)
53
+ const zone = zoneOf(n.path ?? '', rule)
54
+ const tags = Array.isArray(n.tags) ? n.tags.filter(t => typeof t === 'string') : []
55
+ return {
56
+ id: n.id, title: typeof n.title === 'string' && n.title ? n.title : n.id,
57
+ summary: typeof n.summary === 'string' ? n.summary.split('\n')[0].slice(0, summaryLength) : '',
58
+ type: n.type ?? 'document', status: n.status ?? null, audience: n.audience ?? null, path: n.path ?? null,
59
+ tags: tags.filter(t => !t.startsWith('concept:')).slice(0, 8),
60
+ concepts: tags.filter(t => t.startsWith('concept:')).map(t => t.slice(8)),
61
+ zone: zone.id, zoneLabel: zone.label, degree: 0, relationDegree: 0,
62
+ }
63
+ })
64
+ const byId = new Map(nodes.map(n => [n.id, n]))
65
+ const edges = []
66
+ const seen = new Set()
67
+ let outside = 0
68
+ for (const e of graph.edges) {
69
+ if (!byId.has(e.source) || !byId.has(e.target)) { outside++; continue }
70
+ if (e.source === e.target) continue
71
+ const key = `${e.source}\u0000${e.target}\u0000${e.type}`
72
+ if (seen.has(key)) continue
73
+ seen.add(key)
74
+ const kind = edgeClass(e.type)
75
+ edges.push({ id: 'e' + edges.length, source: e.source, target: e.target, type: e.type, kind, declared: e.declared !== false })
76
+ for (const id of [e.source, e.target]) { const n = byId.get(id); n.degree++; if (kind === 'relation') n.relationDegree++ }
77
+ }
78
+ const counts = new Map()
79
+ for (const n of nodes) counts.set(n.zone, { id: n.zone, label: n.zoneLabel, count: (counts.get(n.zone)?.count ?? 0) + 1 })
80
+ const zones = [...counts.values()].sort((a, b) => b.count - a.count || (a.id < b.id ? -1 : 1)).map((z, index) => ({ ...z, index }))
81
+ return {
82
+ schema: 'atelier.graph-workspace-model@v1',
83
+ coverage: { records: nodes.length, links: edges.length, linksOutsideReadableSet: outside, byClass: Object.fromEntries(EDGE_CLASSES.map(k => [k, edges.filter(e => e.kind === k).length])) },
84
+ nodes, edges, zones,
85
+ plan: plan ? planModel(plan, nodes) : null,
86
+ }
87
+ }
88
+
89
+ function planModel(plan, nodes) {
90
+ const concepts = Array.isArray(plan.concepts) ? plan.concepts.filter(c => typeof c?.id === 'string') : []
91
+ const ids = new Set(concepts.map(c => c.id))
92
+ return {
93
+ purpose: typeof plan.purpose === 'string' ? plan.purpose : null,
94
+ concepts: concepts.map(c => ({
95
+ id: c.id, title: typeof c.title === 'string' ? c.title : c.id.replace(/-/g, ' ').replace(/^\w/, x => x.toUpperCase()),
96
+ definition: c.definition ?? '', identityRule: c.identityRule ?? '',
97
+ declaredBy: nodes.filter(n => n.concepts.includes(c.id)).map(n => n.id),
98
+ })),
99
+ relations: (plan.relations ?? []).filter(r => ids.has(r.from) && ids.has(r.to)).map(r => ({ id: r.id, predicate: r.predicate, from: r.from, to: r.to, meaning: r.meaning ?? '' })),
100
+ questions: (plan.questions ?? []).map(q => ({ id: q.id, question: q.question ?? q.title ?? q.id, concepts: (q.concepts ?? []).filter(id => ids.has(id)), relations: q.relations ?? [] })),
101
+ }
102
+ }
103
+
104
+ /** Neighbour ids of a record, in either direction, without duplicates. */
105
+ export function neighbours(model, id) {
106
+ const out = new Set()
107
+ for (const e of model.edges) { if (e.source === id) out.add(e.target); else if (e.target === id) out.add(e.source) }
108
+ return [...out]
109
+ }
@@ -0,0 +1,88 @@
1
+ // Work recipes, layered: Atelier ships defaults, a cohort coach adds or adjusts,
2
+ // and the owner has the final say. A recipe is data that chooses the room, the lens
3
+ // and the one primary action. Every resolved field records which layer set it.
4
+
5
+ export const ROOMS = Object.freeze(['constellation', 'workbench', 'review'])
6
+ export const LENSES = Object.freeze([null, 'question', 'concept', 'zone'])
7
+ export const SUBJECTS = Object.freeze([null, 'record', 'concept', 'relation'])
8
+ export const LAYERS = Object.freeze(['atelier', 'coach', 'owner'])
9
+ /** The single emphasised action of a recipe. The default shell implements each one. */
10
+ export const PRIMARY_ACTIONS = Object.freeze(['search', 'open-evidence', 'next-step', 'walk', 'review', 'pick'])
11
+
12
+ /** The primary actions the default shell honours for a recipe's room, lens and subject. */
13
+ export function primaryActionsFor({ room, lens = null, subject = null }) {
14
+ if (room === 'review') return ['review']
15
+ if (room === 'workbench') return subject === 'record' ? ['walk'] : ['next-step']
16
+ if (lens === 'zone') return ['pick', 'search']
17
+ if (lens) return ['open-evidence', 'pick', 'search']
18
+ return ['search']
19
+ }
20
+
21
+ export const DEFAULT_RECIPES = Object.freeze([
22
+ { id: 'explore', label: 'Explore', room: 'constellation', lens: null, subject: null, primary: 'search', description: 'Wander the whole graph. Zones on, search ready.' },
23
+ { id: 'question', label: 'Answer a question', room: 'constellation', lens: 'question', subject: null, primary: 'open-evidence', description: 'Light the records that declare a question’s concepts.' },
24
+ { id: 'refine', label: 'Refine a concept', room: 'workbench', lens: null, subject: 'concept', primary: 'next-step', description: 'One concept, one step at a time.' },
25
+ { id: 'inspect', label: 'Inspect a record', room: 'workbench', lens: null, subject: 'record', primary: 'walk', description: 'A record and its closest links, with room to read.' },
26
+ ].map(Object.freeze))
27
+
28
+ const FIELDS = ['label', 'room', 'lens', 'subject', 'primary', 'description']
29
+ const text = (v, max) => typeof v === 'string' && v.trim().length > 0 && v.length <= max
30
+
31
+ export function validateRecipe(recipe, { partial = false } = {}) {
32
+ const problems = []
33
+ if (!recipe || typeof recipe !== 'object' || Array.isArray(recipe)) return ['recipe must be an object']
34
+ if (!/^[a-z][a-z0-9-]{0,47}$/.test(recipe.id ?? '')) problems.push('id must be a short lowercase slug')
35
+ for (const key of Object.keys(recipe)) if (!['id', 'hidden', ...FIELDS].includes(key)) problems.push(`unknown field ${key}`)
36
+ const need = !partial
37
+ if ((need || 'label' in recipe) && !text(recipe.label, 60)) problems.push('label must be 1–60 characters')
38
+ if ((need || 'description' in recipe) && !text(recipe.description, 200)) problems.push('description must be 1–200 characters')
39
+ if ((need || 'room' in recipe) && !ROOMS.includes(recipe.room)) problems.push(`room must be one of ${ROOMS.join(', ')}`)
40
+ if ('lens' in recipe && !LENSES.includes(recipe.lens)) problems.push('unknown lens')
41
+ if ('subject' in recipe && !SUBJECTS.includes(recipe.subject)) problems.push('unknown subject')
42
+ if ((need || 'primary' in recipe) && !PRIMARY_ACTIONS.includes(recipe.primary)) problems.push(`primary must be one of ${PRIMARY_ACTIONS.join(', ')}`)
43
+ if ('hidden' in recipe && typeof recipe.hidden !== 'boolean') problems.push('hidden must be true or false')
44
+ return problems
45
+ }
46
+
47
+ /**
48
+ * Compose recipe layers. Each layer is a list; an entry with an existing id adjusts
49
+ * it (partial), a new id adds a complete recipe, and `hidden: true` removes it from
50
+ * what the owner sees. Later layers win. Throws on any invalid entry.
51
+ */
52
+ export function composeRecipes({ atelier = DEFAULT_RECIPES, coach = [], owner = [] } = {}) {
53
+ const resolved = new Map()
54
+ const provenance = new Map()
55
+ for (const [layer, list] of [['atelier', atelier], ['coach', coach], ['owner', owner]]) {
56
+ if (!Array.isArray(list)) throw new TypeError(`${layer} recipes must be a list`)
57
+ for (const entry of list) {
58
+ const existing = resolved.get(entry?.id)
59
+ const problems = validateRecipe(entry, { partial: !!existing })
60
+ if (problems.length) throw new TypeError(`${layer} recipe ${entry?.id ?? '(no id)'}: ${problems.join('; ')}`)
61
+ const next = { lens: null, subject: null, hidden: false, ...(existing ?? {}), ...entry }
62
+ // Only combinations a shell can honour: lenses belong to the constellation,
63
+ // subjects to the workbench, and the review room takes neither.
64
+ if (next.room === 'workbench' && !next.subject) throw new TypeError(`${layer} recipe ${entry.id}: a workbench recipe needs a subject`)
65
+ if (next.room !== 'workbench' && next.subject) throw new TypeError(`${layer} recipe ${entry.id}: only a workbench recipe takes a subject`)
66
+ if (next.room !== 'constellation' && next.lens) throw new TypeError(`${layer} recipe ${entry.id}: only a constellation recipe takes a lens`)
67
+ const actions = primaryActionsFor(next)
68
+ if (!actions.includes(next.primary)) throw new TypeError(`${layer} recipe ${entry.id}: primary ${next.primary} is not available here; use ${actions.join(' or ')}`)
69
+ resolved.set(entry.id, next)
70
+ const fields = provenance.get(entry.id) ?? {}
71
+ for (const key of Object.keys(entry)) if (key !== 'id') fields[key] = layer
72
+ if (!existing) for (const key of FIELDS) fields[key] ??= layer
73
+ provenance.set(entry.id, fields)
74
+ }
75
+ }
76
+ const visible = [...resolved.values()].filter(r => !r.hidden)
77
+ return { recipes: visible.map(r => ({ ...r, setBy: { ...provenance.get(r.id) } })), hidden: [...resolved.values()].filter(r => r.hidden).map(r => r.id) }
78
+ }
79
+
80
+ /** Recipes a workspace can actually run: concept work needs a plan with concepts. */
81
+ export function availableRecipes(recipes, model) {
82
+ const concepts = !!model.plan?.concepts.length, relations = !!model.plan?.relations.length, questions = !!model.plan?.questions.length
83
+ return recipes.filter(r =>
84
+ (r.subject !== 'concept' || concepts) && (r.subject !== 'relation' || relations) &&
85
+ (r.lens !== 'question' || questions) && (r.lens !== 'concept' || concepts) &&
86
+ (r.room !== 'review' || concepts) && (r.subject !== 'record' || model.nodes.length > 0) &&
87
+ (r.lens !== 'zone' || model.zones?.length > 0))
88
+ }
@@ -0,0 +1,95 @@
1
+ // Scale primitives for workspaces with tens of thousands of records.
2
+ //
3
+ // A constellation draws every readable record while that stays legible and fast.
4
+ // Beyond a budget, the whole is shown as zones (with honest counts and directed
5
+ // bundles between them) and a person opens one zone at a time. Nothing here hides
6
+ // a record silently: every bounded result states what it left out.
7
+
8
+ export const DEFAULT_BUDGET = Object.freeze({ records: 12000, links: 60000 })
9
+
10
+ /** Decide how the whole should be drawn for this model and budget. */
11
+ export function planConstellation(model, budget = DEFAULT_BUDGET) {
12
+ const records = model.nodes.length, links = model.edges.length
13
+ if (records <= budget.records && links <= budget.links) return { mode: 'records', records, links }
14
+ return { mode: 'zones', records, links, reason: records > budget.records ? 'records-over-budget' : 'links-over-budget', budget: { ...budget } }
15
+ }
16
+
17
+ /** Zone-level summary of the whole: counts by type and directed bundles by edge class. */
18
+ export function aggregateZones(model) {
19
+ const zoneOfId = new Map(model.nodes.map(n => [n.id, n.zone]))
20
+ const zones = model.zones.map(z => ({ id: z.id, label: z.label, index: z.index, count: z.count, types: Object.create(null), declaredRelations: 0 }))
21
+ const byId = new Map(zones.map(z => [z.id, z]))
22
+ for (const n of model.nodes) { const z = byId.get(n.zone); z.types[n.type] = (z.types[n.type] ?? 0) + 1 }
23
+ const bundles = new Map()
24
+ let internal = 0
25
+ for (const e of model.edges) {
26
+ const a = zoneOfId.get(e.source), b = zoneOfId.get(e.target)
27
+ if (a === b) { internal++; if (e.kind === 'relation') byId.get(a).declaredRelations++; continue }
28
+ const key = a + '\u0000' + b
29
+ const bundle = bundles.get(key) ?? { from: a, to: b, total: 0, link: 0, containment: 0, relation: 0 }
30
+ bundle.total++; bundle[e.kind]++
31
+ bundles.set(key, bundle)
32
+ }
33
+ return {
34
+ schema: 'atelier.graph-workspace-zones@v1',
35
+ coverage: { records: model.nodes.length, zones: zones.length, linksInsideZones: internal, linksBetweenZones: model.edges.length - internal, linksOutsideReadableSet: model.coverage.linksOutsideReadableSet },
36
+ zones,
37
+ bundles: [...bundles.values()].sort((x, y) => y.total - x.total || (x.from + x.to < y.from + y.to ? -1 : 1)),
38
+ }
39
+ }
40
+
41
+ /** Records ranked by connection count, then title, for budgets and label priority. */
42
+ export function rankRecords(model) {
43
+ return [...model.nodes].sort((a, b) => b.degree - a.degree || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0))
44
+ }
45
+
46
+ /**
47
+ * A bounded sub-model for one zone. Members are kept by rank up to `limit`.
48
+ * Every link touching the zone is accounted for exactly once:
49
+ * drawn (both ends kept) + omittedInsideZone + toOtherZones + omittedToOtherZones = links touching the zone.
50
+ * Plan concept declarations are projected onto the kept records, with omissions counted.
51
+ */
52
+ export function zoneSlice(model, zoneId, { limit = DEFAULT_BUDGET.records } = {}) {
53
+ const members = rankRecords(model).filter(n => n.zone === zoneId)
54
+ const kept = members.slice(0, limit)
55
+ const keep = new Set(kept.map(n => n.id))
56
+ const inZone = new Set(members.map(n => n.id))
57
+ const zoneOfId = new Map(model.nodes.map(n => [n.id, n.zone]))
58
+ const edges = []
59
+ const boundary = new Map()
60
+ let omittedInsideZone = 0, omittedToOtherZones = 0, touching = 0
61
+ for (const e of model.edges) {
62
+ const s = inZone.has(e.source), t = inZone.has(e.target)
63
+ if (!s && !t) continue
64
+ touching++
65
+ if (keep.has(e.source) && keep.has(e.target)) { edges.push(e); continue }
66
+ if (s && t) { omittedInsideZone++; continue }
67
+ const memberEnd = s ? e.source : e.target
68
+ if (!keep.has(memberEnd)) { omittedToOtherZones++; continue }
69
+ const other = zoneOfId.get(s ? e.target : e.source)
70
+ boundary.set(other, (boundary.get(other) ?? 0) + 1)
71
+ }
72
+ const toOtherZones = [...boundary.values()].reduce((a, b) => a + b, 0)
73
+ const byClass = { link: 0, containment: 0, relation: 0 }
74
+ for (const e of edges) byClass[e.kind]++
75
+ const plan = model.plan ? {
76
+ ...model.plan,
77
+ concepts: model.plan.concepts.map(c => {
78
+ const declaredBy = c.declaredBy.filter(id => keep.has(id))
79
+ return { ...c, declaredBy, omittedDeclarations: c.declaredBy.length - declaredBy.length }
80
+ }),
81
+ } : null
82
+ return {
83
+ ...model,
84
+ nodes: kept.map(n => ({ ...n })),
85
+ edges,
86
+ zones: model.zones.filter(z => z.id === zoneId).map(z => ({ ...z, count: kept.length })),
87
+ plan,
88
+ coverage: { records: kept.length, links: edges.length, byClass, linksOutsideReadableSet: model.coverage.linksOutsideReadableSet },
89
+ slice: {
90
+ zone: zoneId, membersInZone: members.length, omittedRecords: members.length - kept.length,
91
+ linksTouchingZone: touching, omittedLinksInsideZone: omittedInsideZone, omittedLinksToOtherZones: omittedToOtherZones, linksToOtherZonesCount: toOtherZones,
92
+ linksToOtherZones: [...boundary.entries()].map(([zone, count]) => ({ zone, count })).sort((a, b) => b.count - a.count),
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,156 @@
1
+ // Graph-workspace themes: resolved token data, never stylesheets.
2
+ // Atelier ships two neutral defaults, `night` (the default) and `paper`. A coach or
3
+ // owner theme overrides values by name; the result is validated, including text
4
+ // contrast, before any room applies it as CSS custom properties.
5
+
6
+ const CSS_VARS = ['--canvas', '--surface', '--surface-2', '--surface-glass', '--ink', '--ink-2', '--ink-3', '--line', '--line-strong', '--accent', '--accent-ink', '--focus', '--warn', '--danger', '--ok', '--font-sans', '--font-display', '--radius', '--radius-lg', '--shadow', '--motion', '--ease']
7
+ const GRAPH = ['canvas', 'edge', 'edgeStructural', 'dim', 'label', 'labelHalo', 'selection']
8
+
9
+ const night = {
10
+ vars: {
11
+ '--canvas': '#0b0e14', '--surface': '#131823', '--surface-2': '#1a2030', '--surface-glass': 'rgba(19,24,35,0.82)',
12
+ '--ink': '#eef1f7', '--ink-2': '#b9c1d3', '--ink-3': '#8d97ad', '--line': 'rgba(238,241,247,0.12)', '--line-strong': 'rgba(238,241,247,0.26)',
13
+ '--accent': '#9cc4ff', '--accent-ink': '#08101d', '--focus': '#ffd479', '--warn': '#ffc46b', '--danger': '#ff9a8a', '--ok': '#8fe0b0',
14
+ '--font-sans': 'Inter, "SF Pro Text", -apple-system, system-ui, sans-serif', '--font-display': '"SF Pro Display", Inter, -apple-system, system-ui, sans-serif',
15
+ '--radius': '10px', '--radius-lg': '16px', '--shadow': '0 12px 40px rgba(0,0,0,0.45)', '--motion': '240ms', '--ease': 'cubic-bezier(0.2, 0, 0, 1)',
16
+ },
17
+ graph: { canvas: '#0b0e14', edge: '#7d88a3', edgeStructural: '#c9d2e6', dim: '#283044', label: '#e6eaf2', labelHalo: '#0b0e14', selection: '#ffd479',
18
+ zones: ['#7aa7ff', '#5fd1b0', '#f2a65a', '#c792ea', '#ff8fa3', '#e6d36a', '#6fd3f2', '#a4e07a', '#f28fd2', '#b0b8c8'] },
19
+ }
20
+ const paper = {
21
+ vars: {
22
+ ...night.vars,
23
+ '--canvas': '#f5f3ee', '--surface': '#ffffff', '--surface-2': '#f0ede6', '--surface-glass': 'rgba(255,255,255,0.86)',
24
+ '--ink': '#1a1d24', '--ink-2': '#454b58', '--ink-3': '#5f6676', '--line': 'rgba(26,29,36,0.12)', '--line-strong': 'rgba(26,29,36,0.28)',
25
+ '--accent': '#2c55a8', '--accent-ink': '#ffffff', '--focus': '#a05a00', '--warn': '#8a4a00', '--danger': '#a32121', '--ok': '#1f6b3c', '--shadow': '0 12px 36px rgba(20,22,30,0.14)',
26
+ },
27
+ graph: { canvas: '#f5f3ee', edge: '#9aa1ae', edgeStructural: '#4a5162', dim: '#dcd8cf', label: '#1a1d24', labelHalo: '#f5f3ee', selection: '#a05a00',
28
+ zones: ['#2f62c9', '#0f7259', '#9a4f0c', '#7a45a8', '#b83255', '#6f5d00', '#0f6a85', '#3f6c14', '#a3337f', '#5d6577'] },
29
+ }
30
+
31
+ export const DEFAULT_THEME = Object.freeze({
32
+ schema: 'atelier.graph-workspace-theme@v1', id: 'atelier-night', version: '1', defaultScheme: 'dark',
33
+ schemes: Object.freeze({ dark: night, light: paper }),
34
+ })
35
+
36
+ // Every token has one type. Only colours, lengths, durations, an easing and font
37
+ // family names are accepted, so no theme value can load a resource or inject CSS.
38
+ const HEX = /^#[0-9a-f]{6}$/i
39
+ const RGBA = /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d{1,3})\s*\)$/i
40
+ const COLOR = v => HEX.test(v)
41
+ const SOFT_COLOR = v => HEX.test(v) || RGBA.test(v)
42
+ const FONT = v => /^[A-Za-z0-9 "',.-]{1,160}$/.test(v) && !/["']{2}/.test(v)
43
+ const LENGTH = v => /^\d{1,2}(?:\.\d{1,2})?px$/.test(v)
44
+ const SHADOW = v => v === 'none' || v.split(/,(?![^()]*\))/).every(part => /^\s*(?:(?:0|-?\d{1,3}(?:\.\d{1,2})?px)\s+){2,4}(?:rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d{1,3})\s*\)|#[0-9a-f]{6})\s*$/i.test(part))
45
+ const DURATION = v => /^\d{1,4}ms$/.test(v)
46
+ const EASE = v => /^(?:ease|linear|ease-in|ease-out|ease-in-out|cubic-bezier\(\s*-?\d(?:\.\d{1,3})?\s*,\s*-?\d(?:\.\d{1,3})?\s*,\s*-?\d(?:\.\d{1,3})?\s*,\s*-?\d(?:\.\d{1,3})?\s*\))$/.test(v)
47
+ export const TOKEN_TYPES = Object.freeze({
48
+ '--canvas': COLOR, '--surface': COLOR, '--surface-2': COLOR, '--surface-glass': SOFT_COLOR,
49
+ '--ink': COLOR, '--ink-2': COLOR, '--ink-3': COLOR, '--line': SOFT_COLOR, '--line-strong': SOFT_COLOR,
50
+ '--accent': COLOR, '--accent-ink': COLOR, '--focus': COLOR, '--warn': COLOR, '--danger': COLOR, '--ok': COLOR,
51
+ '--font-sans': FONT, '--font-display': FONT, '--radius': LENGTH, '--radius-lg': LENGTH, '--shadow': SHADOW, '--motion': DURATION, '--ease': EASE,
52
+ })
53
+ const LAYER_FIELDS = ['id', 'version', 'defaultScheme', 'schemes']
54
+ const SCHEME_FIELDS = ['vars', 'graph']
55
+ // Text and the surfaces it is drawn on in the rooms, with the minimum ratio for its size.
56
+ const TEXT_PAIRS = [
57
+ ['--ink', '--canvas', 7], ['--ink', '--surface', 7], ['--ink', '--surface-2', 7],
58
+ ['--ink-2', '--canvas', 4.5], ['--ink-2', '--surface', 4.5], ['--ink-2', '--surface-2', 4.5],
59
+ ['--ink-3', '--canvas', 4.5], ['--ink-3', '--surface', 4.5], ['--ink-3', '--surface-2', 4.5],
60
+ ['--accent-ink', '--accent', 4.5], ['--accent', '--canvas', 4.5], ['--accent', '--surface', 4.5],
61
+ ['--warn', '--surface', 4.5], ['--danger', '--surface', 4.5], ['--ok', '--surface', 4.5],
62
+ ]
63
+ // Text on the translucent chrome (pills, command bar, selection card), composited
64
+ // over each backdrop it floats on.
65
+ const GLASS_TEXT = [['--ink', 4.5], ['--ink-2', 4.5], ['--ink-3', 4.5]]
66
+ const GLASS_BACKDROPS = ['--canvas', '--surface-2']
67
+ function composite(over, under) {
68
+ const rgb = h => { const n = parseInt(h.slice(1, 7), 16); return [n >> 16, (n >> 8) & 255, n & 255] }
69
+ const m = RGBA.test(over) ? over.match(/[\d.]+/g).map(Number) : [...rgb(over), 1]
70
+ const [r, g, b, a] = [Math.min(255, m[0]), Math.min(255, m[1]), Math.min(255, m[2]), Math.min(1, m[3])]
71
+ const base = rgb(under)
72
+ return '#' + [r, g, b].map((v, i) => Math.round(v * a + base[i] * (1 - a)).toString(16).padStart(2, '0')).join('')
73
+ }
74
+
75
+ function layerProblems(layer, { partial }) {
76
+ const problems = []
77
+ if (!layer || typeof layer !== 'object' || Array.isArray(layer)) return ['theme layer must be an object']
78
+ for (const key of Object.keys(layer)) if (!LAYER_FIELDS.includes(key) && !(key === 'schema' && !partial)) problems.push(`unknown theme field ${key}`)
79
+ if ('id' in layer && !/^[a-z][a-z0-9-]{0,47}$/.test(layer.id ?? '')) problems.push('id must be a short lowercase slug')
80
+ if ('version' in layer && !/^[\w.-]{1,32}$/.test(layer.version ?? '')) problems.push('version must be a short label')
81
+ if ('defaultScheme' in layer && !['dark', 'light'].includes(layer.defaultScheme)) problems.push('defaultScheme must be dark or light')
82
+ if ('schemes' in layer) {
83
+ if (!layer.schemes || typeof layer.schemes !== 'object' || Array.isArray(layer.schemes)) problems.push('schemes must be an object')
84
+ else for (const [scheme, value] of Object.entries(layer.schemes)) {
85
+ if (!['dark', 'light'].includes(scheme)) { problems.push(`unknown scheme ${scheme}`); continue }
86
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { problems.push(`${scheme}: must be an object`); continue }
87
+ for (const key of Object.keys(value)) if (!SCHEME_FIELDS.includes(key)) problems.push(`${scheme}: unknown field ${key}`)
88
+ for (const [k, v] of Object.entries(value.vars ?? {})) {
89
+ if (!Object.hasOwn(TOKEN_TYPES, k)) problems.push(`${scheme}: unknown token ${k}`)
90
+ else if (typeof v !== 'string' || !TOKEN_TYPES[k](v)) problems.push(`${scheme}: rejected ${k}`)
91
+ }
92
+ for (const [k, v] of Object.entries(value.graph ?? {})) {
93
+ if (k === 'zones') { if (!Array.isArray(v) || v.length < 4 || v.length > 16 || !v.every(z => typeof z === 'string' && HEX.test(z))) problems.push(`${scheme}: graph.zones needs 4–16 #rrggbb colours`) }
94
+ else if (!GRAPH.includes(k)) problems.push(`${scheme}: unknown graph key ${k}`)
95
+ else if (typeof v !== 'string' || !HEX.test(v)) problems.push(`${scheme}: graph.${k} must be #rrggbb`)
96
+ }
97
+ }
98
+ }
99
+ return problems
100
+ }
101
+
102
+ export function validateTheme(theme) {
103
+ const problems = []
104
+ if (theme?.schema !== 'atelier.graph-workspace-theme@v1') problems.push('schema must be atelier.graph-workspace-theme@v1')
105
+ problems.push(...layerProblems(theme, { partial: false }))
106
+ if (!['dark', 'light'].includes(theme?.defaultScheme)) problems.push('defaultScheme must be dark or light')
107
+ for (const scheme of ['dark', 'light']) {
108
+ const s = theme?.schemes?.[scheme]
109
+ if (!s) { problems.push(`missing ${scheme} scheme`); continue }
110
+ for (const k of Object.keys(TOKEN_TYPES)) if (!(k in (s.vars ?? {}))) problems.push(`${scheme}: missing ${k}`)
111
+ for (const k of GRAPH) if (!(k in (s.graph ?? {}))) problems.push(`${scheme}: missing graph.${k}`)
112
+ if (!Array.isArray(s.graph?.zones)) problems.push(`${scheme}: missing graph.zones`)
113
+ }
114
+ if (problems.length) return [...new Set(problems)]
115
+ for (const scheme of ['dark', 'light']) {
116
+ const { vars, graph } = theme.schemes[scheme]
117
+ if (vars['--canvas'].toLowerCase() !== graph.canvas.toLowerCase()) problems.push(`${scheme}: --canvas and graph.canvas must be the same colour`)
118
+ for (const [fg, bg, min] of TEXT_PAIRS) if (contrastRatio(vars[fg], vars[bg]) < min) problems.push(`${scheme}: ${fg} on ${bg} is below ${min}:1`)
119
+ for (const backdrop of GLASS_BACKDROPS) {
120
+ const glass = composite(vars['--surface-glass'], vars[backdrop])
121
+ for (const [fg, min] of GLASS_TEXT) if (contrastRatio(vars[fg], glass) < min) problems.push(`${scheme}: ${fg} on --surface-glass over ${backdrop} is below ${min}:1`)
122
+ }
123
+ if (contrastRatio(graph.label, graph.canvas) < 7) problems.push(`${scheme}: label contrast on the canvas is below 7:1`)
124
+ if (Math.min(...graph.zones.map(z => contrastRatio(z, graph.canvas))) < 3) problems.push(`${scheme}: a zone colour is below 3:1 on the canvas (zone names are large text)`)
125
+ }
126
+ return problems
127
+ }
128
+
129
+ /** Override a base theme by layer (coach, then owner). Each layer is validated before it is applied. */
130
+ export function composeTheme(base = DEFAULT_THEME, ...layers) {
131
+ const baseProblems = validateTheme(base)
132
+ if (baseProblems.length) throw new TypeError('Base theme refused: ' + baseProblems.join('; '))
133
+ const out = structuredClone(base)
134
+ layers.filter(Boolean).forEach((layer, index) => {
135
+ const problems = layerProblems(layer, { partial: true })
136
+ if (problems.length) throw new TypeError(`Theme layer ${index + 1} refused: ${problems.join('; ')}`)
137
+ if (layer.id) out.id = layer.id
138
+ if (layer.version) out.version = layer.version
139
+ if (layer.defaultScheme) out.defaultScheme = layer.defaultScheme
140
+ for (const scheme of ['dark', 'light']) {
141
+ const s = layer.schemes?.[scheme]
142
+ if (!s) continue
143
+ Object.assign(out.schemes[scheme].vars, s.vars ?? {})
144
+ Object.assign(out.schemes[scheme].graph, s.graph ?? {})
145
+ }
146
+ })
147
+ const problems = validateTheme(out)
148
+ if (problems.length) throw new TypeError('Theme refused: ' + problems.join('; '))
149
+ return out
150
+ }
151
+
152
+ export function contrastRatio(a, b) {
153
+ const lum = h => { const n = parseInt(h.slice(1, 7), 16); return [n >> 16, (n >> 8) & 255, n & 255].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0) }
154
+ const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p)
155
+ return (x + 0.05) / (y + 0.05)
156
+ }
@@ -717,6 +717,11 @@ class GraphifySupplier:
717
717
  settings = {"parallel": False, "max_workers": 1, **settings}
718
718
  require(type(settings["parallel"]) is bool and type(settings["max_workers"]) is int
719
719
  and 1 <= settings["max_workers"] <= 32, "BUDGET_UNBOUNDED", "Finite native structural worker bound required")
720
+ # Parallel extraction starts worker processes that import Graphify through the
721
+ # ordinary import system, outside the pinned source loader, so they may run a
722
+ # cached pyc this supplier never attested. Only in-process extraction is admitted.
723
+ require(settings["parallel"] is False, "NATIVE_WORKERS_UNATTESTED",
724
+ "Parallel native extraction runs worker processes outside the attested loader")
720
725
  if op == "cache-check":
721
726
  require(settings.get("prompt") is None or isinstance(settings["prompt"], str), "PROMPT_SCOPE", "Prompt must be explicit text")
722
727
  if settings.get("prompt_file") is not None: