@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/docs/guides/extensions.md +6 -6
  4. package/docs/guides/graph-workspace.md +19 -7
  5. package/docs/guides/upgrade.md +24 -0
  6. package/docs/reference/graph-workspace.md +4 -4
  7. package/docs/reference/portable-presentation.md +9 -0
  8. package/docs/reference/ui-primitives.md +116 -0
  9. package/docs/start/independent-start.md +2 -2
  10. package/fixtures/templates/local-library.v1.json +3 -1
  11. package/package.json +3 -1
  12. package/src/extensions/project.mjs +5 -5
  13. package/src/graph-workspace/browser/index.mjs +1 -1
  14. package/src/graph-workspace/browser/{constellation.mjs → map.mjs} +7 -2
  15. package/src/graph-workspace/browser/styles.mjs +1 -1
  16. package/src/graph-workspace/browser/workbench.mjs +2 -2
  17. package/src/graph-workspace/browser/workspace.mjs +53 -51
  18. package/src/graph-workspace/disclosure.mjs +1 -1
  19. package/src/graph-workspace/geometry.mjs +1 -1
  20. package/src/graph-workspace/index.mjs +2 -2
  21. package/src/graph-workspace/recipes.mjs +24 -12
  22. package/src/graph-workspace/scale.mjs +5 -2
  23. package/src/graph-workspace/theme.mjs +2 -2
  24. package/src/ui/primitives/browser.mjs +6 -0
  25. package/src/ui/primitives/carousel.mjs +551 -0
  26. package/src/ui/primitives/disclosure.mjs +50 -0
  27. package/src/ui/primitives/index.mjs +7 -0
  28. package/src/ui/primitives/motion.mjs +50 -0
  29. package/src/ui/primitives/render.mjs +22 -0
  30. package/src/ui/primitives/scroll-lock.mjs +47 -0
  31. package/src/ui/primitives/state.mjs +130 -0
  32. package/src/ui/primitives/styles.mjs +127 -0
  33. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
@@ -1,5 +1,5 @@
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
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 { createConstellation } from './constellation.mjs'
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
- const recipesAll = options.recipes ?? composeRecipes().recipes
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: '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 }
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, constellation = 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('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.' }),
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
- constellation?.setPalette(palette)
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
- 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
+ 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: backToConstellation, onReview: () => openReview({ from: 'workbench' }), onShowOnMap: id => backToConstellation().then(() => { select(id); constellation?.showNode(id) }) })
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 === 'constellation' ? recipe.lens : null
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 !== 'constellation') backToConstellation()
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') constellation?.setFocusZone(null)
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); constellation?.setLens(null) }
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
- constellation?.setLens(ids)
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
- constellation?.setLens(null)
185
- constellation?.setFocusZone(z.id)
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
- constellation?.setLens([...ids])
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
- constellation?.select(id)
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
- constellation ? h('button', { class: 'quiet', type: 'button', onclick: () => constellation.showNode(n.id) }, 'Show on map') : null),
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 === 'constellation' && constellation) {
264
- state.savedCamera = constellation.camera.getState()
265
+ if (state.room === 'map' && mapView) {
266
+ state.savedCamera = mapView.camera.getState()
265
267
  if (subject.kind === 'record') {
266
- const p = constellation.nodeViewport(subject.id), r = el.constellation.getBoundingClientRect()
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 constellation.dive(subject.id)
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 backToConstellation() {
281
+ async function backToMap() {
280
282
  workbench.close()
281
283
  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()
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 constellation',
296
- onBack: () => { if (from === 'workbench') { toWorkbench(); workbench.refresh() } else { el.review.hidden = true; backToConstellation() } },
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 constellation' : 'Text view', hint: 'Every record as a structured list' }] : []),
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
- ...(constellation ? [{ kind: 'command', id: 'fit', label: 'Fit the whole graph', hint: 'Zoom out to everything' }] : []),
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 !== 'constellation') backToConstellation().then(() => select(r.id)); else select(r.id); root.querySelector('#card-title')?.focus() }
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') constellation?.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 constellation?.refresh()
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; 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))
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') backToConstellation(); else select(null) }
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 === 'constellation') openWorkbench({ kind: 'record', id: state.selected })
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: constellation ? constellation.camera.getState() : null, draftStatus: store.status, draftPersistence: store.persistence } },
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(); constellation?.kill(); clear(root); root.classList.remove('atelier-gw') },
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 coach or owner builds on top of
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 constellation. Positions are presentation only:
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 cohort coach adds or adjusts,
2
- // and the owner has the final say. A recipe is data that chooses the room, the lens
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(['constellation', 'workbench', 'review'])
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', 'coach', 'owner'])
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: 'constellation', lens: null, subject: null, primary: 'search', description: 'Wander the whole graph. Zones on, search ready.' },
23
- { id: 'question', label: 'Answer a question', room: 'constellation', lens: 'question', subject: null, primary: 'open-evidence', description: 'Light the records that declare a question’s concepts.' },
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 = [], owner = [] } = {}) {
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], ['coach', coach], ['owner', owner]]) {
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 entry of list) {
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 constellation,
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 !== 'constellation' && next.lens) throw new TypeError(`${layer} recipe ${entry.id}: only a constellation recipe takes a lens`)
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
- // A constellation draws every readable record while that stays legible and fast.
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 planConstellation(model, budget = DEFAULT_BUDGET) {
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 coach or
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 (coach, then owner). Each layer is validated before it is applied. */
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'