@mnstry/atelier 0.2.0-alpha.15 → 0.2.0-alpha.17
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 +9 -0
- package/README.md +1 -1
- package/docs/guides/extensions.md +6 -6
- package/docs/guides/graph-workspace.md +19 -7
- package/docs/guides/upgrade.md +24 -0
- package/docs/reference/graph-workspace.md +4 -4
- package/docs/reference/portable-presentation.md +9 -0
- package/docs/reference/ui-primitives.md +116 -0
- package/docs/start/independent-start.md +2 -2
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +3 -1
- package/src/extensions/project.mjs +5 -5
- package/src/graph-workspace/browser/index.mjs +1 -1
- package/src/graph-workspace/browser/{constellation.mjs → map.mjs} +7 -2
- package/src/graph-workspace/browser/styles.mjs +1 -1
- package/src/graph-workspace/browser/workbench.mjs +2 -2
- package/src/graph-workspace/browser/workspace.mjs +53 -51
- package/src/graph-workspace/disclosure.mjs +1 -1
- package/src/graph-workspace/geometry.mjs +1 -1
- package/src/graph-workspace/index.mjs +2 -2
- package/src/graph-workspace/recipes.mjs +24 -12
- package/src/graph-workspace/scale.mjs +5 -2
- package/src/graph-workspace/theme.mjs +2 -2
- package/src/ui/primitives/browser.mjs +6 -0
- package/src/ui/primitives/carousel.mjs +551 -0
- package/src/ui/primitives/disclosure.mjs +50 -0
- package/src/ui/primitives/index.mjs +7 -0
- package/src/ui/primitives/motion.mjs +50 -0
- package/src/ui/primitives/render.mjs +22 -0
- package/src/ui/primitives/scroll-lock.mjs +47 -0
- package/src/ui/primitives/state.mjs +130 -0
- package/src/ui/primitives/styles.mjs +127 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Default graph workspace shell: one graph, three rooms (
|
|
2
|
-
// review), chosen by a work recipe.
|
|
1
|
+
// Default graph workspace shell: one graph, three rooms (map, workbench,
|
|
2
|
+
// review), chosen by a work recipe. Distributions can use it as is, restyle it through a
|
|
3
3
|
// theme, change its recipes, or build their own shell from the same rooms.
|
|
4
4
|
//
|
|
5
5
|
// const workspace = mountGraphWorkspace(element, {
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
//
|
|
16
16
|
// One workspace per document (it uses fixed element ids). The host decides what
|
|
17
17
|
// the model contains; nothing here fetches, grants or saves to a source.
|
|
18
|
-
import {
|
|
18
|
+
import { createMap } from './map.mjs'
|
|
19
19
|
import { createWorkbench } from './workbench.mjs'
|
|
20
20
|
import { renderReview } from './review.mjs'
|
|
21
21
|
import { injectGraphWorkspaceStyles } from './styles.mjs'
|
|
22
22
|
import { h, clear } from './dom.mjs'
|
|
23
23
|
import { createDraftStore, readiness, memoryStorage } from '../authoring.mjs'
|
|
24
|
-
import { composeRecipes, availableRecipes } from '../recipes.mjs'
|
|
24
|
+
import { composeRecipes, availableRecipes, currentRoom } from '../recipes.mjs'
|
|
25
25
|
import { DEFAULT_THEME, validateTheme } from '../theme.mjs'
|
|
26
26
|
|
|
27
27
|
const fmt = n => n.toLocaleString('en-US')
|
|
@@ -56,16 +56,18 @@ export function mountGraphWorkspace(root, options) {
|
|
|
56
56
|
model, deps = null, theme = DEFAULT_THEME, profile = {}, initialRecipe = null, intro = true,
|
|
57
57
|
webgl = hasWebGL() && !!deps,
|
|
58
58
|
} = options
|
|
59
|
-
|
|
59
|
+
// Recipes may arrive already composed (possibly by alpha.15 code): read their
|
|
60
|
+
// room names as this release does, keeping everything else, provenance included.
|
|
61
|
+
const recipesAll = (options.recipes ?? composeRecipes().recipes).map(r => (r && typeof r === 'object' && !Array.isArray(r) && 'room' in r ? { ...r, room: currentRoom(r.room) } : r))
|
|
60
62
|
const recipes = availableRecipes(recipesAll, model)
|
|
61
63
|
const byRecipe = new Map(recipes.map(r => [r.id, r]))
|
|
62
64
|
const vocab = { record: 'record', records: 'records', zone: 'zone', concept: 'concept', model: 'Concepts', ...(profile.vocabulary ?? {}) }
|
|
63
65
|
injectGraphWorkspaceStyles(root.ownerDocument)
|
|
64
66
|
|
|
65
67
|
const byNode = new Map(model.nodes.map(n => [n.id, n]))
|
|
66
|
-
const state = { recipe: null, room: '
|
|
68
|
+
const state = { recipe: null, room: 'map', 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
69
|
const counters = { dives: 0 }
|
|
68
|
-
let palette = null,
|
|
70
|
+
let palette = null, mapView = null
|
|
69
71
|
|
|
70
72
|
// ── DOM ──
|
|
71
73
|
const el = {}
|
|
@@ -74,7 +76,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
74
76
|
root.classList.add('atelier-gw')
|
|
75
77
|
const stage = h('div', { class: 'stage' })
|
|
76
78
|
stage.append(
|
|
77
|
-
make('
|
|
79
|
+
make('map', 'div', { class: 'map constellation', role: 'img', 'aria-label': 'Map of every record you can read. Use search, the selection card or Text view to move through it by keyboard.' }),
|
|
78
80
|
h('header', { class: 'top' },
|
|
79
81
|
h('div', { class: 'menu-wrap', 'data-graph-reserve': '' },
|
|
80
82
|
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: '▾' })),
|
|
@@ -116,7 +118,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
116
118
|
|
|
117
119
|
function applyAppearance() {
|
|
118
120
|
palette = applyTheme(theme, state.scheme, root)
|
|
119
|
-
|
|
121
|
+
mapView?.setPalette(palette)
|
|
120
122
|
el.schemeBtn.textContent = state.scheme === 'dark' ? 'Light appearance' : 'Dark appearance'
|
|
121
123
|
}
|
|
122
124
|
applyAppearance()
|
|
@@ -126,10 +128,10 @@ export function mountGraphWorkspace(root, options) {
|
|
|
126
128
|
|
|
127
129
|
const font = getComputedStyle(root).getPropertyValue('--font-sans').trim() || 'system-ui'
|
|
128
130
|
if (webgl) {
|
|
129
|
-
|
|
130
|
-
if (intro)
|
|
131
|
+
mapView = createMap({ container: el.map, model, palette, font, deps, onSelect: id => select(id), onOpen: id => openWorkbench({ kind: 'record', id }) })
|
|
132
|
+
if (intro) mapView.intro()
|
|
131
133
|
}
|
|
132
|
-
const workbench = createWorkbench({ root: el.workbench, model, palette, store, vocab, onBack:
|
|
134
|
+
const workbench = createWorkbench({ root: el.workbench, model, palette, store, vocab, onBack: backToMap, onReview: () => openReview({ from: 'workbench' }), onShowOnMap: id => backToMap().then(() => { select(id); mapView?.showNode(id) }) })
|
|
133
135
|
el.workbench.hidden = true
|
|
134
136
|
|
|
135
137
|
// ── Recipes ──
|
|
@@ -142,7 +144,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
142
144
|
root.dataset.recipe = recipe.id
|
|
143
145
|
root.dataset.primary = recipe.primary
|
|
144
146
|
closeMenus()
|
|
145
|
-
state.lens = recipe.room === '
|
|
147
|
+
state.lens = recipe.room === 'map' ? recipe.lens : null
|
|
146
148
|
renderLensCard()
|
|
147
149
|
if (recipe.room === 'workbench') {
|
|
148
150
|
// In the workbench the primary action is where focus lands (the top bar sits behind it).
|
|
@@ -152,17 +154,17 @@ export function mountGraphWorkspace(root, options) {
|
|
|
152
154
|
const opened = openWorkbench(subject)
|
|
153
155
|
if (mounted) opened.then(() => focusPrimary(recipe.primary))
|
|
154
156
|
} else if (recipe.room === 'review') openReview({ from: 'recipe' })
|
|
155
|
-
else if (state.room !== '
|
|
157
|
+
else if (state.room !== 'map') backToMap()
|
|
156
158
|
announce(`${recipe.label}. ${recipe.description}`)
|
|
157
159
|
}
|
|
158
160
|
function renderLensCard() {
|
|
159
161
|
el.questionCard.hidden = !state.lens
|
|
160
162
|
el.questionCard.dataset.lens = state.lens ?? ''
|
|
161
|
-
if (state.lens !== 'zone')
|
|
163
|
+
if (state.lens !== 'zone') mapView?.setFocusZone(null)
|
|
162
164
|
if (state.lens === 'question') renderQuestionCard()
|
|
163
165
|
else if (state.lens === 'concept') renderConceptLens()
|
|
164
166
|
else if (state.lens === 'zone') renderZoneLens()
|
|
165
|
-
else { clear(el.questionCard);
|
|
167
|
+
else { clear(el.questionCard); mapView?.setLens(null) }
|
|
166
168
|
}
|
|
167
169
|
function renderConceptLens() {
|
|
168
170
|
clear(el.questionCard)
|
|
@@ -170,7 +172,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
170
172
|
const c = plan.concepts.find(x => x.id === state.conceptId) ?? plan.concepts[0]
|
|
171
173
|
state.conceptId = c.id
|
|
172
174
|
const ids = c.declaredBy.filter(id => byNode.has(id))
|
|
173
|
-
|
|
175
|
+
mapView?.setLens(ids)
|
|
174
176
|
el.questionCard.append(h('p', { class: 'eyebrow', text: `The ${vocab.concept} in view` }),
|
|
175
177
|
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
178
|
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.' }),
|
|
@@ -181,8 +183,8 @@ export function mountGraphWorkspace(root, options) {
|
|
|
181
183
|
const z = model.zones.find(x => x.id === state.zoneId) ?? model.zones[0]
|
|
182
184
|
if (!z) return
|
|
183
185
|
state.zoneId = z.id
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
mapView?.setLens(null)
|
|
187
|
+
mapView?.setFocusZone(z.id)
|
|
186
188
|
el.questionCard.append(h('p', { class: 'eyebrow', text: `The ${vocab.zone} in view` }),
|
|
187
189
|
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
190
|
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.` }))
|
|
@@ -219,7 +221,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
219
221
|
const q = plan.questions.find(x => x.id === state.questionId) ?? plan.questions[0]
|
|
220
222
|
state.questionId = q.id
|
|
221
223
|
const ids = new Set(questionLit())
|
|
222
|
-
|
|
224
|
+
mapView?.setLens([...ids])
|
|
223
225
|
const zones = new Set([...ids].map(id => byNode.get(id).zoneLabel))
|
|
224
226
|
el.questionCard.append(h('p', { class: 'eyebrow', text: 'The question on the table' }),
|
|
225
227
|
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 }))),
|
|
@@ -231,7 +233,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
231
233
|
// ── Selection and rooms ──
|
|
232
234
|
function select(id) {
|
|
233
235
|
state.selected = id
|
|
234
|
-
|
|
236
|
+
mapView?.select(id)
|
|
235
237
|
renderCard()
|
|
236
238
|
if (id) announce(`Selected ${model.nodes.find(n => n.id === id)?.title}.`)
|
|
237
239
|
}
|
|
@@ -253,20 +255,20 @@ export function mountGraphWorkspace(root, options) {
|
|
|
253
255
|
h('p', { class: 'card-meta', text: `${n.degree} links · ${n.relationDegree} declared${n.concepts.length ? ' · declares ' + n.concepts.join(', ') : ''}` }),
|
|
254
256
|
h('div', { class: 'card-actions' },
|
|
255
257
|
h('button', { class: 'primary', type: 'button', 'data-gw': 'open-selected', onclick: () => openWorkbench({ kind: 'record', id: n.id }) }, 'Open in the workbench'),
|
|
256
|
-
|
|
258
|
+
mapView ? h('button', { class: 'quiet', type: 'button', onclick: () => mapView.showNode(n.id) }, 'Show on map') : null),
|
|
257
259
|
near.length ? h('details', { class: 'card-neighbours' }, h('summary', { text: `Walk to a neighbour (${near.length})` }),
|
|
258
260
|
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
261
|
}
|
|
260
262
|
async function openWorkbench(subject) {
|
|
261
263
|
closeMenus()
|
|
262
264
|
let from = null
|
|
263
|
-
if (state.room === '
|
|
264
|
-
state.savedCamera =
|
|
265
|
+
if (state.room === 'map' && mapView) {
|
|
266
|
+
state.savedCamera = mapView.camera.getState()
|
|
265
267
|
if (subject.kind === 'record') {
|
|
266
|
-
const p =
|
|
268
|
+
const p = mapView.nodeViewport(subject.id), r = el.map.getBoundingClientRect()
|
|
267
269
|
from = p ? { x: r.left + p.x, y: r.top + p.y } : null
|
|
268
270
|
root.classList.add('diving')
|
|
269
|
-
await
|
|
271
|
+
await mapView.dive(subject.id)
|
|
270
272
|
}
|
|
271
273
|
}
|
|
272
274
|
counters.dives++
|
|
@@ -276,13 +278,13 @@ export function mountGraphWorkspace(root, options) {
|
|
|
276
278
|
el.review.hidden = true
|
|
277
279
|
workbench.open(subject, { from })
|
|
278
280
|
}
|
|
279
|
-
async function
|
|
281
|
+
async function backToMap() {
|
|
280
282
|
workbench.close()
|
|
281
283
|
el.review.hidden = true
|
|
282
|
-
state.room = '
|
|
283
|
-
root.dataset.room = '
|
|
284
|
-
if (
|
|
285
|
-
|
|
284
|
+
state.room = 'map'
|
|
285
|
+
root.dataset.room = 'map'
|
|
286
|
+
if (mapView && state.savedCamera) await mapView.restore(state.savedCamera)
|
|
287
|
+
mapView?.refresh()
|
|
286
288
|
}
|
|
287
289
|
function openReview({ from = 'workbench' } = {}) {
|
|
288
290
|
closeMenus()
|
|
@@ -292,8 +294,8 @@ export function mountGraphWorkspace(root, options) {
|
|
|
292
294
|
const toWorkbench = () => { el.review.hidden = true; state.room = 'workbench'; root.dataset.room = 'workbench' }
|
|
293
295
|
renderReview(el.review, {
|
|
294
296
|
model: { ...model, systemId: profile.id ?? 'workspace' }, store,
|
|
295
|
-
backLabel: from === 'workbench' ? '← Back to the workbench' : '← Back to the
|
|
296
|
-
onBack: () => { if (from === 'workbench') { toWorkbench(); workbench.refresh() } else { el.review.hidden = true;
|
|
297
|
+
backLabel: from === 'workbench' ? '← Back to the workbench' : '← Back to the map',
|
|
298
|
+
onBack: () => { if (from === 'workbench') { toWorkbench(); workbench.refresh() } else { el.review.hidden = true; backToMap() } },
|
|
297
299
|
onOpen: s => { toWorkbench(); workbench.open(s) },
|
|
298
300
|
})
|
|
299
301
|
}
|
|
@@ -303,9 +305,9 @@ export function mountGraphWorkspace(root, options) {
|
|
|
303
305
|
const q = query.trim().toLowerCase()
|
|
304
306
|
const commands = [
|
|
305
307
|
...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
|
|
308
|
+
...(webgl ? [{ kind: 'command', id: 'text', label: state.textView ? 'Show the map' : 'Text view', hint: 'Every record as a structured list' }] : []),
|
|
307
309
|
{ kind: 'command', id: 'scheme', label: state.scheme === 'dark' ? 'Light appearance' : 'Dark appearance', hint: 'Switch appearance' },
|
|
308
|
-
...(
|
|
310
|
+
...(mapView ? [{ kind: 'command', id: 'fit', label: 'Fit the whole graph', hint: 'Zoom out to everything' }] : []),
|
|
309
311
|
]
|
|
310
312
|
if (!q) return commands.slice(0, 6)
|
|
311
313
|
const out = commands.filter(c => c.label.toLowerCase().includes(q))
|
|
@@ -331,10 +333,10 @@ export function mountGraphWorkspace(root, options) {
|
|
|
331
333
|
else if (r.kind === 'concept') openWorkbench({ kind: 'concept', id: r.id })
|
|
332
334
|
else if (r.kind === 'question') { state.questionId = r.id; const q = recipes.find(x => x.lens === 'question'); if (q) setRecipe(q.id) }
|
|
333
335
|
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 !== '
|
|
336
|
+
else if (r.kind === 'record') { if (state.room !== 'map') backToMap().then(() => select(r.id)); else select(r.id); root.querySelector('#card-title')?.focus() }
|
|
335
337
|
else if (r.id === 'text') toggleText()
|
|
336
338
|
else if (r.id === 'scheme') toggleScheme()
|
|
337
|
-
else if (r.id === 'fit')
|
|
339
|
+
else if (r.id === 'fit') mapView?.fit()
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
// ── Text view ──
|
|
@@ -366,7 +368,7 @@ export function mountGraphWorkspace(root, options) {
|
|
|
366
368
|
state.textView = !state.textView
|
|
367
369
|
root.classList.toggle('text-mode', state.textView)
|
|
368
370
|
el.textToggle.setAttribute('aria-pressed', String(state.textView))
|
|
369
|
-
if (state.textView) el.textView.querySelector('input')?.focus(); else
|
|
371
|
+
if (state.textView) el.textView.querySelector('input')?.focus(); else mapView?.refresh()
|
|
370
372
|
}
|
|
371
373
|
function toggleScheme() { state.scheme = state.scheme === 'dark' ? 'light' : 'dark'; applyAppearance(); renderTextView(); renderCard(); if (state.room === 'workbench') workbench.refresh() }
|
|
372
374
|
|
|
@@ -384,13 +386,13 @@ export function mountGraphWorkspace(root, options) {
|
|
|
384
386
|
on(el.displayBtn, 'click', () => toggle(el.display, el.displayBtn))
|
|
385
387
|
on(el.textToggle, 'click', toggleText)
|
|
386
388
|
on(el.schemeBtn, 'click', toggleScheme)
|
|
387
|
-
on(el.zonesToggle, 'change', e => { state.zonesOn = e.target.checked;
|
|
388
|
-
on(el.linksToggle, 'change', e => { state.linksOn = e.target.checked;
|
|
389
|
-
on(el.labelsRange, 'input', e => { state.labelBoost = +e.target.value;
|
|
390
|
-
on(el.physicsToggle, 'change', e => { e.target.checked = !!
|
|
391
|
-
on(el.fit, 'click', () =>
|
|
392
|
-
on(el.zoomIn, 'click', () =>
|
|
393
|
-
on(el.zoomOut, 'click', () =>
|
|
389
|
+
on(el.zonesToggle, 'change', e => { state.zonesOn = e.target.checked; mapView?.setOption('zonesOn', state.zonesOn) })
|
|
390
|
+
on(el.linksToggle, 'change', e => { state.linksOn = e.target.checked; mapView?.setOption('linksOn', state.linksOn) })
|
|
391
|
+
on(el.labelsRange, 'input', e => { state.labelBoost = +e.target.value; mapView?.setOption('labelBoost', state.labelBoost) })
|
|
392
|
+
on(el.physicsToggle, 'change', e => { e.target.checked = !!mapView?.setPhysics(e.target.checked) })
|
|
393
|
+
on(el.fit, 'click', () => mapView?.fit())
|
|
394
|
+
on(el.zoomIn, 'click', () => mapView?.zoom(1.5))
|
|
395
|
+
on(el.zoomOut, 'click', () => mapView?.zoom(1 / 1.5))
|
|
394
396
|
on(el.command, 'focus', renderCommand)
|
|
395
397
|
on(el.command, 'input', renderCommand)
|
|
396
398
|
on(el.command, 'keydown', e => {
|
|
@@ -410,9 +412,9 @@ export function mountGraphWorkspace(root, options) {
|
|
|
410
412
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); el.command.focus(); return }
|
|
411
413
|
if (e.target.closest?.('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return
|
|
412
414
|
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')
|
|
415
|
+
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') backToMap(); else select(null) }
|
|
414
416
|
else if (e.key === 't') { e.preventDefault(); toggleText() }
|
|
415
|
-
else if (e.key === 'o' && state.selected && state.room === '
|
|
417
|
+
else if (e.key === 'o' && state.selected && state.room === 'map') openWorkbench({ kind: 'record', id: state.selected })
|
|
416
418
|
})
|
|
417
419
|
|
|
418
420
|
let mounted = false
|
|
@@ -421,11 +423,11 @@ export function mountGraphWorkspace(root, options) {
|
|
|
421
423
|
mounted = true
|
|
422
424
|
|
|
423
425
|
return {
|
|
424
|
-
get state() { return { ...state, savedCamera: undefined, counters: { ...counters }, webgl, records: model.nodes.length, links: model.edges.length, workbench: workbench.subject, camera:
|
|
425
|
-
recipes, store, constellation, workbench,
|
|
426
|
-
select, openWorkbench, openReview, setRecipe, runPrimary, backToConstellation, toggleText,
|
|
426
|
+
get state() { return { ...state, savedCamera: undefined, counters: { ...counters }, webgl, records: model.nodes.length, links: model.edges.length, workbench: workbench.subject, camera: mapView ? mapView.camera.getState() : null, draftStatus: store.status, draftPersistence: store.persistence } },
|
|
427
|
+
recipes, store, map: mapView, constellation: mapView, workbench,
|
|
428
|
+
select, openWorkbench, openReview, setRecipe, runPrimary, backToMap, backToConstellation: backToMap, toggleText,
|
|
427
429
|
setScheme(scheme) { state.scheme = scheme; applyAppearance() },
|
|
428
430
|
readiness: () => readiness(model.plan, store, new Set(model.nodes.map(n => n.id))).status,
|
|
429
|
-
destroy() { for (const fn of cleanups.splice(0)) fn();
|
|
431
|
+
destroy() { for (const fn of cleanups.splice(0)) fn(); mapView?.kill(); clear(root); root.classList.remove('atelier-gw') },
|
|
430
432
|
}
|
|
431
433
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Shippable-content gate for layered systems. When a
|
|
1
|
+
// Shippable-content gate for layered systems. When a distribution or owner builds on top of
|
|
2
2
|
// private workspaces, the shareable layer must not carry anything from them.
|
|
3
3
|
// fingerprintsFrom() derives distinctive strings from a private knowledge graph and
|
|
4
4
|
// plan (ids, paths, long titles, summary openings, plan wording, names);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Presentation geometry for the
|
|
1
|
+
// Presentation geometry for the map. Positions are presentation only:
|
|
2
2
|
// distance never asserts a relation. The force layout itself is injected so this
|
|
3
3
|
// package carries no layout dependency; any ForceAtlas2-compatible function works.
|
|
4
4
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Pure modules only; browser rooms live under ./browser/index.mjs.
|
|
4
4
|
export { buildWorkspaceModel, zoneOf, ROOT_ZONE, validateZoneRule, edgeClass, EDGE_CLASSES, neighbours } from './model.mjs'
|
|
5
5
|
export { seedPositions, layoutWorkspace, normalizePositions, zoneGeometry, LAYOUT_WEIGHTS } from './geometry.mjs'
|
|
6
|
-
export { planConstellation, aggregateZones, zoneSlice, rankRecords, DEFAULT_BUDGET } from './scale.mjs'
|
|
6
|
+
export { planMap, planConstellation, aggregateZones, zoneSlice, rankRecords, DEFAULT_BUDGET } from './scale.mjs'
|
|
7
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'
|
|
8
|
+
export { DEFAULT_RECIPES, composeRecipes, validateRecipe, availableRecipes, ROOMS, DEPRECATED_ROOMS, currentRoom, LENSES, SUBJECTS, LAYERS, PRIMARY_ACTIONS, primaryActionsFor } from './recipes.mjs'
|
|
9
9
|
export { DEFAULT_THEME, composeTheme, validateTheme, contrastRatio } from './theme.mjs'
|
|
10
10
|
export { conceptSteps, relationSteps, guideConcept, guideRelation, stepState, createDraftStore, memoryStorage, readiness } from './authoring.mjs'
|
|
11
11
|
export { fingerprintsFrom, scanForFingerprints } from './disclosure.mjs'
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// Work recipes, layered: Atelier ships defaults, a
|
|
2
|
-
//
|
|
1
|
+
// Work recipes, layered: Atelier ships defaults, a distribution (the tier that
|
|
2
|
+
// adapts Atelier for a programme or cohort) adds or adjusts, and the owner has the
|
|
3
|
+
// final say. A recipe is data that chooses the room, the lens
|
|
3
4
|
// and the one primary action. Every resolved field records which layer set it.
|
|
4
5
|
|
|
5
|
-
export const ROOMS = Object.freeze(['
|
|
6
|
+
export const ROOMS = Object.freeze(['map', 'workbench', 'review'])
|
|
7
|
+
/** Names accepted for one release and read as their replacements. */
|
|
8
|
+
export const DEPRECATED_ROOMS = Object.freeze({ constellation: 'map' })
|
|
6
9
|
export const LENSES = Object.freeze([null, 'question', 'concept', 'zone'])
|
|
7
10
|
export const SUBJECTS = Object.freeze([null, 'record', 'concept', 'relation'])
|
|
8
|
-
export const LAYERS = Object.freeze(['atelier', '
|
|
11
|
+
export const LAYERS = Object.freeze(['atelier', 'distribution', 'owner'])
|
|
9
12
|
/** The single emphasised action of a recipe. The default shell implements each one. */
|
|
10
13
|
export const PRIMARY_ACTIONS = Object.freeze(['search', 'open-evidence', 'next-step', 'walk', 'review', 'pick'])
|
|
11
14
|
|
|
@@ -19,14 +22,16 @@ export function primaryActionsFor({ room, lens = null, subject = null }) {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export const DEFAULT_RECIPES = Object.freeze([
|
|
22
|
-
{ id: 'explore', label: 'Explore', room: '
|
|
23
|
-
{ id: 'question', label: 'Answer a question', room: '
|
|
25
|
+
{ id: 'explore', label: 'Explore', room: 'map', lens: null, subject: null, primary: 'search', description: 'Wander the whole graph. Zones on, search ready.' },
|
|
26
|
+
{ id: 'question', label: 'Answer a question', room: 'map', lens: 'question', subject: null, primary: 'open-evidence', description: 'Light the records that declare a question’s concepts.' },
|
|
24
27
|
{ id: 'refine', label: 'Refine a concept', room: 'workbench', lens: null, subject: 'concept', primary: 'next-step', description: 'One concept, one step at a time.' },
|
|
25
28
|
{ 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
29
|
].map(Object.freeze))
|
|
27
30
|
|
|
28
31
|
const FIELDS = ['label', 'room', 'lens', 'subject', 'primary', 'description']
|
|
29
32
|
const text = (v, max) => typeof v === 'string' && v.trim().length > 0 && v.length <= max
|
|
33
|
+
/** A room name as this release reads it: deprecated names map to their replacements. */
|
|
34
|
+
export const currentRoom = room => (typeof room === 'string' && Object.hasOwn(DEPRECATED_ROOMS, room) ? DEPRECATED_ROOMS[room] : room)
|
|
30
35
|
|
|
31
36
|
export function validateRecipe(recipe, { partial = false } = {}) {
|
|
32
37
|
const problems = []
|
|
@@ -36,7 +41,7 @@ export function validateRecipe(recipe, { partial = false } = {}) {
|
|
|
36
41
|
const need = !partial
|
|
37
42
|
if ((need || 'label' in recipe) && !text(recipe.label, 60)) problems.push('label must be 1–60 characters')
|
|
38
43
|
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(', ')}`)
|
|
44
|
+
if ((need || 'room' in recipe) && !ROOMS.includes(currentRoom(recipe.room))) problems.push(`room must be one of ${ROOMS.join(', ')}`)
|
|
40
45
|
if ('lens' in recipe && !LENSES.includes(recipe.lens)) problems.push('unknown lens')
|
|
41
46
|
if ('subject' in recipe && !SUBJECTS.includes(recipe.subject)) problems.push('unknown subject')
|
|
42
47
|
if ((need || 'primary' in recipe) && !PRIMARY_ACTIONS.includes(recipe.primary)) problems.push(`primary must be one of ${PRIMARY_ACTIONS.join(', ')}`)
|
|
@@ -48,22 +53,29 @@ export function validateRecipe(recipe, { partial = false } = {}) {
|
|
|
48
53
|
* Compose recipe layers. Each layer is a list; an entry with an existing id adjusts
|
|
49
54
|
* it (partial), a new id adds a complete recipe, and `hidden: true` removes it from
|
|
50
55
|
* what the owner sees. Later layers win. Throws on any invalid entry.
|
|
56
|
+
* `coach` is the alpha.15 name of the distribution layer, accepted for one release;
|
|
57
|
+
* the room `constellation` is likewise read as `map`.
|
|
51
58
|
*/
|
|
52
|
-
export function composeRecipes({ atelier = DEFAULT_RECIPES, coach
|
|
59
|
+
export function composeRecipes({ atelier = DEFAULT_RECIPES, distribution, coach, owner = [] } = {}) {
|
|
60
|
+
if (distribution !== undefined && coach !== undefined) throw new TypeError('Give the distribution layer once: `coach` is its deprecated name')
|
|
61
|
+
// Only an omitted layer defaults; anything supplied (null included) is validated below.
|
|
62
|
+
const middle = distribution !== undefined ? distribution : coach !== undefined ? coach : []
|
|
53
63
|
const resolved = new Map()
|
|
54
64
|
const provenance = new Map()
|
|
55
|
-
for (const [layer, list] of [['atelier', atelier], ['
|
|
65
|
+
for (const [layer, list] of [['atelier', atelier], ['distribution', middle], ['owner', owner]]) {
|
|
56
66
|
if (!Array.isArray(list)) throw new TypeError(`${layer} recipes must be a list`)
|
|
57
|
-
for (const
|
|
67
|
+
for (const raw of list) {
|
|
68
|
+
// Only plain recipe objects are normalised; anything else reaches validation unchanged.
|
|
69
|
+
const entry = raw && typeof raw === 'object' && !Array.isArray(raw) && 'room' in raw ? { ...raw, room: currentRoom(raw.room) } : raw
|
|
58
70
|
const existing = resolved.get(entry?.id)
|
|
59
71
|
const problems = validateRecipe(entry, { partial: !!existing })
|
|
60
72
|
if (problems.length) throw new TypeError(`${layer} recipe ${entry?.id ?? '(no id)'}: ${problems.join('; ')}`)
|
|
61
73
|
const next = { lens: null, subject: null, hidden: false, ...(existing ?? {}), ...entry }
|
|
62
|
-
// Only combinations a shell can honour: lenses belong to the
|
|
74
|
+
// Only combinations a shell can honour: lenses belong to the map,
|
|
63
75
|
// subjects to the workbench, and the review room takes neither.
|
|
64
76
|
if (next.room === 'workbench' && !next.subject) throw new TypeError(`${layer} recipe ${entry.id}: a workbench recipe needs a subject`)
|
|
65
77
|
if (next.room !== 'workbench' && next.subject) throw new TypeError(`${layer} recipe ${entry.id}: only a workbench recipe takes a subject`)
|
|
66
|
-
if (next.room !== '
|
|
78
|
+
if (next.room !== 'map' && next.lens) throw new TypeError(`${layer} recipe ${entry.id}: only a map recipe takes a lens`)
|
|
67
79
|
const actions = primaryActionsFor(next)
|
|
68
80
|
if (!actions.includes(next.primary)) throw new TypeError(`${layer} recipe ${entry.id}: primary ${next.primary} is not available here; use ${actions.join(' or ')}`)
|
|
69
81
|
resolved.set(entry.id, next)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Scale primitives for workspaces with tens of thousands of records.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// The map draws every readable record while that stays legible and fast.
|
|
4
4
|
// Beyond a budget, the whole is shown as zones (with honest counts and directed
|
|
5
5
|
// bundles between them) and a person opens one zone at a time. Nothing here hides
|
|
6
6
|
// a record silently: every bounded result states what it left out.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
export const DEFAULT_BUDGET = Object.freeze({ records: 12000, links: 60000 })
|
|
9
9
|
|
|
10
10
|
/** Decide how the whole should be drawn for this model and budget. */
|
|
11
|
-
export function
|
|
11
|
+
export function planMap(model, budget = DEFAULT_BUDGET) {
|
|
12
12
|
const records = model.nodes.length, links = model.edges.length
|
|
13
13
|
if (records <= budget.records && links <= budget.links) return { mode: 'records', records, links }
|
|
14
14
|
return { mode: 'zones', records, links, reason: records > budget.records ? 'records-over-budget' : 'links-over-budget', budget: { ...budget } }
|
|
@@ -93,3 +93,6 @@ export function zoneSlice(model, zoneId, { limit = DEFAULT_BUDGET.records } = {}
|
|
|
93
93
|
},
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Deprecated alpha.15 name of `planMap`, kept for one release. */
|
|
98
|
+
export const planConstellation = planMap
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Graph-workspace themes: resolved token data, never stylesheets.
|
|
2
|
-
// Atelier ships two neutral defaults, `night` (the default) and `paper`. A
|
|
2
|
+
// Atelier ships two neutral defaults, `night` (the default) and `paper`. A distribution or
|
|
3
3
|
// owner theme overrides values by name; the result is validated, including text
|
|
4
4
|
// contrast, before any room applies it as CSS custom properties.
|
|
5
5
|
|
|
@@ -126,7 +126,7 @@ export function validateTheme(theme) {
|
|
|
126
126
|
return problems
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/** Override a base theme by layer (
|
|
129
|
+
/** Override a base theme by layer (distribution, then owner). Each layer is validated before it is applied. */
|
|
130
130
|
export function composeTheme(base = DEFAULT_THEME, ...layers) {
|
|
131
131
|
const baseProblems = validateTheme(base)
|
|
132
132
|
if (baseProblems.length) throw new TypeError('Base theme refused: ' + baseProblems.join('; '))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Atelier UI primitives for the browser. */
|
|
2
|
+
export { enhanceCarousels, enhanceCarousel } from './carousel.mjs'
|
|
3
|
+
export { enhanceDisclosures } from './disclosure.mjs'
|
|
4
|
+
export { lockScroll, unlockScroll, scrollLocked } from './scroll-lock.mjs'
|
|
5
|
+
export { animate, animationAllowed, onMotionChange, startMotionWatch } from './motion.mjs'
|
|
6
|
+
export * from './state.mjs'
|