@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.
- package/CHANGELOG.md +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /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
|
+
}
|