@restless-dreams/orbit 0.0.0-stage → 0.1.0

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -2
  3. package/dist/bin/orbit.js +8907 -0
  4. package/dist/bin/orbit.js.map +48 -0
  5. package/dist/chunk-0gwh723s.js +408 -0
  6. package/dist/chunk-0gwh723s.js.map +10 -0
  7. package/dist/chunk-7n00wx0t.js +82 -0
  8. package/dist/chunk-7n00wx0t.js.map +10 -0
  9. package/dist/chunk-mtrnm0yc.js +16993 -0
  10. package/dist/chunk-mtrnm0yc.js.map +63 -0
  11. package/dist/core/auto-render.d.ts +85 -0
  12. package/dist/core/browser.d.ts +23 -0
  13. package/dist/core/create.d.ts +23 -0
  14. package/dist/core/diagnostics.d.ts +40 -0
  15. package/dist/core/dispatch.d.ts +23 -0
  16. package/dist/core/document-options.d.ts +18 -0
  17. package/dist/core/document-source.d.ts +128 -0
  18. package/dist/core/editing.d.ts +63 -0
  19. package/dist/core/engine/animation-clock.d.ts +40 -0
  20. package/dist/core/engine/camera.d.ts +70 -0
  21. package/dist/core/engine/colors.d.ts +51 -0
  22. package/dist/core/engine/display.d.ts +125 -0
  23. package/dist/core/engine/export.d.ts +32 -0
  24. package/dist/core/engine/glide.d.ts +34 -0
  25. package/dist/core/engine/labels.d.ts +106 -0
  26. package/dist/core/engine/node-drag.d.ts +49 -0
  27. package/dist/core/engine/scene.d.ts +159 -0
  28. package/dist/core/engine/spin.d.ts +62 -0
  29. package/dist/core/engine/surface-pattern.d.ts +20 -0
  30. package/dist/core/engine/text-measure.d.ts +116 -0
  31. package/dist/core/engine/views.d.ts +353 -0
  32. package/dist/core/extensions.d.ts +155 -0
  33. package/dist/core/format-upgrades.d.ts +33 -0
  34. package/dist/core/index.d.ts +36 -0
  35. package/dist/core/input-controller.d.ts +109 -0
  36. package/dist/core/languages.d.ts +199 -0
  37. package/dist/core/layout.d.ts +61 -0
  38. package/dist/core/map-tools/definitions.d.ts +71 -0
  39. package/dist/core/map-tools/input.d.ts +62 -0
  40. package/dist/core/map-tools/reading.d.ts +40 -0
  41. package/dist/core/map-tools/tools.d.ts +51 -0
  42. package/dist/core/markdown-writing.d.ts +87 -0
  43. package/dist/core/material.d.ts +25 -0
  44. package/dist/core/media.d.ts +42 -0
  45. package/dist/core/node-attributes.d.ts +31 -0
  46. package/dist/core/node-links.d.ts +25 -0
  47. package/dist/core/parser-markdown.d.ts +42 -0
  48. package/dist/core/parser-yaml.d.ts +8 -0
  49. package/dist/core/render-to-svg.d.ts +57 -0
  50. package/dist/core/serializer-markdown.d.ts +18 -0
  51. package/dist/core/serializer-yaml.d.ts +2 -0
  52. package/dist/core/source-patch.d.ts +24 -0
  53. package/dist/core/source.d.ts +27 -0
  54. package/dist/core/styles.d.ts +6 -0
  55. package/dist/core/suggest.d.ts +8 -0
  56. package/dist/core/svg-renderer.d.ts +729 -0
  57. package/dist/core/text-layout.d.ts +54 -0
  58. package/dist/core/themes.d.ts +62 -0
  59. package/dist/core/types.d.ts +463 -0
  60. package/dist/core/ui/actions.d.ts +75 -0
  61. package/dist/core/ui/controls.d.ts +61 -0
  62. package/dist/core/ui/detail-sheet.d.ts +88 -0
  63. package/dist/core/ui/frames.d.ts +11 -0
  64. package/dist/core/ui/icons.d.ts +14 -0
  65. package/dist/core/ui/inline-editor.d.ts +79 -0
  66. package/dist/core/ui/media-players.d.ts +65 -0
  67. package/dist/core/ui/media-viewer.d.ts +169 -0
  68. package/dist/core/ui/media-zoom.d.ts +51 -0
  69. package/dist/core/ui/note-markdown.d.ts +19 -0
  70. package/dist/core/ui/outline.d.ts +44 -0
  71. package/dist/core/url-guard.d.ts +40 -0
  72. package/dist/core/utils.d.ts +23 -0
  73. package/dist/core/web-page/page.d.ts +58 -0
  74. package/dist/core/web-page/runtime.d.ts +13 -0
  75. package/dist/core/web-page/start.d.ts +1 -0
  76. package/dist/core/yaml-mini.d.ts +54 -0
  77. package/dist/index.d.ts +1 -0
  78. package/dist/index.js +1448 -0
  79. package/dist/index.js.map +17 -0
  80. package/dist/marked/index.d.ts +31 -0
  81. package/dist/marked/index.js +110 -0
  82. package/dist/marked/index.js.map +10 -0
  83. package/dist/react/index.d.ts +55 -0
  84. package/dist/react/index.js +352 -0
  85. package/dist/react/index.js.map +1 -0
  86. package/dist/web-page.d.ts +2 -0
  87. package/dist/web-page.js +130 -0
  88. package/llms-full.txt +2585 -0
  89. package/llms.txt +35 -0
  90. package/package.json +118 -4
  91. package/skills/orbit/SKILL.md +140 -0
  92. package/skills/orbit/references/editing.md +194 -0
  93. package/skills/orbit/references/extending.md +247 -0
  94. package/skills/orbit/references/format.md +303 -0
  95. package/skills/orbit/references/guide.md +522 -0
  96. package/skills/orbit/references/languages.md +159 -0
  97. package/skills/orbit/references/map-tools.md +188 -0
  98. package/skills/orbit/references/media.md +160 -0
  99. package/skills/orbit/references/recipes.md +227 -0
  100. package/skills/orbit/references/theming.md +184 -0
package/llms-full.txt ADDED
@@ -0,0 +1,2585 @@
1
+ # Orbit
2
+
3
+ > Mind maps written in markdown and explored like a solar system. The node in focus is the sun, its children orbit it as planets and their children are moons; readers click a planet to fly into it, zoom, and flick the orbit to spin it. Framework-agnostic and dependency-free, with a web component, React and marked integrations, SVG export without a browser, and a command line.
4
+
5
+ - Install with `npm install @restless-dreams/orbit`. The package is ESM-only. React (`@restless-dreams/orbit/react`) and marked (`@restless-dreams/orbit/marked`) are optional peers.
6
+ - A map fills its container: always give the container a height.
7
+ - Write maps in orbit-md, Orbit's markdown (every heading and list item is a node, text under it is its note, `<!-- orbit key: value -->` comments set attributes), or in YAML (`nodes:` with `text` and `children`).
8
+ - Check a map with `npx @restless-dreams/orbit validate --json map.md`: every problem comes with a suggested fix, and its line where it has one. `npx @restless-dreams/orbit example` prints a map with the main features, and `orbit convert --to yaml map.md` (or `--to md`) turns one flavor into the other.
9
+ - Draw one with `createOrbitMap(element, markdown, options)`, the `<orbit-map>` element, `<OrbitMindMap source={markdown} />` from `@restless-dreams/orbit/react`, or `renderToSVGString(markdown, options)` without a browser.
10
+ - Share a map as one web page, which opens in any browser, offline: `npx @restless-dreams/orbit export --html map.md --out map.html`.
11
+ - Let people build the map on the map with `editable: true` (a + in the orbit, text typed inside nodes), and save what they make with `map.toMarkdown()` on the `edit` event.
12
+ - Let a language model read and change a map with `createMapTools(map)`: `tools.for('anthropic' | 'openai' | 'openai-chat' | 'gemini')` lists the tools for its API, and `tools.run(name, input).text` is each call's result.
13
+ - Maps speak the page's language (`lang`): English and Spanish are built in, `registerLanguage` adds more.
14
+ - Themes: `paper` (default), `night`, `glass`, `glass-night`, `cosmos`, `blueprint`, `ink`, `contrast`, `flat`, `flat-night`, `material`, `material-night`, `liquid`, `liquid-night`, `auto`. Pass `theme: 'night'` or `theme: { preset: 'night', tokens: { … } }`, or set `--orbit-*` CSS custom properties.
15
+
16
+ ## Docs
17
+
18
+ - [Map format](https://github.com/restless-dreams/orbit/blob/main/docs/format.md): headings, lists, notes, media, every attribute and document option, the YAML flavor, and every diagnostic code
19
+ - [README](https://github.com/restless-dreams/orbit/blob/main/README.md): install and a quick start for each way to draw a map
20
+ - [Guide](https://github.com/restless-dreams/orbit/blob/main/docs/guide.md): every renderer option, event and method, the React component, the element, marked, pictures and videos, SVG export and the command line
21
+ - [Recipes](https://github.com/restless-dreams/orbit/blob/main/docs/recipes.md): plain HTML, React and Next.js, Vue, Svelte, Astro, remark and rehype, static sites
22
+ - [Theming](https://github.com/restless-dreams/orbit/blob/main/docs/theming.md): the fourteen themes, Material themes from any color, and every CSS custom property
23
+ - [Extending](https://github.com/restless-dreams/orbit/blob/main/docs/extending.md): node types, your own actions, status words and named themes, for one map or every map
24
+ - [Editing](https://github.com/restless-dreams/orbit/blob/main/docs/editing.md): context menus on a long press, adding, renaming, moving and removing nodes on the map, and saving the result as orbit-md
25
+ - [Map tools for AI](https://github.com/restless-dreams/orbit/blob/main/docs/map-tools.md): tools for any model to read and change a map, on screen or on a server, for each provider's API and MCP
26
+ - [Languages](https://github.com/restless-dreams/orbit/blob/main/docs/languages.md): maps in the page's language, English and Spanish built in, adding languages and changing single words
27
+ - [Pictures and videos](https://github.com/restless-dreams/orbit/blob/main/docs/media.md): the media viewer, its keys, YouTube and Vimeo, sound and autoplay, and opening, playing and following media from code
28
+ - [Coding agents](https://github.com/restless-dreams/orbit/blob/main/docs/agents.md): the skill, validation as JSON, and messages for setup mistakes
29
+ - [Everything in one file](https://cdn.jsdelivr.net/npm/@restless-dreams/orbit/llms-full.txt): the README and all of the guides above, for the latest release
30
+ - [Website](https://orbit.hyuchia.com): live examples, the themes side by side, setup for each integration, and a playground to write a map and explore it in the browser
31
+
32
+ ## Optional
33
+
34
+ - [Accessibility](https://github.com/restless-dreams/orbit/blob/main/docs/accessibility.md): keyboard, screen readers, reduced motion and contrast
35
+ - [API reference](https://github.com/restless-dreams/orbit/blob/main/docs/api/README.md): every export, generated from the types
36
+
37
+ ---
38
+
39
+ <!-- README.md -->
40
+
41
+ <img src="docs/media/logo.svg" alt="Orbit's logo: a golden sun with six colorful planets on a dotted orbit" width="96">
42
+
43
+ # Orbit
44
+
45
+ Mind maps written in markdown and explored like a solar system.
46
+
47
+ ![A map of a product roadmap: the orbit spins, the camera flies into a branch and out to the overview of the whole map](docs/media/tour.webp)
48
+
49
+ **[Website](https://orbit.hyuchia.com)** · [Playground](https://orbit.hyuchia.com/playground) · [Guide](docs/guide.md) · [Map format](docs/format.md) · [Recipes](docs/recipes.md) · [Theming](docs/theming.md) · [Extending](docs/extending.md) · [Editing](docs/editing.md) · [Map tools for AI](docs/map-tools.md) · [Pictures and videos](docs/media.md) · [Languages](docs/languages.md) · [Accessibility](docs/accessibility.md) · [Coding agents](docs/agents.md) · [API reference](docs/api/README.md)
50
+
51
+ - Real-markdown authoring: headers and bullet lists nest the tree.
52
+ - YAML alternative for app-generated maps.
53
+ - Explorable: zoom into a node to enter it and out to leave, flick the orbit to spin it, pan and zoom the overview. Every move is animated so you never lose your place.
54
+ - Readable at any size: labels stay crisp, sized for reading and in high contrast.
55
+ - Fourteen themes, from pastel paper and a field of stars to flat, Material and liquid glass, each restylable with CSS custom properties, and Material themes built from any color.
56
+ - First-class pictures, videos, embeds and multi-line notes per node: pictures and videos open over the whole window, to zoom into and to play, and the rest in a built-in detail sheet, unless you handle `onAction` yourself.
57
+ - Built on the map: a + in the orbit adds ideas, typed right inside their planet, and nodes can be renamed, dragged and saved back as markdown.
58
+ - Ready for AI: tools any model calls to read a map and change it, for Anthropic, OpenAI, Gemini, MCP and the providers that follow OpenAI's API, with every change animated on the map.
59
+ - Keyboard and screen-reader friendly: one tab stop, arrow-key navigation, a list view of the whole map, and announcements as you move, in English, Spanish or any language you add.
60
+ - Framework-agnostic core with no dependencies; React wrapper at `@restless-dreams/orbit/react`; `marked` extension at `@restless-dreams/orbit/marked`.
61
+ - Made for coding agents too: the docs in one file (`llms-full.txt`), an agent skill, validation as JSON, and problems reported with the fix.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ npm install @restless-dreams/orbit
67
+ ```
68
+
69
+ React and marked are optional peers, needed only for their integrations: `npm install react react-dom` for `@restless-dreams/orbit/react`, and `npm install marked` for `@restless-dreams/orbit/marked`.
70
+
71
+ ## Quick start
72
+
73
+ ```ts
74
+ import { createOrbitMap } from '@restless-dreams/orbit';
75
+
76
+ const map = createOrbitMap(document.getElementById('map')!, `# 🚀 Project
77
+
78
+ ## Research
79
+ ## Build
80
+ ## Launch`);
81
+
82
+ map.on('focus-change', ({ node }) => console.log('Now looking at', node.text));
83
+ ```
84
+
85
+ The map fills its container, so give the container a size.
86
+
87
+ **React:**
88
+
89
+ ```tsx
90
+ import { OrbitMindMap } from '@restless-dreams/orbit/react';
91
+
92
+ export const ProjectMap = ({ markdown }: { markdown: string }) => {
93
+ return (
94
+ <div style={{ height: 480 }}>
95
+ <OrbitMindMap source={markdown} onNodeClick={(node) => console.log(node.text)} />
96
+ </div>
97
+ );
98
+ };
99
+ ```
100
+
101
+ **Without a build step,** with the `<orbit-map>` element:
102
+
103
+ ```html
104
+ <script type="module">
105
+ import { defineOrbitMapElement } from 'https://cdn.jsdelivr.net/npm/@restless-dreams/orbit/+esm';
106
+ defineOrbitMapElement();
107
+ </script>
108
+
109
+ <orbit-map height="420">
110
+ # 🚀 Project
111
+
112
+ ## Research <!-- orbit status: active -->
113
+ ## Build
114
+ </orbit-map>
115
+ ```
116
+
117
+ **In a markdown pipeline,** with marked:
118
+
119
+ ```ts
120
+ import { Marked } from 'marked';
121
+ import { orbitMarked, hydrateOrbitBlocks } from '@restless-dreams/orbit/marked';
122
+
123
+ const markdown = new Marked(orbitMarked());
124
+ container.innerHTML = await markdown.parse(article); // orbit fences become map placeholders
125
+ hydrateOrbitBlocks(container); // turns them into live maps
126
+ ```
127
+
128
+ **Without a browser,** `renderToSVGString(markdown, { width: 1200, height: 800 })` draws a standalone SVG, for static sites, emails and print.
129
+
130
+ [The guide](docs/guide.md) covers every option, event and method, and [Recipes](docs/recipes.md) shows Orbit in Vue, Svelte, Astro, Next.js and static sites.
131
+
132
+ ## Write a map
133
+
134
+ Every heading and every list item is a node, the text under it is its note, and `<!-- orbit … -->` comments set attributes:
135
+
136
+ ```markdown
137
+ ---
138
+ title: Product roadmap
139
+ theme: night
140
+ ---
141
+ # 🚀 Product roadmap
142
+
143
+ ## Research <!-- orbit status: done -->
144
+ Six weeks of interviews before we commit to scope.
145
+ - [x] User interviews
146
+ - Competitor teardown #market
147
+
148
+ ## Build <!-- orbit status: active -->
149
+ ### Frontend
150
+ - [/] App shell
151
+ - Offline sync
152
+ ### Backend
153
+
154
+ ## Launch
155
+ ![The launch poster](images/poster.png)
156
+ ```
157
+
158
+ Maps can be YAML too, for the ones programs write. [The map format](docs/format.md) has every rule, attribute, option and diagnostic, and `npx @restless-dreams/orbit example` prints a map with the main features.
159
+
160
+ ## Themes
161
+
162
+ ![The fourteen themes: paper, night, glass, glass night, cosmos, blueprint, ink, contrast, flat, flat night, material, material night, liquid and liquid night](docs/media/themes.webp)
163
+
164
+ Pick one with the `theme` option, the `theme` attribute or `theme:` in the frontmatter. Every color, size and finish is a CSS custom property your page can set, and `materialTheme('#0B57D0')` builds a Material theme from any color. See [Theming](docs/theming.md).
165
+
166
+ ## Command line
167
+
168
+ ```bash
169
+ npx @restless-dreams/orbit validate my-map.md # every problem as my-map.md:line: severity: message [code]
170
+ npx @restless-dreams/orbit validate --json my-map.md # the same as JSON, for scripts and coding agents
171
+ npx @restless-dreams/orbit validate --tree my-map.md # the map as Orbit reads it, with each node's id
172
+ npx @restless-dreams/orbit render my-map.md --out my-map.svg # draw a map to SVG (--view all for the whole map)
173
+ npx @restless-dreams/orbit export --html my-map.md --out my-map.html # one web page to explore or present the map, offline
174
+ npx @restless-dreams/orbit convert --to yaml my-map.md # markdown ↔ YAML (either direction)
175
+ npx @restless-dreams/orbit example > my-map.md # a map with the main features (--yaml for YAML)
176
+ ```
177
+
178
+ ## AI in your app
179
+
180
+ `createMapTools(map)` gives a language model tools to read the map and change it, in the shape your provider's API takes. Its changes animate on the map, and a call it gets wrong changes nothing and says what to send instead:
181
+
182
+ ```ts
183
+ import { createMapTools } from '@restless-dreams/orbit';
184
+
185
+ const tools = createMapTools(map); // a map on screen, or a document on a server
186
+ tools.for('anthropic'); // the tools for the model: also 'openai', 'openai-chat' and 'gemini'
187
+ tools.run('add_nodes', { parent_id: 'research', outline: '- Interviews\n- Survey' }); // each call it makes: { ok, text, changed }
188
+ ```
189
+
190
+ [Map tools for AI](docs/map-tools.md) has the whole loop for each provider.
191
+
192
+ ## Coding agents
193
+
194
+ Point your agent at `node_modules/@restless-dreams/orbit/llms-full.txt`, the docs in one file, or install the skill for Codex, Claude Code, Kimi Code and other agents with `npx skills add restless-dreams/orbit`. [Coding agents](docs/agents.md) has the details.
195
+
196
+ ## Browsers
197
+
198
+ Orbit is made for current browsers, and draws in older ones down to Chromium 99, which some phones' WebViews still run, with plainer versions of the few effects that need newer CSS ([which ones](docs/guide.md#browsers)).
199
+
200
+ ## Contributing
201
+
202
+ With [Bun](https://bun.sh) installed, `bun install`, then `bun run verify` runs the linter, the type checks, the tests and the build. [CONTRIBUTING.md](CONTRIBUTING.md) has the rest, [RELEASE.md](RELEASE.md) how releases are made, and [docs/architecture.md](docs/architecture.md) how Orbit is put together.
203
+
204
+ ## License
205
+
206
+ [MIT](LICENSE)
207
+
208
+ ---
209
+
210
+ <!-- docs/guide.md -->
211
+
212
+ # Using Orbit
213
+
214
+ How to draw maps on any page, in React, with the `<orbit-map>` element or in a markdown pipeline, and everything a map offers: its options and events, how people move around it, pictures and videos, editing, export and theming. [The map format](format.md) is the reference for writing maps, and [Recipes](recipes.md) shows Orbit in each framework.
215
+
216
+ ## Drawing a map
217
+
218
+ ```ts
219
+ import { createOrbitMap } from '@restless-dreams/orbit';
220
+
221
+ const map = createOrbitMap(document.getElementById('map')!, `# 🚀 Project
222
+
223
+ ## Research
224
+ ## Build
225
+ ## Launch`);
226
+
227
+ map.on('focus-change', ({ node }) => console.log('Now looking at', node.text));
228
+ ```
229
+
230
+ The map fills its container, so give the container a size. `createOrbitMap` returns the renderer: call `map.render(newMarkdown)` to draw a new version (the reader keeps their place), and `map.destroy()` when you remove it. Options passed in win over the document's frontmatter; `documentToRendererOptions(doc.options)` shows what a document asks for. `new OrbitSVGRenderer(element, options)` plus `renderer.render(source)` is the same thing in two steps.
231
+
232
+ ## React
233
+
234
+ ```tsx
235
+ import { OrbitMindMap } from '@restless-dreams/orbit/react';
236
+
237
+ const markdown = `# Project
238
+
239
+ ## Research
240
+ ## Build`;
241
+
242
+ export const ProjectMap = () => {
243
+ return (
244
+ <div style={{ height: 480 }}>
245
+ <OrbitMindMap source={markdown} onNodeClick={(node) => console.log(node.text)} />
246
+ </div>
247
+ );
248
+ };
249
+ ```
250
+
251
+ `OrbitMindMap` fills its parent, so give the parent a size. Pass the map as `source` (markdown or YAML), as a parsed `document`, or as a node tree in `data`. Inline callbacks and inline `theme` / `palette` objects are fine: the map only redraws when their values change.
252
+
253
+ - **Drive it from code** with a ref: `ref.current.flyTo(id)`, `overview()`, `setViewMode(mode)`, `zoomBy(factor)`, `fit()`, and `ref.current.renderer` for everything else.
254
+ - **Control the focus** with `focusedId` and `onFocusChange`, like a controlled input.
255
+ - **Server rendering:** the component is marked `"use client"` for React Server Components. On the server and in the first render it draws a static SVG of the map, so the page shows it before JavaScript runs; the live map replaces it on mount (`staticFallback={false}` turns that off).
256
+
257
+ ## Web component
258
+
259
+ ```ts
260
+ import { defineOrbitMapElement } from '@restless-dreams/orbit';
261
+ defineOrbitMapElement();
262
+ ```
263
+
264
+ ```html
265
+ <orbit-map height="420" view-mode="focus">
266
+ # 🚀 Project
267
+
268
+ ## Research <!-- orbit status: active -->
269
+ ## Build
270
+ </orbit-map>
271
+ ```
272
+
273
+ Indent the content however you like, and `<!-- orbit … -->` comments and frontmatter work inside the element. The map renders in a shadow root, so the raw markdown is never shown. You can also put the source in a `<script type="text/orbit">` child or a `source` attribute.
274
+
275
+ - **Size:** the element is a block, 420 px tall by default. Use the `height` attribute, `height` in the frontmatter, or plain CSS (`orbit-map { height: 60vh }`, which wins over both).
276
+ - **Attributes:** `view-mode`, `theme`, `palette` (comma-separated), `max-depth`, `interactive`, `animation`, `editable`. Changing one updates the map in place.
277
+ - **Updates:** editing the element's content or its `source` property redraws the map, and the reader keeps their place. `element.flyTo(id)` and `element.overview()` move the map, `element.openMedia(id)` shows a node's picture or plays its video (see [Pictures and videos](#pictures-and-videos)), and `element.renderer` is the underlying `OrbitSVGRenderer`.
278
+ - **Events:** the map's events bubble out of the element (see Events below).
279
+ - **Errors:** a map that can't be drawn shows an error card with the line number and fires an `orbit-error` event (`event.detail.message`, `.line`, `.diagnostics`).
280
+
281
+ To avoid a flash of raw markdown before the script loads, hide undefined elements: `orbit-map:not(:defined) { visibility: hidden; }`.
282
+
283
+ ## Markdown pipelines (marked)
284
+
285
+ ```ts
286
+ import { Marked } from 'marked';
287
+ import { orbitMarked, hydrateOrbitBlocks } from '@restless-dreams/orbit/marked';
288
+
289
+ const markdown = new Marked(orbitMarked());
290
+ container.innerHTML = await markdown.parse(article); // orbit fences become map placeholders
291
+ hydrateOrbitBlocks(container); // turns them into live maps
292
+ ```
293
+
294
+ Fences can carry display options: ```` ```orbit height=480 theme=glass view=all ````. Each map fills its placeholder, which is 420 px tall unless the fence's `height` or the map's frontmatter says otherwise; CSS that gives the placeholder a height wins over both. Until a placeholder hydrates (or when JavaScript is off), it contains a nested list of the map, so the content stays readable and indexable. `renderOrbitMarkdown(markdown)` renders with a private instance in one call; `configureMarked(instance)` registers the extension on an existing instance (the global `marked` by default). Neither touches the global `marked` unless you pass it.
295
+
296
+ ## Format
297
+
298
+ Orbit reads two flavors: orbit-md, its markdown, and YAML. The dispatcher (`parseOrbit`) tells them apart. [The map format](format.md) has every rule, attribute, option and diagnostic; `npx @restless-dreams/orbit example` prints a map that uses all of them.
299
+
300
+ ### orbit-md, the markdown flavor (recommended for maps people write)
301
+
302
+ Every heading and every list item is a node; everything else belongs to the node above it. Headers (`#`–`######`, or lines underlined with `===` and `---`) nest by level, and every list under a heading is its children, as deep as you indent. Checkboxes set a status: `- [ ] Draft` is `todo`, `- [x] Ship` is `done`, `- [/] Build` is `active`. Hashtags at the end of a title are tags: `- Offline sync #frontend`.
303
+
304
+ A document can also be just a list. A single top item becomes the root; with several, the root is named after the frontmatter `title`, a one-line paragraph above the list, or "Untitled map". Several `#` headings share a root the same way:
305
+
306
+ ```markdown
307
+ Project plan
308
+ - Research
309
+ - Build
310
+ - Launch
311
+ ```
312
+
313
+ ```markdown
314
+ # 🚀 Project Launch
315
+
316
+ ## Research
317
+ - Market analysis
318
+ - User interviews
319
+
320
+ ## Build
321
+
322
+ ### Frontend
323
+ ### Backend
324
+
325
+ ## Launch
326
+ ```
327
+
328
+ Image, video, and embed:
329
+
330
+ ```markdown
331
+ ## Mountain Trip
332
+
333
+ ![Sunrise from the summit](photos/sunrise.jpg)
334
+
335
+ We left at 5am to catch the sunrise.
336
+ ```
337
+
338
+ Text under a heading or a list item is its note: paragraphs, quotes, code. The
339
+ list after it is still the children. Under a list item, indent the note, a
340
+ picture or an attribute block, as you would under a heading. To keep a bullet
341
+ list inside a note, quote it with `>`:
342
+
343
+ ```markdown
344
+ ## Build
345
+ Currently focused on the frontend. Starts when [Research](#research) is done.
346
+
347
+ - Frontend <!-- orbit status: active -->
348
+ Vite + React. Ships behind the beta flag.
349
+ - Backend
350
+ ![Backend diagram](diagrams/backend.png)
351
+ > - Billing
352
+ > - Search
353
+
354
+ ## Research
355
+ ```
356
+
357
+ A `#id` link, like `[Research](#research)` above, flies to that node of the map.
358
+
359
+ Per-node attributes go in HTML comments that start with `orbit`. Other comments are hidden, as markdown hides them:
360
+
361
+ ```markdown
362
+ ## Build
363
+ <!-- orbit
364
+ color: "#F5A3A8"
365
+ status: active
366
+ priority: 1
367
+ tags: [frontend, sprint-3]
368
+ -->
369
+ ```
370
+
371
+ Document-level frontmatter sets renderer options:
372
+
373
+ ```markdown
374
+ ---
375
+ view: focus
376
+ palette: ["#F8D978", "#F5A3A8", "#A8DDB5"]
377
+ baseUrl: /docs/assets/
378
+ ---
379
+ # Project
380
+ ```
381
+
382
+ ### YAML flavor (for app-generated maps)
383
+
384
+ ```yaml
385
+ title: My Project
386
+ view: focus
387
+ nodes:
388
+ - text: Project
389
+ icon: 🚀
390
+ children:
391
+ - text: Research
392
+ tags: [discovery]
393
+ children: [Market analysis, User interviews] # scalar shorthand
394
+ - text: Build
395
+ status: active
396
+ note: |
397
+ Currently focused on the frontend.
398
+ Targeting end-of-quarter ship.
399
+ ```
400
+
401
+ Plain strings in `children` are shorthand for `{ text: ... }` leaf nodes.
402
+
403
+ ### Document options
404
+
405
+ Frontmatter (markdown) or top-level keys (YAML): `title` (used as the SVG's
406
+ accessible label), `view`, `theme`, `showRings`, `interactive`, `animation`,
407
+ `speed`, `palette`, `height`, `maxDepth` (levels below the root to render;
408
+ `1` = root + direct children), `className`, `baseUrl`, `schemaVersion` (the
409
+ format version the document is written for, `1`). Keys Orbit doesn't know are
410
+ kept and written back, and saving keeps a file as it was written: only the
411
+ ideas an edit changed are written again (see
412
+ [What saving keeps](format.md#what-saving-keeps)).
413
+
414
+ ### Problems in a document
415
+
416
+ Parsing is forgiving. When something is wrong but recoverable (an unknown frontmatter key, a second `#` root, an attribute block that isn't valid YAML, an image without a URL), the parser works around it and records a diagnostic with a code, the line it's on and a message that says how to fix it (`Unknown frontmatter key "rings" (ignored). Did you mean "showRings"?`):
417
+
418
+ ```ts
419
+ const doc = parseOrbit(source);
420
+
421
+ for (const problem of doc.diagnostics ?? []) {
422
+ console.log(problem.line, problem.code, problem.message);
423
+ }
424
+ ```
425
+
426
+ Pass `{ strict: true }` to throw an `OrbitParseError` (with `code` and `line`) on the first problem instead. `orbit validate` lists every problem and fails until there are none. When a map rendered through `<orbit-map>` or `renderOrbitMarkdownBlocks` can't be drawn at all, it shows an error card and dispatches an `orbit-error` event; the other maps on the page still render.
427
+
428
+ ### Round-tripping
429
+
430
+ `serializeOrbitMarkdown` / `serializeOrbitYAML` produce documents that parse back to the same tree. The markdown reads the way people write maps:
431
+ - **Headings and lists:** the root and its children are headings, and everything below them is a nested list.
432
+ - **Titles:** statuses on list items are checkboxes (`[ ]`, `[/]`, `[x]`) and simple tags are hashtags; titles are escaped only where markdown would misread them.
433
+ - **Attributes:** short ones go on the node's line, `<!-- orbit color: "#E8A33D", priority: 2 -->`: up to three, or more while the comment stays under 80 characters (`<!-- orbit type: goal, target: 5, current: 2, unit: apples -->`). The rest go in a comment block below it.
434
+ - **Notes and media:** notes are plain paragraphs, quoted only when their lines would read as nodes, and pictures and videos are lines of their own.
435
+
436
+ A map written in that style comes back unchanged.
437
+
438
+ ```ts
439
+ import { parseOrbit, serializeOrbitMarkdown, serializeOrbitYAML } from '@restless-dreams/orbit';
440
+
441
+ const doc = parseOrbit(source);
442
+ const asYaml = serializeOrbitYAML(doc);
443
+ const backToMarkdown = serializeOrbitMarkdown(parseOrbit(asYaml));
444
+ ```
445
+
446
+ ## Action API
447
+
448
+ Wire your own behavior on click:
449
+
450
+ ```ts
451
+ const renderer = new OrbitSVGRenderer(host, {
452
+ onAction: ({ node, action }) => {
453
+ if (action === 'image') {
454
+ openMyLightbox(node.image!.src);
455
+ }
456
+
457
+ if (action === 'note') {
458
+ openMyDrawer(node.note!);
459
+ }
460
+
461
+ return false; // skip Orbit's own viewer and detail sheet
462
+ },
463
+ });
464
+ ```
465
+
466
+ The focused node shows a button for each kind of content it has, then your own [actions](extending.md#actions). They sit under it, or, where its planets crowd it (as they do on a phone), under the whole orbit, which is then fitted to leave them room: they never cover a planet. When `onAction` isn't provided (or doesn't return `false`), "View image" and "Play video" open the [media viewer](#pictures-and-videos), and the other buttons the built-in detail sheet: a panel along the right edge on wide screens and a bottom sheet on phones (swipe down to close), with the node's media, note, status and tags, and arrows to step through its siblings. YouTube and Vimeo links play through their embeddable players (`youtube-nocookie.com/embed/…`, `player.vimeo.com/video/…`). Links open in a new tab.
467
+
468
+ Notes are markdown and are rendered safely (no raw HTML). Opt-out flags: `showActionButtons: false`, `showDetailSheet: false` and `mediaViewer: false`. Custom note rendering: `noteRenderer: (note, ctx) => '<p>...</p>'`. The HTML it returns is used as is, so sanitize it if the note content is untrusted.
469
+
470
+ ## Pictures and videos
471
+
472
+ "View image" and "Play video" open the media viewer, over the whole window, even when the map sits in a small box. It follows the map's theme, light or dark.
473
+
474
+ - **Pictures** fit the window. The wheel, a pinch, a double click or tap, the `+`, `−` and `0` keys and the zoom buttons zoom around the point under the pointer, and a zoomed picture pans with a drag.
475
+ - **Videos** start at once: files in the browser's own player, YouTube and Vimeo in theirs.
476
+ - **Every picture and video of the map,** in reading order: the previous and next buttons, the arrow keys or a swipe step through them, and a counter says where you are.
477
+ - **Around them,** the node's title, the caption and the picture's description, with a link to the picture or to the video's page. Esc, a click around the media or a swipe down closes the viewer, and focus goes back where it was.
478
+
479
+ The detail sheet's picture opens the viewer too, and its video has a button that moves it there. `mediaViewer: false` keeps pictures and videos in the detail sheet, and `onAction` returning `false` (or the `action` event's `preventDefault()`) skips both, as for any button.
480
+
481
+ A page can drive the viewer itself, to show a picture big or play a video on a screen of its own, such as an audience's while someone presents:
482
+
483
+ ```ts
484
+ map.openMedia('summit'); // a node's picture, or its video
485
+ map.openMedia('trail', { kind: 'video', autoplay: false }); // its video, ready to play
486
+ map.openMedia('trail', { kind: 'video', startAt: 42 }); // its video, playing from 0:42
487
+ map.playMedia();
488
+ map.seekMedia(42);
489
+ map.pauseMedia();
490
+ map.unmuteMedia(); // from a press, for a video a browser started muted
491
+ map.closeMedia();
492
+ map.on('media-state', ({ node, playing, time }) => tellThePresenter(node.id, playing, time));
493
+ ```
494
+
495
+ `openMedia` works whatever `mediaViewer` and `showDetailSheet` say. On a map that isn't interactive, the viewer is the page's to drive: no buttons, no video controls, and no input of its own. [Pictures and videos](media.md) covers the keys, YouTube and Vimeo, sound and autoplay, and the events.
496
+
497
+ ## Extending
498
+
499
+ Give nodes a type, add your own buttons, name your own themes and status words. Pass them to one map, or register them once for every map, `<orbit-map>` elements and markdown pages included:
500
+
501
+ ```ts
502
+ import { createOrbitMap, registerTheme } from '@restless-dreams/orbit';
503
+
504
+ registerTheme('brand', { preset: 'night', tokens: { 'color-root': '#FFD166' } });
505
+
506
+ createOrbitMap(element, '# Launch\n## v1.0 beta <!-- orbit type: milestone, status: review -->', {
507
+ theme: 'brand',
508
+ nodeTypes: {
509
+ milestone: { label: 'Milestone', icon: '🏁', color: '#F59E0B', details: (node, element) => { element.textContent = 'Due Friday'; } },
510
+ },
511
+ actions: [{ id: 'assign', label: 'Assign', icon: '👤', run: (node) => openAssignDialog(node) }],
512
+ statuses: { review: 'active' },
513
+ });
514
+ ```
515
+
516
+ A node type sets its nodes' icon, color and theme tokens, can draw inside them (`render`), add a section to the detail sheet (`details`) and bring its own actions. `nodeTypeFor` gives nodes that name no type one to be drawn as, for display only: it's never written to the map. [Extending](extending.md) covers every option.
517
+
518
+ ## Editing on the map
519
+
520
+ Let people build a map on the map itself. With `editable`, a **+** in the orbit adds an idea, typed right inside its new planet. Enter adds it and opens the next one, so ideas go in one after another. A long press, a right click or F2 edits a node's text where it is, and a node held still lifts, to be dragged onto another node or to a new place in the orbit:
521
+
522
+ ```ts
523
+ const map = createOrbitMap(element, markdown, {
524
+ editable: true,
525
+ onEdit: () => save(map.toMarkdown()),
526
+ });
527
+ ```
528
+
529
+ With `editingKeys` too, the keyboard builds maps as in mind-map apps: Tab adds an idea under the node with keyboard focus, Shift+Enter beside it, and Delete removes it; while typing, Tab and Shift+Tab go a level down and up.
530
+
531
+ For a menu of your own, `onNodeContext` reports the long press or right click and where it happened, and `onNodeDrag` when the press moves on to drag the node, the moment to close the menu. `editNode(id)`, `editNewNode(parentId, index?)`, `addNode`, `updateNode`, `removeNode` and `moveNode` then change the map, animating each edit and firing `edit`. `toMarkdown()` writes the edited map as orbit-md. [Editing](editing.md) covers menus, dragging, keys, ids, focus, saving, React and `<orbit-map editable>`.
532
+
533
+ ## Interaction
534
+
535
+ A map has two views. **Focus** puts one node at the center (the sun) with its children around it (planets) and their children as small moons. The **overview** lays out the whole tree.
536
+
537
+ - **Moving around:** click or tap a planet (or a moon) to fly into it; click the sun, the back chip or a breadcrumb to go back up. The camera flies between views along a smooth zoom path, and nodes that both views share move into place.
538
+ - **Zooming:** the wheel, a trackpad pinch or a two-finger pinch zooms around the pointer. In focus mode, zooming into a planet enters it once it's big enough to read, so a crowded orbit can be zoomed into and read first; zooming out leaves the current node, and a small zoom out springs back. The overview zooms and pans freely, with momentum.
539
+ - **Spinning:** drag around the sun to turn the orbit. A hard flick keeps it spinning and slowing down like a fidget spinner; a touch catches it. `spin: { snap: 'planet' }` brings the nearest planet to the top as it stops, and `onSpinSettle` tells you which one.
540
+ - **Pages that scroll:** when the page around the map scrolls, the wheel scrolls the page and Ctrl (⌘ on a Mac) + wheel zooms the map; on touch screens, one finger scrolls the page except on the orbit itself. `zoom: { wheel: 'always' }` or `'modifier'` changes this.
541
+ - **Keyboard:** the map is one tab stop. In focus mode, `←`/`→` step through the planets (turning the orbit so the chosen one is on top), `↓` enters the orbit, `↑` returns to the sun, `Enter`/`Space` flies into a node, `Escape`/`Backspace` goes back. In the overview, the arrow keys follow the tree. `+`, `−` and `0` zoom and fit.
542
+ - **Assistive technology:** the list button shows the whole map as a nested list, which screen readers can also browse at any time, and moves are announced. When the reader asks for reduced motion, views cross-fade instead of flying and a flicked orbit doesn't coast.
543
+ - **Pictures and videos:** "View image" and "Play video" open them over the whole window, to zoom into a picture (the wheel, a pinch, a double click or tap) and to play a video; the arrows and swipes step through every picture and video of the map. See [Pictures and videos](#pictures-and-videos).
544
+ - **Editing:** on `editable` maps, the + in the orbit adds an idea and a long press, a right click or F2 edits a node's text. A long press, a right click, or the context menu key (or Shift+F10) also fires `node-context`, for a menu of your own. A node held still lifts: drag it onto another node to move it there, or along the orbit to a new place. With `editingKeys`, Tab, Shift+Enter and Delete add and remove ideas from the keyboard. See [Editing on the map](#editing-on-the-map).
545
+ - **Programmatic:** `renderer.flyTo(id)` (in focus mode), `renderer.overview()`, `renderer.focusNode(id)` (keeps the current view), `renderer.setViewMode(mode)`, `renderer.zoomBy(factor)`, `renderer.fit()`, `renderer.spin(degreesPerSecond)`, `renderer.getFocusPath()`, `renderer.getFocusedNode()`, `renderer.getViewMode()`, `renderer.getNodeColor(id)` for the color a node is drawn with, to match a list or legend beside the map, and `renderer.openMedia(id)`, `closeMedia()`, `playMedia()`, `pauseMedia()`, `seekMedia(seconds)` and `unmuteMedia()` for the media viewer.
546
+
547
+ ## Events
548
+
549
+ Listen on the renderer, or for DOM events that bubble from the map's container (and out of `<orbit-map>`):
550
+
551
+ ```ts
552
+ const stop = map.on('focus-change', ({ node, path }) => {});
553
+ element.addEventListener('orbit-focus-change', (event) => event.detail.node);
554
+ ```
555
+
556
+ | Event | DOM event | Detail |
557
+ | --- | --- | --- |
558
+ | `focus-change` | `orbit-focus-change` | `{ node, path }`: the focused node and the path from the root |
559
+ | `view-mode-change` | `orbit-view-mode-change` | `{ mode }`: `'focus'` or `'all'` |
560
+ | `node-click` | `orbit-node-click` | `{ node }`, before the map reacts |
561
+ | `action` | `orbit-action` | `{ node, action, sourceEvent }`; `preventDefault()` skips the built-in viewer, sheet or link |
562
+ | `spin-settle` | `orbit-spin-settle` | `{ planet }`: the planet on top when a spun orbit stops |
563
+ | `node-context` | `orbit-node-context` | `{ node, x, y }`: a long press, right click or context menu key on a node, and where; `preventDefault()` takes the press |
564
+ | `edit` | `orbit-edit` | `{ kind, node, tree }`: an edit, made on the map or with `addNode`, `updateNode`, `removeNode` or `moveNode` |
565
+ | `editor-open`, `editor-close` | `orbit-editor-open`, `orbit-editor-close` | `{ node, parent }`: the field for typing on the map opened or closed, on `node` or for a new idea under `parent` |
566
+ | `node-drag` | `orbit-node-drag` | `{ phase, node, parent, index }`: a node dragged somewhere else, as it leaves its place (`start`), when where it would go changes (`over`), and when it's let go (`end`) |
567
+ | `media-open`, `media-close` | `orbit-media-open`, `orbit-media-close` | `{ node, kind, index, total }`: the media viewer opened (or stepped) on a node's picture (`kind: 'image'`) or video, `index` of `total` in reading order, or closed |
568
+ | `media-state` | `orbit-media-state` | `{ node, playing, time, duration, ended, muted }`: the viewer's video started, paused, moved on, ended or was muted; while it plays, its time a few times a second |
569
+ | | `orbit-error` | `{ message, line, diagnostics }`, on elements whose map couldn't be drawn |
570
+
571
+ The `on…` options (`onFocusChange`, `onAction`, …) receive the same information.
572
+
573
+ ## Server rendering and export
574
+
575
+ `renderToSVGString` draws a map without a browser, for static sites, emails, docs and print. Colors are resolved to plain values and labels are SVG text, so the file looks the same anywhere:
576
+
577
+ ```ts
578
+ import { renderToSVGString } from '@restless-dreams/orbit';
579
+
580
+ const svg = renderToSVGString(markdown, { width: 1200, height: 800, theme: 'night', viewMode: 'all' });
581
+ ```
582
+
583
+ Options: `width` and `height` (positive, up to a million pixels), `viewMode`, `focus` (a node id), `theme`, `palette`, `maxDepth`, `showRings`, `title`, `baseUrl`, `responsive` (100 % wide and tall instead of pixels) and `idPrefix` (when several SVGs share a page). In the browser, `renderer.toSVGString()` exports what's on screen.
584
+
585
+ ## A map as a web page
586
+
587
+ `orbit export --html` makes one HTML file with Orbit, the map and its pictures inside. It opens in any browser, offline: explore the map with Orbit's own controls, or present it, one idea at a time in reading order with its note as a caption (the Present button or P; the arrows, Space or a swipe step, Escape stops). It's the way to share a map for review: send the file to someone, or to your own phone.
588
+
589
+ ```bash
590
+ npx @restless-dreams/orbit export --html map.md --out map.html --theme auto
591
+ ```
592
+
593
+ Pictures on your computer that the map shows (its nodes' pictures, videos' thumbnails and pictures in notes) go inside the page; pictures on the web stay links, and so do videos. `--title`, `--theme`, `--view`, `--focus` and `--language` work as for `render`; with `--theme auto` the page follows the reader's light or dark mode.
594
+
595
+ In code, `exportWebPage` returns the same page. It needs Orbit's web page script, `@restless-dreams/orbit/web-page.js`, as text, to put inside:
596
+
597
+ ```ts
598
+ import { readFileSync } from 'node:fs';
599
+ import { exportWebPage } from '@restless-dreams/orbit';
600
+
601
+ const script = readFileSync(new URL(import.meta.resolve('@restless-dreams/orbit/web-page.js')), 'utf8');
602
+ const html = exportWebPage(markdown, { script, theme: 'night', media: { 'photos/summit.jpg': 'data:image/jpeg;base64,…' } });
603
+ ```
604
+
605
+ Options: `title`, `theme`, `viewMode`, `focus`, `language`, `strings` and `media`, the pictures to put inside the page by the address the map gives them.
606
+
607
+ ## Map tools for AI
608
+
609
+ `createMapTools` gives a language model tools to read a map and change it: read it as an outline with each node's id, search it, add branches written as markdown, rename, mark, move, sort and remove nodes. `tools.for('anthropic')`, `'openai'`, `'openai-chat'` or `'gemini'` lists them in the shape that provider's API takes, `tools.definitions` as MCP lists tools, and `tools.run(name, input)` carries out each call. On a map on screen, each change animates and fires `edit`; on a document, it changes the document in place. A call a model gets wrong changes nothing, and its result says what to send instead. [Map tools for AI](map-tools.md) has the loop for each provider.
610
+
611
+ ## Notes in your own UI
612
+
613
+ `renderNoteMarkdown` renders a note the way the detail sheet does, for a page that shows notes itself, such as captions under a presented map. It builds DOM nodes rather than HTML text, so a note can't add markup, and its links and pictures go through the same URL guard as the map:
614
+
615
+ ```ts
616
+ import { renderNoteMarkdown } from '@restless-dreams/orbit';
617
+
618
+ caption.replaceChildren(renderNoteMarkdown(node.note ?? '', { baseUrl }));
619
+ ```
620
+
621
+ Options: `baseUrl`, where relative links and pictures resolve from, and `resolveLink(href)`, whether a `#id` link points at a node of the map (links to missing nodes show as plain text).
622
+
623
+ ## Renderer options
624
+
625
+ | Option | Default | |
626
+ | --- | --- | --- |
627
+ | `viewMode` | `'focus'` | `'focus'` or `'all'` (the overview). |
628
+ | `focus` | the root | The node in focus when the map first draws, by id: to open a map where the reader left it. |
629
+ | `theme` | `'paper'` | A theme name (see Theming) or `{ preset, tokens }`. |
630
+ | `palette` | theme colors | Node colors: the first for the root, the rest cycle through the branches. |
631
+ | `maxDepth` | all levels | Levels below the root to draw. |
632
+ | `showRings` | `true` | Dashed orbit rings. |
633
+ | `interactive` | `true` | `false` draws a still picture. |
634
+ | `showControls` | `true` | Breadcrumbs, view switch, list button and zoom buttons. |
635
+ | `showActionButtons` / `showDetailSheet` | `true` | Content buttons under the focused node / the built-in detail sheet. |
636
+ | `mediaViewer` | `true` | Pictures and videos open in the media viewer, over the whole window; `false` keeps them in the detail sheet. |
637
+ | `editable` | `false` | A + in the orbit to add ideas, text edited inside nodes (long press, right click or F2), and nodes dragged somewhere else. |
638
+ | `editingKeys` | `false` | Mind-map keys on editable maps: Tab adds an idea under a node, Shift+Enter beside it, and Delete removes it; while typing, Tab and Shift+Tab go a level down and up. |
639
+ | `longPressDelay` | `500` | Milliseconds a touch holds still on a node before it is a long press; apps can pass their platform's own. |
640
+ | `insets` | | `{ top, right, bottom, left }` in px: the parts of the map your page covers with its own controls, such as a floating toolbar. Views are fitted into what's left, and the map still draws under them. |
641
+ | `orbitAnimation` / `orbitSpeed` | `false` / `90` | Let the orbit drift slowly, one turn every `orbitSpeed` seconds; it pauses while you point at it, tab through it, zoom or pan. |
642
+ | `spin` | | `{ decay: 1.4, maxSpeed: 1440, snap: false, haptics: false }`: seconds for a flick to lose two thirds of its speed, fastest spin in degrees per second, snapping a planet to the top, and a light vibration as planets pass the top. |
643
+ | `zoom` | | `{ wheel: 'auto', minimum: 0.4, maximum: 6 }`: wheel behavior and the zoom range relative to the fitted view. |
644
+ | `transitionDuration` | `'auto'` | Flight length in milliseconds, scaled to the distance travelled when `'auto'`; `0` turns flights off. |
645
+ | `ariaLabel` | document title | The map's accessible name. |
646
+ | `language` | the page's | The language of the map's buttons, sheets and announcements: a tag like `'es'` ([Languages](languages.md)). By default, the closest `lang` attribute around the map. |
647
+ | `strings` | | Words to change on this map only: `{ zoomIn: 'Closer' }`. |
648
+ | `baseUrl` | | Resolves relative image, video and link URLs. |
649
+ | `onNodeClick`, `onFocusChange`, `onViewModeChange`, `onAction`, `onSpinSettle`, `onNodeContext`, `onEdit`, `onEditorOpen`, `onEditorClose`, `onMediaOpen`, `onMediaClose`, `onMediaState` | | Callbacks. |
650
+ | `noteRenderer` | | Renders note markdown to HTML yourself. |
651
+
652
+ ## CLI
653
+
654
+ ```bash
655
+ npx @restless-dreams/orbit validate my-map.md # every problem as my-map.md:line: severity: message [code]
656
+ npx @restless-dreams/orbit validate --json my-map.md # the same as JSON, for scripts and coding agents
657
+ npx @restless-dreams/orbit validate --tree my-map.md # the map as Orbit reads it, a node a line with its #id
658
+ npx @restless-dreams/orbit render my-map.md --out my-map.svg # draw a map to SVG (--theme, --view, --focus, --width, --height, --language)
659
+ npx @restless-dreams/orbit convert --to yaml my-map.md # markdown ↔ YAML (either direction)
660
+ npx @restless-dreams/orbit example > my-map.md # a map with the main features (--yaml for YAML)
661
+ npx @restless-dreams/orbit --version # the installed version
662
+ ```
663
+
664
+ Use `-` as the file to read from stdin, and `--help` after any command for its options. `validate` exits with 1 while the map has a problem (`--loose` accepts the ones the map works around, but never a map it can't read); every command exits with 2 for a mistake in the command itself. `render` links pictures on your computer from where the SVG goes, so it can move with them. The command itself is `orbit`: where the package is installed, scripts can call `orbit validate my-map.md` directly.
665
+
666
+ ## Languages
667
+
668
+ Maps speak the language of the page they're on: the buttons, the detail sheet, the media viewer and everything screen readers hear. English and Spanish are built in; `registerLanguage` adds others, and the `language` and `strings` options pick a language or change single words for one map.
669
+
670
+ ```ts
671
+ import { createOrbitMap, registerLanguage } from '@restless-dreams/orbit';
672
+
673
+ createOrbitMap(element, markdown, { language: 'es' }); // or <html lang="es">, or <orbit-map lang="es">
674
+ registerLanguage('fr', { zoomIn: 'Zoomer', overview: 'Vue d’ensemble' });
675
+ ```
676
+
677
+ [Languages](languages.md) lists every word, and how to translate them.
678
+
679
+ ## Theming
680
+
681
+ Pick a theme with the `theme` option, the `theme` attribute or `theme:` in the frontmatter:
682
+
683
+ ![The fourteen themes: paper, night, glass, glass night, cosmos, blueprint, ink, contrast, flat, flat night, material, material night, liquid and liquid night](media/themes.webp)
684
+
685
+ - `paper` (the default): pastel planets with soft shadows, on your page's own background.
686
+ - `night`: planets that glow on a deep navy sky.
687
+ - `glass` and `glass-night`: frosted, translucent planets over a soft backdrop of their own.
688
+ - `cosmos`: jewel-toned planets around a golden star, in a field of stars.
689
+ - `blueprint`: outlined planets, construction lines and monospaced labels on a gridded blue sheet.
690
+ - `ink`: black line art with serif lettering, on your page's own background.
691
+ - `contrast`: black outlines, solid colors and bold labels, for the most legible map.
692
+ - `flat` and `flat-night`: solid, saturated planets with no shading, shadows or glow.
693
+ - `material` and `material-night`: Material 3's tonal containers and surfaces. `materialTheme('#0B57D0')` builds the same look from any color, the way Material You colors an app from its wallpaper.
694
+ - `liquid` and `liquid-night`: liquid glass, clear planets that catch the light along their rims, over a vivid wallpaper of their own.
695
+ - `auto`: `paper` or `night`, following the reader's light or dark preference.
696
+
697
+ Labels keep at least 4.5:1 contrast with their node in every theme.
698
+
699
+ Every color, size and finish is a CSS custom property, so you can restyle a map from your own stylesheet ([the full list](theming.md)). Properties set by the page win over the theme:
700
+
701
+ ```css
702
+ orbit-map,
703
+ .my-map {
704
+ --orbit-sans-font: Inter, sans-serif; /* your font, in every sans-serif theme */
705
+ --orbit-surface: #fbf8f3; /* background behind the map */
706
+ --orbit-color-root: #f8d978; /* the root, then one color per branch */
707
+ --orbit-color-1: #f5a3a8;
708
+ --orbit-color-2: #a8ddb5;
709
+ --orbit-node-text: #243042;
710
+ --orbit-node-shadow-opacity: 0; /* planets without a shadow */
711
+ --orbit-ring-dash: none; /* solid orbits */
712
+ --orbit-sheet-background: #ffffff; /* the detail sheet and the media viewer */
713
+ --orbit-focus-ring: #2563eb;
714
+ }
715
+ ```
716
+
717
+ Names are measured in the font they're drawn with, so they fill their nodes without spilling out of them, in any font, and fit again when a web font arrives ([Fonts](theming.md#fonts)).
718
+
719
+ The full list is exported as `THEME_TOKENS`, and `themeTokens(name)` returns a theme's values (for example to style your own UI to match). A `theme` object sets values in code (`theme: { preset: 'night', tokens: { surface: '#0b1020', 'ring-dash': 'none' } }`), and `palette: ['#…', '#…']` replaces the node colors. Children share their branch's color in lighter and darker shades, and labels switch to light text on dark nodes on their own.
720
+
721
+ ## Browsers
722
+
723
+ Orbit is made for current browsers, and draws in older ones down to Chromium 99, which some phones' WebViews still run. A few effects need newer CSS, and older browsers get plainer versions instead of broken ones:
724
+
725
+ - without `color-mix()` (Chromium 111, Safari 16.2, Firefox 113), sibling ideas keep their branch's color instead of a shade of it, and rims, rings, shadows and halos take the theme's own colors;
726
+ - without the `translate` property (Chromium 104), overlays are centered through their transform;
727
+ - without the `dvh` unit (Chromium 108), the detail sheet sizes by `vh`.
728
+
729
+ ## Cleanup
730
+
731
+ If you mount via `renderOrbitElement` and later remove the host from the DOM, call `OrbitSVGRenderer.unmount(host)` to release RAF + ResizeObserver resources. The `<orbit-map>` element does this automatically.
732
+
733
+ ---
734
+
735
+ <!-- docs/format.md -->
736
+
737
+ # Map format
738
+
739
+ The complete reference for writing Orbit maps. A map is a tree: one root, and nodes with children. Write it in **orbit-md**, Orbit's markdown (the default, for maps people write), or in **YAML** (for maps programs write). `npx @restless-dreams/orbit example` prints a map with the main features, and `npx @restless-dreams/orbit validate map.md` checks one.
740
+
741
+ - [Markdown](#markdown)
742
+ - [The tree](#the-tree)
743
+ - [Node titles](#node-titles)
744
+ - [Notes](#notes)
745
+ - [Links between nodes](#links-between-nodes)
746
+ - [Media](#media)
747
+ - [Attributes](#attributes)
748
+ - [Statuses](#statuses)
749
+ - [Document options](#document-options)
750
+ - [YAML](#yaml)
751
+ - [Which flavor a document is](#which-flavor-a-document-is)
752
+ - [Node ids](#node-ids)
753
+ - [Checking a map](#checking-a-map)
754
+
755
+ ## Markdown
756
+
757
+ ```markdown
758
+ ---
759
+ title: Product roadmap
760
+ theme: night
761
+ ---
762
+ # 🚀 Product roadmap
763
+ What ships this year.
764
+
765
+ ## Research <!-- orbit status: done -->
766
+ Six weeks of interviews before we commit to scope.
767
+ - [x] User interviews
768
+ - Competitor teardown #market
769
+
770
+ ## Build
771
+ ### Frontend
772
+ - [/] App shell
773
+ - Offline sync <!-- orbit status: blocked -->
774
+ Waits on the [backend](#backend) sync API.
775
+ ### Backend
776
+
777
+ ## [Launch](https://example.com/launch-plan)
778
+ ![The launch poster](images/poster.png)
779
+ ```
780
+
781
+ ### The tree
782
+
783
+ **Every heading and every list item is a node.** Everything else belongs to the node above it.
784
+
785
+ - **Headings** (`#` to `######`) nest by level: a heading's children are the headings below it, up to the next heading at its own level or higher. A line underlined with `===` is a `#` heading, and with `---` a `##` heading. Headings may be indented up to three spaces and end with closing `#`s.
786
+ - **Lists** are children of the heading above them, every list, in the order written next to that heading's subheadings. Bullets (`-`, `*`, `+`) and numbered items (`1.`, `1)`) work the same. Indent an item further than the one above it to nest it under that item; lists go as deep as you like, past the six heading levels. A tab counts as four spaces.
787
+ - **The root** is the single top heading: the only heading at the highest level used, usually `#`. Several top headings share a root, as do the top items of a document that is just a list: it's named after the frontmatter `title`, a one-line paragraph above them, or "Untitled map". A list with a single top item is rooted at that item. Text before the root heading belongs to the root.
788
+ - A ```` ```orbit ```` or `~~~orbit` fence around the whole map is fine: `parseOrbit` unwraps it.
789
+
790
+ ### Node titles
791
+
792
+ A heading's text, or a list item's first line, is the node's title, read in this order:
793
+
794
+ | Written | Becomes |
795
+ | --- | --- |
796
+ | `[ ] Draft` / `[x] Ship` / `[/] Build` | status `todo` / `done` / `active` |
797
+ | `🚀 Launch` (emoji first) | icon `🚀`, text `Launch` |
798
+ | `Offline sync #frontend #sprint-3` | tags `frontend` and `sprint-3`, text `Offline sync` |
799
+ | `Launch [plan](https://…)` | text `Launch plan`, link `https://…` |
800
+ | `**Frontend**`, `*Docs*`, `` `api` ``, `~~Old idea~~` | the text without the symbols |
801
+ | `Launch <!-- orbit status: active -->` | an attribute, inline (see [Attributes](#attributes)) |
802
+
803
+ - **Hashtags** count at the end of a title, after a space, and need a letter: `C# rewrite` and `Fix #42` stay as written.
804
+ - **Links:** a title that is all link works the same; with several links, the first is the node's link.
805
+ - **A backslash** keeps a character as written: `\*not bold\*`, `ship \#notatag`, `\[x] not a task`.
806
+
807
+ They combine: `## 📈 [Growth](https://example.com/growth) #q3 <!-- orbit status: active -->`.
808
+
809
+ ### Notes
810
+
811
+ A note is longer text shown in the detail sheet, as markdown. Everything under a heading or list item that isn't a heading or list item is its note: paragraphs, `>` quotes, code blocks and tables.
812
+
813
+ - Under a list item, indent the note under it, as you would write anything under a heading. A line right after the item, with no blank line between, continues the item too.
814
+ - To keep a list inside a note, instead of making it children, quote it with `>`: headings and bullets inside a quote stay note text.
815
+
816
+ ```markdown
817
+ # Launch
818
+ ## Research
819
+ Questions to answer:
820
+ > - Who buys it?
821
+ > - Why now?
822
+
823
+ - Frontend
824
+ Vite and React. Ships behind the beta flag.
825
+ - Backend
826
+ ```
827
+
828
+ ### Links between nodes
829
+
830
+ A link to `#id` points at another node of the map, the way `#heading` links point at headings in a page: a note link like `[Research](#research)`, or a node whose link is `#research`. It flies to that node instead of opening a page.
831
+
832
+ ```markdown
833
+ # Launch
834
+ ## Build
835
+ Starts when [Research](#research) is done.
836
+ ## [Read the research](#research)
837
+ ## Research
838
+ ```
839
+
840
+ The id is the node's text in lowercase with dashes (see [Node ids](#node-ids)); `#Research` and GitHub-style `#-research` find it too. A link to a node the map doesn't have is reported.
841
+
842
+ ### Media
843
+
844
+ A node shows its image (or its video's thumbnail) inside it. Its image and video open over the whole window, in the media viewer ([Pictures and videos](media.md)), and its embed in the detail sheet. Put each on a line of its own under the heading, or indented under the list item:
845
+
846
+ | Line | Becomes |
847
+ | --- | --- |
848
+ | `![Alt text](images/photo.jpg)` | the image |
849
+ | `[Trailer](https://www.youtube.com/watch?v=…)` | the video: YouTube, Vimeo, or a `.mp4`, `.webm` or `.mov` file |
850
+ | `[![Alt text](thumbnail.jpg)](https://vimeo.com/…)` | the video, with that thumbnail |
851
+ | `[Any other link](https://…)` | note text |
852
+
853
+ A node has at most one image, one video and one embed; extra media lines stay in the note. Embeds (a page shown in a frame) are set with the `embed` attribute. To read a media line differently, follow it with `<!-- orbit contentType: image -->` (or `video`, `embed`, or `none` to keep it as note text), and caption it the same way, with `<!-- orbit caption: The summit at dawn -->` right under it. Relative URLs resolve against `baseUrl`.
854
+
855
+ ```markdown
856
+ # Trip
857
+ - Summit
858
+ ![The summit at dawn](images/summit.jpg)
859
+ - Trail
860
+ [Walkthrough](https://www.youtube.com/watch?v=aqz-KE-bpKQ)
861
+ ```
862
+
863
+ Attributes set media too: `<!-- orbit image: images/photo.jpg -->`.
864
+
865
+ ### Attributes
866
+
867
+ Attributes go in an HTML comment that starts with `orbit` (in any case, with or without a colon after it), written as YAML:
868
+
869
+ ```markdown
870
+ ## Build
871
+ <!-- orbit
872
+ color: "#F5A3A8"
873
+ status: active
874
+ priority: 1
875
+ tags: [frontend, sprint-3]
876
+ -->
877
+
878
+ - Frontend <!-- orbit status: active -->
879
+ - Backend
880
+ <!-- orbit
881
+ image: diagrams/backend.png
882
+ note: Talks to the billing service.
883
+ -->
884
+ ```
885
+
886
+ - **Block form**: on the lines after a heading, or indented under a list item. Any number of keys. A block written right under a list item without its indent still belongs to the heading above the list, and an `attributes-after-list` note says so: indent it to set the list item's.
887
+ - **Inline form**: at the end of a heading or list item's line. Separate several with commas: `<!-- orbit status: active, priority: 1 -->`.
888
+ - **Other HTML comments** are hidden, as markdown hides them: Orbit keeps them in the file, out of titles, ids and notes as they're shown, and a renamed idea keeps the ones at the end of its title. One that reads like attributes but doesn't start with `orbit` (`<!-- status: done -->`) sets nothing, and a `comment-without-orbit` note says how to write it.
889
+
890
+ | Key | Value | Example | Effect |
891
+ | --- | --- | --- | --- |
892
+ | `color` | any CSS color | `"#F5A3A8"`, `tomato` | The node's color, instead of its branch's. Its children take shades of it. |
893
+ | `icon` | emoji or short symbol | `🚀`, `"★"` | Shown before the text. Same as an emoji at the start of the title. |
894
+ | `status` | text | `active`, `done`, `blocked` | Shown in the detail sheet; some words get a colored dot (see [Statuses](#statuses)). |
895
+ | `priority` | number | `1` | Shown in the detail sheet. |
896
+ | `tags` | list of text | `[frontend, sprint-3]` | Shown in the detail sheet. A single word works too, and so do words separated by commas: `tags: frontend, sprint-3`. Hashtags at the end of the title add to them. |
897
+ | `collapsed` | `true` / `false` | `true` | Hides the node's children (they show as a "+N" count) until you fly into it. |
898
+ | `link` | URL, or `#id` | `https://example.com` | Offered as a button when the node is in focus. Same as a link in the title. `#id` goes to another node (see [Links between nodes](#links-between-nodes)). |
899
+ | `note` | text | `Ships in May.` | The node's note. Same as a paragraph or blockquote. |
900
+ | `noteFormat` | `markdown` / `plain` | `plain` | How the note is shown. Default `markdown`. |
901
+ | `image` | URL, or `{ src, alt, caption }` | `images/photo.jpg` | The node's image. |
902
+ | `video` | URL, or `{ src, provider, thumbnail, caption }` | `https://youtu.be/…` | The node's video. `provider`: `youtube`, `vimeo` or `native`. |
903
+ | `embed` | URL, or `{ src, caption }` | `https://example.com/demo` | A page shown in a frame in the detail sheet. |
904
+ | `caption` | text | `The summit at dawn.` | Caption for the media line just above it, or for the one image, video or embed set next to it. Anywhere else, for the node's video, embed or image (in that order). |
905
+ | `id` | text | `launch` | The node's id, instead of one made from its text (see [Node ids](#node-ids)). |
906
+ | `type` | text | `milestone` | The node's type. A type your code defines sets its look, content and actions (see [Extending](extending.md#node-types)). Any case. |
907
+ | `contentType` | `image` / `video` / `embed` / `none` | `embed` | Markdown only: how to read the media line just above. |
908
+
909
+ Any other key is kept as a **custom attribute** in `node.attrs`, for your own code (`<!-- orbit owner: Ana -->`). A key that looks like a misspelled attribute (`colour`, `stauts`) is kept too, with a warning that suggests the right one.
910
+
911
+ ### Statuses
912
+
913
+ `status` is free text, shown as written. These words (any case) also get a colored dot on the node and in the detail sheet:
914
+
915
+ | Dot | Words |
916
+ | --- | --- |
917
+ | active (blue) | `active`, `doing`, `in progress`, `wip`, `started`, `ongoing` |
918
+ | done (green) | `done`, `complete`, `completed`, `shipped`, `finished`, `closed` |
919
+ | blocked (red) | `blocked`, `stuck`, `at risk`, `failed` |
920
+ | todo (gray) | `todo`, `to do`, `planned`, `backlog`, `next`, `open` |
921
+
922
+ Other words get a neutral dot. Checkboxes set `todo` (`[ ]`), `done` (`[x]`) and `active` (`[/]`).
923
+
924
+ ## Document options
925
+
926
+ Set in the frontmatter (markdown) or as top-level keys (YAML). Options passed in code win over these.
927
+
928
+ | Key | Value | Default | |
929
+ | --- | --- | --- | --- |
930
+ | `title` | text | the root's text | The map's accessible name and SVG title. Names the root when a document has no single top heading. |
931
+ | `theme` | `paper`, `night`, `glass`, `glass-night`, `cosmos`, `blueprint`, `ink`, `contrast`, `flat`, `flat-night`, `material`, `material-night`, `liquid`, `liquid-night` or `auto` | `paper` | See [Theming](theming.md). |
932
+ | `view` | `focus` or `all` | `focus` | The first view: one node with its children around it, or the overview of the whole tree. |
933
+ | `palette` | list of colors | the theme's | The root's color, then one per top-level branch, cycling. A comma-separated string works too. |
934
+ | `maxDepth` | number | all levels | Levels below the root to draw: `1` is the root and its children. |
935
+ | `showRings` | `true` / `false` | `true` | Draw the orbit rings. |
936
+ | `interactive` | `true` / `false` | `true` | `false` draws a still picture. |
937
+ | `animation` | `true` / `false` | `false` | Let the orbit drift slowly on its own. |
938
+ | `speed` | seconds | `90` | Seconds per turn of that drift. |
939
+ | `height` | pixels | `420` | Height of `<orbit-map>` elements and marked placeholders. |
940
+ | `baseUrl` | URL | | Resolves relative image, video and link URLs. |
941
+ | `className` | text | | Class names added to the map's element. |
942
+ | `schemaVersion` | number | | The version of the format the document is written for: `1`. See [Versions](#versions). |
943
+ | `flavor` | `markdown` or `yaml` | | Forces the flavor (see [Which flavor a document is](#which-flavor-a-document-is)). |
944
+
945
+ Flags also read `yes`, `no`, `on` and `off`.
946
+
947
+ ## YAML
948
+
949
+ For maps that programs generate:
950
+
951
+ ```yaml
952
+ title: Product roadmap
953
+ theme: night
954
+ nodes:
955
+ - text: Product roadmap
956
+ icon: 🚀
957
+ note: What ships this year.
958
+ children:
959
+ - text: Research
960
+ status: done
961
+ children: [User interviews, Competitor teardown]
962
+ - text: Build
963
+ status: active
964
+ children:
965
+ - text: Frontend
966
+ children:
967
+ - App shell
968
+ - text: Offline sync
969
+ status: blocked
970
+ - text: Launch
971
+ link: https://example.com/launch-plan
972
+ image: images/poster.png
973
+ ```
974
+
975
+ - `nodes` is a list with the root node. Several top-level nodes share a root named after `title`, as in markdown.
976
+ - A node is a map with `text` and optionally `children`, plus any [attribute](#attributes) (`icon`, `color`, `status`, `link`, `note`, `image`, …) as a key. Custom keys are kept in `node.attrs`. A node without `text` but with `title`, `name` or `label`, or without `children` but with `items`, `nodes` or `subnodes`, is read with those as its text and children, and reported (`misnamed-node-key`) until they're renamed.
977
+ - A plain string in `children` is a node with just that text.
978
+ - Document options go at the top level, next to `nodes`.
979
+
980
+ ## Which flavor a document is
981
+
982
+ `parseOrbit` reads a document as YAML when its frontmatter says `flavor: yaml`, its first line is `nodes:`, or the whole text reads as a YAML map: one with a `nodes:` list, or one that starts with a document option (`title:`, `theme:`, `view:`, …). Anything else is markdown, so a first line like `Status: draft`, or a note that starts with `nodes:`, stays markdown. `parseOrbitMarkdown` and `parseOrbitYAML` skip the detection.
983
+
984
+ ## What saving keeps
985
+
986
+ Orbit never drops what a document says, even what it doesn't read:
987
+
988
+ - **Saving keeps the file as it was written.** `serializeOrbitMarkdown` (and a map's `toMarkdown()`) write a document parsed from markdown back line for line, and only the ideas an edit changed are written again: a renamed idea gets a new title line, a checked-off item a new checkbox, a moved idea takes its lines along. So a note written by hand or by another app keeps its formatting, its frontmatter keys, its HTML comments and rules, and its link definitions at the end. `{ keepSource: false }` writes the whole map in Orbit's own form instead; so does `orbit convert`.
989
+ - **Keys and values Orbit doesn't read are kept.** Frontmatter and top-level YAML keys it doesn't know are on `document.extra`, and both serializers write them back, with any changes an app makes to them: an app can keep a setting of its own there. A document without `extra` changes none of them, so no key another app wrote is lost; an `extra` object, even `{}`, changes or removes them. An attribute Orbit doesn't know is a custom attribute on `node.attrs`; a value it can't read for one of its own (a list for `status`, a word for `priority`) is reported and kept there under its own name. A picture or video keeps fields Orbit doesn't use (`width`, a `provider` it doesn't know).
990
+ - **A file that isn't a map yet** (only paragraphs) is a root with the text as its note: adding the first idea gives it a heading and keeps the text.
991
+
992
+ ## Versions
993
+
994
+ A document says which version of the format it's written for with `schemaVersion`; none means `1`, the current one.
995
+
996
+ - **Older documents are upgraded as they're read.** When the format changes, Orbit brings documents written for the version before up to date as it reads them (a `format-upgraded` note says so), and the next save writes them as the new version, changing only what the upgrade changed.
997
+ - **Newer documents are read as well as they can be,** and reported with `unsupported-schema-version`, an error: apps should open them without letting them be edited. Nothing in them is lost by saving, but what this version doesn't know of them isn't understood either.
998
+ - **Node types have versions of their own,** for the fields their nodes keep in attributes: `type: task@2` says a node was written for version 2 of `task`, and none means 1. A type that changes its fields says so with `version` and brings older nodes up to date with `upgrade` (see [Extending Orbit](extending.md#versions)); a node written for a newer version of its type is left as it is.
999
+
1000
+ ## Node ids
1001
+
1002
+ Each node gets an id from its text: lowercase, accents folded, other letters kept (`Investigación` → `investigacion`, `研究` → `研究`, `Q3 plan` → `q3-plan`). Repeated ids get `-2`, `-3`, … in tree order. Set `id` to choose one; ids are what `renderer.flyTo(id)`, the `focus` export option and the React `focusedId` prop take.
1003
+
1004
+ ## Checking a map
1005
+
1006
+ Parsing is forgiving: when something is wrong but the map can still be drawn, the parser works around it and records a diagnostic with a `code`, a `severity`, a message that says how to fix it and the `line` where it has one. `{ strict: true }` throws an `OrbitParseError` on the first one instead, except for the few that only note something and leave the map valid: `unknown-attribute` (kept as your own attribute), `synthetic-root`, `caption-without-media`, `format-upgraded`, `ignored-node-entry`, `comment-without-orbit` and `attributes-after-list`.
1007
+
1008
+ ```bash
1009
+ npx @restless-dreams/orbit validate map.md # every problem as map.md:line: severity: message [code]
1010
+ npx @restless-dreams/orbit validate --json map.md # the same as JSON: valid, flavor, root, nodes, depth, diagnostics
1011
+ npx @restless-dreams/orbit validate --tree map.md # the map as Orbit reads it, a node a line with its #id
1012
+ ```
1013
+
1014
+ `validate` exits with 1 while the map has a problem that makes it invalid, and lists the notes too (add `--loose` to accept the problems the map works around, but never a map it can't read).
1015
+
1016
+ | Code | Meaning |
1017
+ | --- | --- |
1018
+ | `empty-document` | Nothing to draw. |
1019
+ | `no-structure` | No headings or lists (markdown), or no `nodes:` list (YAML). |
1020
+ | `synthetic-root` | Several top headings, items or YAML nodes and no title, so the root is called "Untitled map". |
1021
+ | `invalid-frontmatter` | The frontmatter isn't valid YAML `key: value` pairs. |
1022
+ | `unknown-frontmatter-key` | A frontmatter key Orbit doesn't know: kept and written back, but not used. The message names the one you probably meant. |
1023
+ | `unknown-yaml-key` | The same, for a YAML document's top-level keys. |
1024
+ | `invalid-option-value` | A document option with a value Orbit can't use, such as an unknown theme or a `flavor` other than `markdown` or `yaml`. |
1025
+ | `unsupported-schema-version` | The document is written for a newer version of the format (an error: open it without editing it). |
1026
+ | `format-upgraded` | The document was written for an older version of the format and read as the current one; saving writes it in the current one. |
1027
+ | `invalid-attributes` | An attribute block isn't valid YAML, or a value has the wrong type: kept as written, but not used. |
1028
+ | `unknown-attribute` | A key that looks like a misspelled attribute, kept as a custom one. |
1029
+ | `media-missing-source` | An `image`, `video` or `embed` without a URL. |
1030
+ | `unknown-content-type` | `contentType` isn't `image`, `video`, `embed` or `none`. |
1031
+ | `caption-without-media` | A `caption` on a node with no image, video or embed. |
1032
+ | `unknown-link-target` | A `#id` link points at no node of the map; the message suggests the id you probably meant. |
1033
+ | `invalid-yaml` | The YAML document can't be read. |
1034
+ | `ignored-node-entry` | A YAML node that is neither a map nor text. |
1035
+ | `comment-without-orbit` | An HTML comment that reads like attributes (`<!-- status: done -->`) but doesn't start with `orbit`, so it sets nothing. |
1036
+ | `attributes-after-list` | An attribute block right under a list item, without its indent: it sets the heading's attributes, not the item's. |
1037
+ | `misnamed-node-key` | A YAML node's text under `title`, `name` or `label`, or its children under `items`, `nodes` or `subnodes`: read as them, but write `text` and `children`. |
1038
+
1039
+ ---
1040
+
1041
+ <!-- docs/recipes.md -->
1042
+
1043
+ # Recipes
1044
+
1045
+ How to use Orbit with common frameworks and tools. Every map fills its container, so give the container a height.
1046
+
1047
+ - [Plain HTML](#plain-html)
1048
+ - [React and Next.js](#react-and-nextjs)
1049
+ - [Vue](#vue)
1050
+ - [Svelte](#svelte)
1051
+ - [Astro](#astro)
1052
+ - [Markdown pipelines](#markdown-pipelines)
1053
+ - [Static sites, emails and print](#static-sites-emails-and-print)
1054
+
1055
+ ## Plain HTML
1056
+
1057
+ The `<orbit-map>` element needs no build step:
1058
+
1059
+ ```html
1060
+ <script type="module">
1061
+ import { defineOrbitMapElement } from 'https://cdn.jsdelivr.net/npm/@restless-dreams/orbit/+esm';
1062
+ defineOrbitMapElement();
1063
+ </script>
1064
+
1065
+ <orbit-map height="420">
1066
+ # 🚀 Project
1067
+
1068
+ ## Research <!-- orbit status: active -->
1069
+ ## Build
1070
+ ## Launch
1071
+ </orbit-map>
1072
+ ```
1073
+
1074
+ Hide elements until the script loads, so the raw markdown never flashes: `orbit-map:not(:defined) { visibility: hidden; }`.
1075
+
1076
+ To draw into your own element instead:
1077
+
1078
+ ```js
1079
+ import { createOrbitMap } from '@restless-dreams/orbit';
1080
+
1081
+ const map = createOrbitMap(document.querySelector('#map'), markdown, { theme: 'auto' });
1082
+ map.on('focus-change', ({ node }) => console.log(node.text));
1083
+ ```
1084
+
1085
+ ## React and Next.js
1086
+
1087
+ `@restless-dreams/orbit/react` is marked `"use client"`, so you can render it from a Server Component. It draws a static SVG of the map on the server, and the live map takes over in the browser.
1088
+
1089
+ ```tsx
1090
+ // app/roadmap/page.tsx (a Server Component)
1091
+ import { OrbitMindMap } from '@restless-dreams/orbit/react';
1092
+ import { readFile } from 'node:fs/promises';
1093
+
1094
+ const RoadmapPage = async () => {
1095
+ const markdown = await readFile('content/roadmap.md', 'utf8');
1096
+ return (
1097
+ <div style={{ height: 520 }}>
1098
+ <OrbitMindMap source={markdown} theme="auto" />
1099
+ </div>
1100
+ );
1101
+ };
1102
+
1103
+ export default RoadmapPage;
1104
+ ```
1105
+
1106
+ Callbacks are functions, which can't cross from a Server Component, so put interactive maps in a client component:
1107
+
1108
+ ```tsx
1109
+ 'use client';
1110
+
1111
+ import { useRef, useState } from 'react';
1112
+ import { OrbitMindMap, type OrbitMindMapRef } from '@restless-dreams/orbit/react';
1113
+
1114
+ export const RoadmapExplorer = ({ markdown }: { markdown: string }) => {
1115
+ const map = useRef<OrbitMindMapRef>(null);
1116
+ const [focusedId, setFocusedId] = useState<string>();
1117
+
1118
+ return (
1119
+ <>
1120
+ <button onClick={() => map.current?.overview()}>Show everything</button>
1121
+ <div style={{ height: 520 }}>
1122
+ <OrbitMindMap
1123
+ ref={map}
1124
+ source={markdown}
1125
+ focusedId={focusedId}
1126
+ onFocusChange={(node) => setFocusedId(node.id)}
1127
+ />
1128
+ </div>
1129
+ </>
1130
+ );
1131
+ };
1132
+ ```
1133
+
1134
+ ## Vue
1135
+
1136
+ Use the web component. Tell Vue that `orbit-map` is a custom element:
1137
+
1138
+ ```ts
1139
+ // vite.config.ts
1140
+ import vue from '@vitejs/plugin-vue';
1141
+
1142
+ export default {
1143
+ plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'orbit-map' } } })],
1144
+ };
1145
+ ```
1146
+
1147
+ ```ts
1148
+ // main.ts
1149
+ import { defineOrbitMapElement } from '@restless-dreams/orbit';
1150
+
1151
+ defineOrbitMapElement();
1152
+ ```
1153
+
1154
+ ```vue
1155
+ <script setup lang="ts">
1156
+ import { ref } from 'vue';
1157
+
1158
+ const markdown = ref('# Project\n\n## Research\n## Build');
1159
+ const focused = ref('');
1160
+ </script>
1161
+
1162
+ <template>
1163
+ <orbit-map
1164
+ :source="markdown"
1165
+ height="420"
1166
+ @orbit-focus-change="(event) => (focused = event.detail.node.text)"
1167
+ />
1168
+ <p>Looking at: {{ focused }}</p>
1169
+ </template>
1170
+ ```
1171
+
1172
+ Setting `source` redraws the map and keeps the reader's place.
1173
+
1174
+ ## Svelte
1175
+
1176
+ An action draws the map and redraws it when the markdown changes (Svelte 4 and 5):
1177
+
1178
+ ```svelte
1179
+ <script>
1180
+ import { createOrbitMap } from '@restless-dreams/orbit';
1181
+
1182
+ let { markdown } = $props();
1183
+
1184
+ const orbit = (element, source) => {
1185
+ const map = createOrbitMap(element, source);
1186
+ return {
1187
+ update: (next) => map.render(next),
1188
+ destroy: () => map.destroy(),
1189
+ };
1190
+ };
1191
+ </script>
1192
+
1193
+ <div use:orbit={markdown} style="height: 420px"></div>
1194
+ ```
1195
+
1196
+ ## Astro
1197
+
1198
+ Interactive, with the web component:
1199
+
1200
+ ```astro
1201
+ ---
1202
+ import markdown from '../content/roadmap.md?raw';
1203
+ ---
1204
+ <orbit-map source={markdown} height="420"></orbit-map>
1205
+
1206
+ <script>
1207
+ import { defineOrbitMapElement } from '@restless-dreams/orbit';
1208
+ defineOrbitMapElement();
1209
+ </script>
1210
+ ```
1211
+
1212
+ Or static, with no JavaScript at all:
1213
+
1214
+ ```astro
1215
+ ---
1216
+ import { renderToSVGString } from '@restless-dreams/orbit';
1217
+ import markdown from '../content/roadmap.md?raw';
1218
+ const svg = renderToSVGString(markdown, { responsive: true, theme: 'paper' });
1219
+ ---
1220
+ <figure style="height: 420px" set:html={svg} />
1221
+ ```
1222
+
1223
+ ## Markdown pipelines
1224
+
1225
+ **marked:** `@restless-dreams/orbit/marked` turns ```` ```orbit ```` fences into maps (see [Markdown pipelines](guide.md#markdown-pipelines-marked) in the guide).
1226
+
1227
+ **remark and rehype** (Astro, Docusaurus, Next.js MDX, Eleventy, …): a small rehype plugin replaces the fences with static SVG at build time:
1228
+
1229
+ ```js
1230
+ import { visit } from 'unist-util-visit';
1231
+ import { toString } from 'hast-util-to-string';
1232
+ import { fromHtml } from 'hast-util-from-html';
1233
+ import { renderToSVGString } from '@restless-dreams/orbit';
1234
+
1235
+ /** Replaces ```orbit code blocks with a static SVG of the map. */
1236
+ export const rehypeOrbit = (options = {}) => {
1237
+ return (tree) => {
1238
+ let count = 0;
1239
+
1240
+ visit(tree, 'element', (node, index, parent) => {
1241
+ const code = node.tagName === 'pre' ? node.children.find((child) => child.tagName === 'code') : null;
1242
+ const classes = code?.properties?.className ?? [];
1243
+
1244
+ if (!code || !classes.includes('language-orbit') || !parent || index === undefined) {
1245
+ return;
1246
+ }
1247
+
1248
+ count += 1;
1249
+ const svg = renderToSVGString(toString(code), { responsive: true, idPrefix: `orbit-${count}`, ...options });
1250
+ parent.children[index] = fromHtml(svg, { fragment: true, space: 'svg' }).children[0];
1251
+ });
1252
+ };
1253
+ };
1254
+ ```
1255
+
1256
+ To make those maps explorable in the browser, emit `<orbit-map>` elements instead of SVG and load `defineOrbitMapElement()` on the page.
1257
+
1258
+ ## Static sites, emails and print
1259
+
1260
+ `renderToSVGString` and the `orbit render` command draw a map without a browser:
1261
+
1262
+ ```bash
1263
+ npx @restless-dreams/orbit render roadmap.md --out roadmap.svg --theme night --width 1600 --height 1000
1264
+ npx @restless-dreams/orbit render roadmap.md --view all --out overview.svg
1265
+ ```
1266
+
1267
+ The SVG has its colors resolved and its labels as text, so it opens the same in browsers, design tools and image converters. Give each SVG inlined in one page its own `idPrefix`.
1268
+
1269
+ ---
1270
+
1271
+ <!-- docs/theming.md -->
1272
+
1273
+ # Theming
1274
+
1275
+ ## Themes
1276
+
1277
+ ![The fourteen themes: paper, night, glass, glass night, cosmos, blueprint, ink, contrast, flat, flat night, material, material night, liquid and liquid night](media/themes.webp)
1278
+
1279
+ | Theme | Looks like | Background |
1280
+ | --- | --- | --- |
1281
+ | `paper` (default) | Pastel planets with a soft colored shadow and a fine rim of their own color | Your page's |
1282
+ | `night` | Luminous planets that glow on a deep navy sky | Its own |
1283
+ | `glass` | Frosted, translucent planets with a bright rim | Its own pastel backdrop |
1284
+ | `glass-night` | Tinted glass planets that glow over an aurora | Its own |
1285
+ | `cosmos` | Jewel-toned planets lit from one side, around a golden star with a corona | Deep space with stars |
1286
+ | `blueprint` | Outlined planets, construction lines and monospaced labels | A gridded blue sheet |
1287
+ | `ink` | Black line art: outlined planets, a black sun and serif lettering | Your page's |
1288
+ | `contrast` | Black outlines, solid colors and bold labels, for the most legible map | White |
1289
+ | `flat` | Solid, saturated planets with white lettering: no shading, shadows or glow | Your page's |
1290
+ | `flat-night` | Bright solid planets with dark lettering, cut out from the page | Near black |
1291
+ | `material` | Material 3: tonal containers around a sun in the primary color (see [Material themes](#material-themes-from-any-color)) | A tonal surface |
1292
+ | `material-night` | Material 3's dark scheme: deep containers with light lettering | A dark tonal surface |
1293
+ | `liquid` | Liquid glass: clear planets that catch the light along their rims | A vivid wallpaper of its own |
1294
+ | `liquid-night` | Liquid glass at night, glowing in each planet's color | A deep, vivid wallpaper |
1295
+ | `auto` | `paper` or `night`, following the reader's light or dark preference | |
1296
+
1297
+ Choose one with the `theme` option, the `theme` attribute of `<orbit-map>`, the `theme` prop in React, or `theme:` in a map's frontmatter.
1298
+
1299
+ Your own themes get a name too: `registerTheme('brand', { preset: 'night', tokens: { … } })` makes `theme: brand` work in all of those places (see [Extending](extending.md#themes)). The same guide covers status words with your own colors and node types with their own look.
1300
+
1301
+ Labels keep at least 4.5:1 contrast with their node in every theme (WCAG AA), on every branch color, every shade below it and whichever node is in focus; `contrast` keeps more than 7:1 (AAA).
1302
+
1303
+ ## Material themes from any color
1304
+
1305
+ `materialTheme(color)` builds a Material 3 theme from one color, the way Material You colors an app from its wallpaper: tonal palettes in the color's hue, surfaces and containers chosen by tone, the sun in the primary color and each branch in its own hue, spread evenly around the color wheel from yours. Colors are chosen by tone, so labels and text stay readable whatever the color.
1306
+
1307
+ ```ts
1308
+ import { createOrbitMap, materialTheme, registerTheme } from '@restless-dreams/orbit';
1309
+
1310
+ createOrbitMap(element, markdown, { theme: materialTheme('#0B57D0') });
1311
+
1312
+ // Or name it, for `theme: brand` in frontmatter and <orbit-map theme="brand">:
1313
+ registerTheme('brand', materialTheme('#E11D48', { dark: true }));
1314
+ ```
1315
+
1316
+ It takes any color Orbit reads (hex, `rgb()`, `oklch()`…) and returns a theme object built on `material`, or `material-night` with `{ dark: true }`. The `material` themes themselves are built from Material's baseline purple, `MATERIAL_SEED`.
1317
+
1318
+ ## Custom properties
1319
+
1320
+ Everything a theme sets is a CSS custom property. Set them on the map's container, or anywhere above it, to restyle a map from your own stylesheet; they win over the theme:
1321
+
1322
+ ```css
1323
+ .roadmap {
1324
+ --orbit-font-family: 'Inter', sans-serif;
1325
+ --orbit-color-root: #ffd166;
1326
+ --orbit-color-1: #ef476f;
1327
+ --orbit-color-2: #06d6a0;
1328
+ --orbit-color-3: #118ab2;
1329
+ --orbit-focus-ring: #ef476f;
1330
+ }
1331
+ ```
1332
+
1333
+ For `<orbit-map>`, set them on the element (`orbit-map { --orbit-surface: … }`); they pass into its shadow root.
1334
+
1335
+ | Group | Properties |
1336
+ | --- | --- |
1337
+ | Fonts | `--orbit-sans-font`, `--orbit-serif-font`, `--orbit-mono-font` (the page's font of each kind, which themes draw with: see [Fonts](#fonts)), `--orbit-font-family` (the map's own font, for its controls and sheet, and by default its labels) |
1338
+ | Page | `--orbit-surface` (background color), `--orbit-surface-pattern` (CSS background layers drawn over it, or `none`), `--orbit-text`, `--orbit-muted-text` |
1339
+ | Node colors | `--orbit-color-root`, `--orbit-color-1` … `--orbit-color-7` (one per top-level branch, in order), `--orbit-shade-light` and `--orbit-shade-dark` (what deeper nodes are mixed toward) |
1340
+ | Planets | `--orbit-node-fill-opacity`, `--orbit-moon-opacity`, `--orbit-node-highlight` (the lit side), `--orbit-node-terminator` (where light turns to shadow), `--orbit-node-shading` (the far edge), `--orbit-node-shade-circle` (where the shading spreads, see below), `--orbit-note-indicator` |
1341
+ | Rims | `--orbit-node-stroke`, `--orbit-node-stroke-tint`, `--orbit-node-stroke-width` (in pixels; in a crowded orbit, a planet's rim is 4% of its radius per pixel, so it grows and shrinks with it), `--orbit-badge-stroke` (around status dots and content markers, which grow with their nodes) |
1342
+ | Shadow or glow | `--orbit-node-shadow`, `--orbit-node-shadow-tint`, `--orbit-node-shadow-opacity`, `--orbit-node-shadow-offset`, `--orbit-node-shadow-size`, `--orbit-sun-shadow-opacity`, `--orbit-sun-shadow-size` |
1343
+ | The sun's halo | `--orbit-sun-halo`, `--orbit-sun-halo-tint`, `--orbit-sun-halo-opacity`, `--orbit-sun-halo-width`, `--orbit-sun-halo-dash`, `--orbit-sun-halo-size` |
1344
+ | Orbits | `--orbit-ring-stroke`, `--orbit-ring-tint`, `--orbit-ring-opacity`, `--orbit-ring-width`, `--orbit-ring-dash` |
1345
+ | Lines (overview) | `--orbit-link-stroke`, `--orbit-link-tint`, `--orbit-link-opacity`, `--orbit-link-width`, `--orbit-link-dash` |
1346
+ | Labels | `--orbit-label-font`, `--orbit-display-font` (the node in focus and the detail sheet's title), `--orbit-label-weight`, `--orbit-display-weight`, `--orbit-label-letter-spacing`, `--orbit-root-text`, `--orbit-node-text`, `--orbit-node-text-inverse` (on dark nodes), `--orbit-label-shadow` (behind light labels) |
1347
+ | Focus | `--orbit-focus-ring` |
1348
+ | Controls | `--orbit-control-background`, `--orbit-control-border`, `--orbit-control-text`, `--orbit-control-muted-text`, `--orbit-control-active-background`, `--orbit-control-active-text`, `--orbit-control-shadow`, `--orbit-control-blur` |
1349
+ | Tooltip | `--orbit-tooltip-background`, `--orbit-tooltip-text` |
1350
+ | Content buttons | `--orbit-action-background`, `--orbit-action-border`, `--orbit-action-text`, `--orbit-action-shadow` |
1351
+ | Detail sheet and media viewer | `--orbit-sheet-background` (behind the viewer's media too), `--orbit-sheet-text`, `--orbit-sheet-muted-text`, `--orbit-sheet-border`, `--orbit-sheet-backdrop`, `--orbit-sheet-shadow` (under the viewer's videos too), `--orbit-sheet-blur`, `--orbit-chip-background`, `--orbit-chip-text`, `--orbit-link-text`. The viewer's buttons are the controls'. |
1352
+ | Status colors | `--orbit-status-active`, `--orbit-status-done`, `--orbit-status-blocked`, `--orbit-status-todo`, `--orbit-status-neutral` |
1353
+ | Error card | `--orbit-error-background`, `--orbit-error-border`, `--orbit-error-text` |
1354
+
1355
+ The list is also exported as `THEME_TOKENS`, and `themeTokens(name)` returns a theme's values, for example to style the rest of your page to match:
1356
+
1357
+ ```ts
1358
+ import { themeTokens } from '@restless-dreams/orbit';
1359
+
1360
+ const night = themeTokens('night');
1361
+ document.body.style.background = night.surface;
1362
+ ```
1363
+
1364
+ ## Fonts
1365
+
1366
+ Maps draw with the reader's system font, unless the page gives them its own. Themes use fonts by kind: most draw in a sans-serif, `blueprint` in a monospaced font and `ink` its names in a serif. Give the page's font of each kind, and every theme draws with it in its own way:
1367
+
1368
+ ```css
1369
+ orbit-map,
1370
+ .my-map {
1371
+ --orbit-sans-font: 'Google Sans Flex', system-ui, sans-serif; /* every sans-serif theme, and ink's controls */
1372
+ --orbit-serif-font: 'Source Serif 4', Georgia, serif; /* ink's names */
1373
+ --orbit-mono-font: 'JetBrains Mono', ui-monospace, monospace; /* blueprint */
1374
+ }
1375
+ ```
1376
+
1377
+ `--orbit-font-family` sets one map's font whatever its theme, and `--orbit-label-font` and `--orbit-display-font` only its names and the focused node's.
1378
+
1379
+ Names are measured in the font they're drawn in, so a name fills its node as far as it really reaches and never spills out of it, whatever the font, its weight or its letter spacing:
1380
+
1381
+ - **Lines** break at spaces, and inside long identifiers where they read, balanced so a name follows its node's round shape ("Lanzamiento / en México" rather than "Lanzamiento en / México").
1382
+ - **Narrower widths:** with a font that has a width axis (Google Sans Flex, Roboto Flex, Roboto and others), a name that would otherwise be cut short is drawn semi-condensed or condensed when that shows it whole. Other fonts keep their width.
1383
+ - **Web fonts:** names are fitted again when a font finishes loading, so a map drawn before its font arrives settles into it. The page doesn't need to wait for its fonts before drawing a map.
1384
+ - **Exports** break names where the map shows them. Without a browser to measure in (`renderToSVGString` on a server), widths are estimated from the kind of each character.
1385
+
1386
+ ## Shaping planets
1387
+
1388
+ Rims, shadows, the sun's halo, orbits and lines take the color of the node they belong to, mixed with a color of the theme. The `-tint` properties say how much of the node's color goes in: `--orbit-node-stroke-tint: 78%` draws the rim with 78% of the node's color and 22% of `--orbit-node-stroke`; `100%` is the node's color alone and `0%` the theme's color alone.
1389
+
1390
+ Widths are in screen pixels; `-offset` and `-size` are in node radii, so `--orbit-node-shadow-offset: 0.16` drops the shadow by about a sixth of the node's radius. Dashes take a `stroke-dasharray` (`4 4`, or `none` for a solid line).
1391
+
1392
+ ```css
1393
+ /* Flat planets with a dark outline and no shadow */
1394
+ .roadmap {
1395
+ --orbit-node-highlight: transparent;
1396
+ --orbit-node-shading: transparent;
1397
+ --orbit-node-stroke: #1f2733;
1398
+ --orbit-node-stroke-tint: 0%;
1399
+ --orbit-node-stroke-width: 2;
1400
+ --orbit-node-shadow-opacity: 0;
1401
+ --orbit-sun-shadow-opacity: 0;
1402
+ }
1403
+
1404
+ /* A glow in each planet's own color instead of a shadow */
1405
+ .roadmap {
1406
+ --orbit-node-shadow-tint: 100%;
1407
+ --orbit-node-shadow-offset: 0;
1408
+ --orbit-node-shadow-size: 1.2;
1409
+ --orbit-node-shadow-opacity: 0.45;
1410
+ }
1411
+ ```
1412
+
1413
+ The shading is one radial gradient over every node, from `--orbit-node-highlight` at its center through `--orbit-node-terminator` to `--orbit-node-shading` at its edge. `--orbit-node-shade-circle` places it: its center, then its radius, in fractions of the node's box. The default, `0.34 0.3 0.78`, lights spheres from the top left. A circle a little to the bottom right with bright outer colors makes glass instead, catching the light along the rim, as `liquid` does:
1414
+
1415
+ ```css
1416
+ /* Clear glass, with light along the rim */
1417
+ .roadmap {
1418
+ --orbit-node-fill-opacity: 0.25;
1419
+ --orbit-node-highlight: transparent;
1420
+ --orbit-node-terminator: rgba(16, 24, 60, 0.07);
1421
+ --orbit-node-shading: rgba(255, 255, 255, 0.95);
1422
+ --orbit-node-shade-circle: 0.53 0.55 0.56;
1423
+ }
1424
+ ```
1425
+
1426
+ The circle is read when the theme is set, and is the same for every node of a map.
1427
+
1428
+ ## Colors in code
1429
+
1430
+ ```ts
1431
+ createOrbitMap(element, markdown, {
1432
+ theme: { preset: 'night', tokens: { surface: '#0b1020', 'font-family': 'Inter, sans-serif', 'ring-dash': 'none' } },
1433
+ palette: ['#ffd166', '#ef476f', '#06d6a0', '#118ab2'],
1434
+ });
1435
+ ```
1436
+
1437
+ - `theme` takes a theme name or an object. The object's `preset` picks the theme to start from (without one, the document's theme or `paper`), and `tokens` sets any property by name, without the `--orbit-` prefix. Properties set in the page's CSS still win.
1438
+ - `palette` replaces the node colors: the first for the root, then one per top-level branch, cycling when there are more branches than colors.
1439
+ - A node's own `color` (`<!-- orbit color: #ef476f -->`, or `color:` in YAML) always wins.
1440
+
1441
+ ## How nodes get their colors
1442
+
1443
+ Each top-level branch takes the next palette color, and everything under it uses shades of that color, mixed toward `--orbit-shade-light` or `--orbit-shade-dark` by position, so siblings stay distinguishable and a node keeps its color in every view. Labels use whichever of the theme's dark and light text has more contrast with the node, custom colors included.
1444
+
1445
+ ## Dark mode
1446
+
1447
+ - `theme: 'auto'` follows the reader's system preference.
1448
+ - To follow your site's own switch, set the theme when it changes: `renderer.setOptions({ theme: isDark ? 'night' : 'paper' })`, or update the `theme` attribute or prop.
1449
+ - `night`, `glass-night`, `cosmos`, `blueprint`, `flat-night`, `material-night` and `liquid-night` are dark: they tell the browser so (`color-scheme: dark`), which darkens scrollbars and form controls in the detail sheet.
1450
+ - Themes that paint their own background can let your page show through instead: set `--orbit-surface: transparent` and `--orbit-surface-pattern: none`.
1451
+
1452
+ ## Exports
1453
+
1454
+ `renderToSVGString(markdown, { theme })` and `orbit render --theme` resolve every property to a plain color, so exported files look the same anywhere. In the browser, `renderer.toSVGString()` also picks up the custom properties your page sets. Background patterns are CSS, so exports keep the `--orbit-surface` color without them.
1455
+
1456
+ ---
1457
+
1458
+ <!-- docs/extending.md -->
1459
+
1460
+ # Extending Orbit
1461
+
1462
+ Orbit has four ways to make maps your own: **node types**, **actions**, **status words** and **themes**. Each works for one map, through its options, or for every map once registered, which also covers `<orbit-map>` elements and maps in markdown pages, where you don't write code per map. A map's own options win over registered ones.
1463
+
1464
+ | | For one map | For every map |
1465
+ | --- | --- | --- |
1466
+ | Node types | `nodeTypes: { milestone: { … } }` | `registerNodeType('milestone', { … })` |
1467
+ | Actions | `actions: [{ id, label, run }]` | `registerAction({ id, label, run })` |
1468
+ | Status words | `statuses: { review: 'active' }` | `registerStatus('review', 'active')` |
1469
+ | Themes | `theme: { preset, tokens }` | `registerTheme('brand', { preset, tokens })` |
1470
+
1471
+ Each `register…` function returns a function that removes the registration again.
1472
+
1473
+ ## Node types
1474
+
1475
+ A node picks a type with the `type` attribute, in any case:
1476
+
1477
+ ```markdown
1478
+ # Launch
1479
+ ## v1.0 beta <!-- orbit type: milestone -->
1480
+ - Ana Torres <!-- orbit type: person, role: Design -->
1481
+ ```
1482
+
1483
+ In YAML it's `type: milestone`. The type decides how those nodes look and behave:
1484
+
1485
+ ```js
1486
+ import { createOrbitMap } from '@restless-dreams/orbit';
1487
+
1488
+ createOrbitMap(element, markdown, {
1489
+ nodeTypes: {
1490
+ milestone: {
1491
+ label: 'Milestone',
1492
+ icon: '🏁',
1493
+ color: '#F59E0B',
1494
+ tokens: { 'node-stroke': '#B45309', 'node-stroke-width': '3' },
1495
+ },
1496
+ person: {
1497
+ label: 'Person',
1498
+ render(node, element) {
1499
+ element.textContent = node.text.split(' ').map((word) => word[0]).join('');
1500
+ },
1501
+ details(node, element) {
1502
+ element.textContent = `Role: ${node.attrs?.role ?? 'not set'}`;
1503
+ },
1504
+ actions: [{ id: 'email', label: 'Email', icon: '✉️', run: (node) => window.open(`mailto:${node.attrs?.email}`) }],
1505
+ },
1506
+ },
1507
+ });
1508
+ ```
1509
+
1510
+ | Key | What it does |
1511
+ | --- | --- |
1512
+ | `label` | The type's name for people, shown as a chip in the detail sheet, in the tooltip and to screen readers. |
1513
+ | `icon` | The icon these nodes get unless they set their own. |
1514
+ | `color` | The color these nodes get unless they set their own. Their children take shades of it. |
1515
+ | `tokens` | [Theme tokens](theming.md#custom-properties) for these nodes only, without `--orbit-`: their rim, shadow, glow, label and status colors. |
1516
+ | `render(node, element)` | Draws inside the node, the way a picture does. `element` is a 100 × 100 px box, clipped to the node's circle and scaled with it, and the label moves outside, onto a pill that shows the node's icon before its name. Return a function to clean up; it runs when the node leaves the map. |
1517
+ | `details(node, element)` | Adds a section to the detail sheet, above the note. These nodes get a "Show details" button. |
1518
+ | `actions` | Actions only these nodes offer, after the map's own. |
1519
+ | `version` | The version of the fields these nodes keep in their attributes, once the type changes them (1 at first). See [Versions](#versions). |
1520
+ | `upgrade(node, from)` | Brings a node written for an older version of the type up to date. |
1521
+
1522
+ - **Custom attributes** like `<!-- orbit role: Design -->` are on `node.attrs`, which makes them handy in `render` and `details`.
1523
+ - **`render` is for showing:** clicks go through to the node, so use actions for anything to press. It runs when the node comes on the map; navigating away and back draws it again.
1524
+ - **Errors stay contained:** if a type's `render` or `details`, or an action, throws, the console says which one and the map keeps working.
1525
+
1526
+ ### Types for display only
1527
+
1528
+ `nodeTypeFor(node)` gives a node that names no type one to be drawn as, as if it said `type: name`: that type's look, what it draws inside and its actions. It's for display only: Orbit never writes it to the map, so saving keeps the node as it was, and never upgrades a node for it.
1529
+
1530
+ ```js
1531
+ createOrbitMap(element, markdown, {
1532
+ nodeTypes: { progress: { label: 'Progress', render: drawProgressRing } },
1533
+ // Ideas with to-dos right under them show a ring, unless they have a type of their own.
1534
+ nodeTypeFor: (node) => (node.children.some((child) => child.status) ? 'progress' : undefined),
1535
+ });
1536
+ ```
1537
+
1538
+ - **A node's own `type` wins:** `nodeTypeFor` is only asked about nodes without one.
1539
+ - **It's asked whenever the map draws,** often, so keep it quick, and return `undefined` to draw a node as it is. An answer that changes with the map, after an edit to the node's children say, shows as the map draws the edit; after a change elsewhere that answers depend on, `render()` the map again.
1540
+ - **The type shows wherever a type does:** in the detail sheet's chip, the tooltip, what screen readers hear, `data-orbit-type`, the label's font and exports (`renderToSVGString(source, { nodeTypes, nodeTypeFor })`).
1541
+ - **If it throws,** the console says so once, and nodes are drawn as they are.
1542
+
1543
+ ### Versions
1544
+
1545
+ A type's nodes keep their own fields in attributes, so changing those fields changes what older maps say. Give the type a `version` and an `upgrade` that brings older nodes up to date:
1546
+
1547
+ ```js
1548
+ registerNodeType('task', {
1549
+ label: 'Task',
1550
+ version: 2,
1551
+ // Version 1 kept the person in `assignee`; version 2 calls it `owner`.
1552
+ upgrade(node, from) {
1553
+ if (from < 2 && node.attrs?.assignee !== undefined) {
1554
+ const { assignee, ...rest } = node.attrs;
1555
+ node.attrs = { ...rest, owner: assignee };
1556
+ }
1557
+ },
1558
+ });
1559
+ ```
1560
+
1561
+ - **Nodes say which version they were written for:** `type: task@2`; none means 1. A map upgrades older nodes when it draws them, and the next save writes them as `task@2` with their new fields, changing nothing else in the file.
1562
+ - **Nodes written for a newer version** than the type knows are left as they are, and saving keeps everything in them.
1563
+ - **Without a map** (on a server, in a script), `upgradeNodeTypes(document.root, nodeTypes)` does the same, with the types you pass and the registered ones, and returns how many nodes it upgraded.
1564
+ - **If `upgrade` throws,** the console says which type failed and the node stays as it was.
1565
+
1566
+ ## Actions
1567
+
1568
+ Actions are buttons under the node in focus, after Orbit's own (details, picture, video, note, link), and in the node's detail sheet.
1569
+
1570
+ ```js
1571
+ createOrbitMap(element, markdown, {
1572
+ actions: [
1573
+ {
1574
+ id: 'assign',
1575
+ label: 'Assign',
1576
+ icon: '👤',
1577
+ when: (node) => node.children.length === 0,
1578
+ run: (node, event) => openAssignDialog(node),
1579
+ },
1580
+ ],
1581
+ });
1582
+ ```
1583
+
1584
+ | Key | What it does |
1585
+ | --- | --- |
1586
+ | `id` | The action's name in the `action` event. Use ids other than Orbit's own: `details`, `image`, `video`, `embed`, `note` and `link`. |
1587
+ | `label` | The button's name, read by screen readers, shown as its tooltip and in the detail sheet. A function gets the node: ``(node) => `Assign ${node.text}` ``. |
1588
+ | `icon` | An emoji or short symbol (`'✏️'`), SVG path data on a 24-unit grid (`'M4 20h4L20 8l-4-4L4 16z'`), or one of Orbit's icons: `note`, `link`, `goto`, `info`, `image`, `video`, `embed`, `list`. Without one, the label's first letter. |
1589
+ | `when(node)` | Which nodes offer it. Default: every node. |
1590
+ | `run(node, event)` | What it does. |
1591
+
1592
+ - **The `action` event fires first** (`orbit-action` on the element), with `action` set to the id. `preventDefault()` there, or returning `false` from `onAction`, skips `run`.
1593
+ - **In the detail sheet,** pressing an action closes the sheet first, so a dialog your action opens is in front.
1594
+ - **One button per id:** a map's own action wins over a registered one with the same id, and a node type's actions come last.
1595
+
1596
+ ## Status words
1597
+
1598
+ `status` is free text, and [some words](format.md#statuses) get a colored dot. Add your own words, as one of the tones or with a color of their own:
1599
+
1600
+ ```js
1601
+ createOrbitMap(element, markdown, {
1602
+ statuses: {
1603
+ review: 'active',
1604
+ shipped: 'done',
1605
+ 'on hold': '#A855F7',
1606
+ },
1607
+ });
1608
+ ```
1609
+
1610
+ The tones are `active`, `done`, `blocked`, `todo` and `neutral`; their colors come from the theme (`--orbit-status-active`, …). Words match in any case.
1611
+
1612
+ ## Themes
1613
+
1614
+ Register a theme once to use it by name everywhere: in options, in a document's frontmatter and on `<orbit-map theme="…">`.
1615
+
1616
+ ```js
1617
+ import { registerTheme } from '@restless-dreams/orbit';
1618
+
1619
+ registerTheme('brand', {
1620
+ preset: 'night',
1621
+ tokens: { 'color-root': '#FFD166', 'color-1': '#06D6A0', surface: '#0B1020' },
1622
+ });
1623
+ ```
1624
+
1625
+ ```markdown
1626
+ ---
1627
+ theme: brand
1628
+ ---
1629
+ # Launch
1630
+ ```
1631
+
1632
+ - **What a theme is:** it starts from `preset` (a built-in theme or another registered one) and sets its own [tokens](theming.md#custom-properties).
1633
+ - **Building on it:** a theme object can start from a registered theme too. `{ preset: 'brand', tokens: { … } }` adds its tokens to brand's.
1634
+ - **Names:** built-in names can't be reused, and `themeNames()` lists every name.
1635
+ - **Validation:** `orbit validate` doesn't know the themes your code registers, so it reports `theme: brand` as unknown.
1636
+
1637
+ ## For every map
1638
+
1639
+ Register at startup, before maps are created. Maps created earlier pick up registered types, actions and words when they next redraw.
1640
+
1641
+ ```js
1642
+ import { defineOrbitMapElement, registerAction, registerNodeType, registerStatus, registerTheme } from '@restless-dreams/orbit';
1643
+
1644
+ registerTheme('brand', { preset: 'night', tokens: { 'color-root': '#FFD166' } });
1645
+ registerNodeType('milestone', { label: 'Milestone', icon: '🏁', color: '#F59E0B' });
1646
+ registerStatus('review', 'active');
1647
+ registerAction({ id: 'copy-link', label: 'Copy link', icon: 'link', run: (node) => navigator.clipboard.writeText(`${location.href}#${node.id}`) });
1648
+ defineOrbitMapElement();
1649
+ ```
1650
+
1651
+ ```html
1652
+ <orbit-map theme="brand">
1653
+ # Launch
1654
+ ## v1.0 beta <!-- orbit type: milestone, status: review -->
1655
+ </orbit-map>
1656
+ ```
1657
+
1658
+ The functions they return remove the registrations again, which helps with hot reloading and tests.
1659
+
1660
+ ## In React
1661
+
1662
+ `<OrbitMindMap>` takes the same options as props. Inline objects are fine: the map compares what node types and actions declare, and calls their latest functions, `nodeTypeFor`'s too.
1663
+
1664
+ ```tsx
1665
+ import { OrbitMindMap } from '@restless-dreams/orbit/react';
1666
+
1667
+ export const Roadmap = ({ markdown, onAssign }: { markdown: string; onAssign: (id: string) => void }) => {
1668
+ return (
1669
+ <div style={{ height: 480 }}>
1670
+ <OrbitMindMap
1671
+ source={markdown}
1672
+ nodeTypes={{ milestone: { label: 'Milestone', icon: '🏁', color: '#F59E0B' } }}
1673
+ actions={[{ id: 'assign', label: 'Assign', icon: '👤', run: (node) => onAssign(node.id) }]}
1674
+ statuses={{ review: 'active' }}
1675
+ />
1676
+ </div>
1677
+ );
1678
+ };
1679
+ ```
1680
+
1681
+ ## Styling nodes with CSS
1682
+
1683
+ Each node's element and label carry hooks for CSS:
1684
+ - `data-orbit-type`: the type, lowercase;
1685
+ - `data-orbit-status`: the status word, lowercase;
1686
+ - `data-orbit-tags`: the tags, separated by spaces.
1687
+
1688
+ Page CSS can target these on maps drawn with `createOrbitMap` or React:
1689
+
1690
+ ```css
1691
+ .orbit-node[data-orbit-type='milestone'] .orbit-node__shade {
1692
+ stroke-dasharray: 4 3;
1693
+ }
1694
+
1695
+ .orbit-label[data-orbit-tags~='urgent'] {
1696
+ font-weight: 800;
1697
+ }
1698
+ ```
1699
+
1700
+ A map inside `<orbit-map>` sits in a shadow root that page CSS can't reach, so use a type's `tokens` there.
1701
+
1702
+ ## Exports
1703
+
1704
+ `toSVGString()`, `renderToSVGString()` and `orbit render` draw node types' icons, colors and tokens, and your status colors. `renderToSVGString(source, { nodeTypes, statuses, nodeTypeFor })` takes them as options too. What `render` draws is HTML, so it isn't part of an exported SVG.
1705
+
1706
+ ---
1707
+
1708
+ <!-- docs/editing.md -->
1709
+
1710
+ # Editing on the map
1711
+
1712
+ People can build a map on the map itself. A **+** in the orbit adds an idea, typed right inside its new planet, a node's text is edited where it is, and a node is dragged to wherever it should go. Each edit animates into place, and the map comes back as orbit-md to save.
1713
+
1714
+ ## Turn it on
1715
+
1716
+ ```js
1717
+ import { createOrbitMap } from '@restless-dreams/orbit';
1718
+
1719
+ const map = createOrbitMap(element, markdown, {
1720
+ editable: true,
1721
+ onEdit: () => save(map.toMarkdown()),
1722
+ });
1723
+ ```
1724
+
1725
+ With `editable`:
1726
+
1727
+ - **A + in the orbit.** In focus mode, the orbit keeps a free place after the planets, drawn as a dashed planet with a plus.
1728
+ - Press it and type: the text goes inside the new planet.
1729
+ - Enter adds the idea right where it was typed and opens the next one after it, so ideas go in one after another, the keyboard staying up.
1730
+ - Escape, or Enter with nothing typed, stops.
1731
+ - **Text edited where it is.** A long press, a right click or F2 on a node turns its label into a field.
1732
+ - Enter saves, and Escape leaves the text as it was. Tapping anywhere else saves.
1733
+ - A moon flies into its planet's orbit first, so its text has room. In the overview, small nodes get a field floating over them, at a size you can read.
1734
+ - **Nodes dragged somewhere else** (see [Dragging nodes](#dragging-nodes)).
1735
+
1736
+ `<orbit-map editable>` and `<OrbitMindMap editable>` turn it on too.
1737
+
1738
+ ## Your own menu
1739
+
1740
+ To offer more than editing text, show your own menu when a node is long-pressed, right-clicked, or chosen with the context menu key:
1741
+
1742
+ ```js
1743
+ const map = createOrbitMap(element, markdown, {
1744
+ editable: true,
1745
+ onNodeContext: ({ node, x, y }) => showMenu(x, y, [
1746
+ { label: 'Rename', run: () => map.editNode(node.id) },
1747
+ { label: 'Add an idea under it', run: () => map.editNewNode(node.id) },
1748
+ { label: 'Mark done', run: () => map.updateNode(node.id, { status: 'done' }) },
1749
+ { label: 'Delete', run: () => map.removeNode(node.id) },
1750
+ ]),
1751
+ onEdit: () => save(map.toMarkdown()),
1752
+ });
1753
+ ```
1754
+
1755
+ - **What `node-context` reports:** a long press (half a second, held still; `longPressDelay` changes it) with a finger or a pen, a right click, or the context menu key or Shift+F10 on the node with keyboard focus. `x` and `y` are where to put your menu, in CSS pixels from the map's top left corner: the press, or the node's center for the keyboard.
1756
+ - **The press is yours** with an `onNodeContext` callback or a `node-context` listener (`map.on('node-context', …)`). It doesn't open the node, it doesn't start editing the text, and the browser's own menu stays closed. A listener for the `orbit-node-context` DOM event takes it by calling `event.preventDefault()`.
1757
+ - **Without any of them,** a long press edits the text on an editable map, once it's let go without moving: moving it drags the node instead. On other maps, it is an ordinary tap and a right click opens the browser's menu.
1758
+ - **The same long press can drag the node.** Your menu opens as the press is held; if the finger then moves, `node-drag` fires with `phase: 'start'`, and that's the moment to close the menu: the node is on its way somewhere else.
1759
+
1760
+ ## Dragging nodes
1761
+
1762
+ On an editable map, a node held still lifts: a long press with a finger or a pen, or the mouse held down half a second. Move it, and it follows the press, its moons with it.
1763
+
1764
+ - **Onto another node:** that node grows and takes a ring. Let go, and the dragged node goes into it, after its last child.
1765
+ - **Along the orbit:** a planet keeps its place held, an empty ring where it was, so nothing slides under the finger. Carried along the ring, the place moves as it passes each planet, and the others make room. A planet catches a drop only near its middle; over its rim, the press is on the orbit, placing the node beside it. A moon or a deeper node carried to the orbit takes the + as its place.
1766
+ - **Let go anywhere else,** and it lands in the place held for it. One that never left its place goes back down. Escape, or a second finger, puts it back.
1767
+ - **What can't happen:** a node can't go inside itself or anything under it, the sun and the root don't lift, and a map that isn't editable never lifts a node.
1768
+
1769
+ Each drop is a `moveNode`: it fires `edit` with `kind: 'move'`, the node flies from where it was let go to its place, and Orbit announces it. A mouse that's let go without moving still clicks, so a slow click opens the node as usual.
1770
+
1771
+ ```js
1772
+ const map = createOrbitMap(element, markdown, {
1773
+ editable: true,
1774
+ onNodeContext: ({ node, x, y }) => showMenu(x, y, node),
1775
+ onNodeDrag: ({ phase }) => {
1776
+ if (phase === 'start') {
1777
+ closeMenu();
1778
+ }
1779
+ },
1780
+ onEdit: () => save(map.toMarkdown()),
1781
+ });
1782
+ ```
1783
+
1784
+ `node-drag` reports each step: `start` as the node leaves its place (with where it was), `over` whenever where it would go changes (`parent` and `index`, or `null` over no place, for haptics or a hint), and `end` when it's let go (where it went, after the `edit`; `null` when it went back).
1785
+
1786
+ ## Keys for editing
1787
+
1788
+ With `editingKeys` as well as `editable`, the keyboard builds a map the way mind-map apps do:
1789
+
1790
+ ```js
1791
+ const map = createOrbitMap(element, markdown, { editable: true, editingKeys: true });
1792
+ ```
1793
+
1794
+ | Key | On a node with keyboard focus | While typing |
1795
+ | --- | --- | --- |
1796
+ | Tab | Adds an idea under it (Insert does too) | Keeps what was typed and goes on under it. With nothing typed, moves the field under the idea before. |
1797
+ | Shift+Tab | Leaves the map, as usual | Keeps what was typed and goes on a level up, beside the parent |
1798
+ | Shift+Enter | Adds an idea beside it, right after it | |
1799
+ | Enter | Flies into it, as without the keys | Keeps what was typed and goes on beside it |
1800
+ | Delete | Removes it, with everything under it | |
1801
+
1802
+ - **A whole map without the mouse:** Tab on the sun and type. Enter brings the next idea, Tab goes under the one just typed, Shift+Tab comes back up, and Escape stops.
1803
+ - **Tab stays in the map.** Without the keys the map is one tab stop, and Tab leaves it. With them, Tab on a node adds an idea, and Shift+Tab leaves. Turn them on where the map is the page's main tool, such as an editor, rather than for a map among other controls.
1804
+ - **After Delete,** keyboard focus moves on to the node after the removed one, or the one before it, or its parent. The root stays. To offer undo, keep the markdown from before each edit (see [Saving](#saving)).
1805
+
1806
+ ## The editing methods
1807
+
1808
+ | Method | What it does |
1809
+ | --- | --- |
1810
+ | `editNode(id)` | Opens the field over a node, to type its text where it is. |
1811
+ | `editNewNode(parentId?, index?)` | Opens the field in the orbit of `parentId` (default: the node in focus), at `index` among its children (default: after the last). It flies there first. |
1812
+ | `stopEditing(save?)` | Closes the field, keeping what was typed unless `save` is false. |
1813
+ | `addNode(parentId, { text, …, children }, index?)` | Adds a node under `parentId`, at `index` among its children (default: last), with the nodes `children` lists under it, as many levels down as you like. Returns the new node. |
1814
+ | `updateNode(id, changes)` | Changes text and attributes: `{ text: 'Budget' }`, `{ status: 'done', color: '#E8A33D' }`. `undefined` removes one: `{ color: undefined }`. Returns the node. |
1815
+ | `removeNode(id)` | Removes a node and everything under it. The root stays. Returns the removed node. |
1816
+ | `moveNode(id, parentId, index?)` | Moves a node, with everything under it, under `parentId` at `index`. Under the same parent, it changes the order. Returns the node. |
1817
+ | `getDocument()` | The map as a document: its tree, and the options its frontmatter set. |
1818
+ | `toMarkdown()` | The map as orbit-md, frontmatter included. |
1819
+
1820
+ To let a language model make these changes, see [Map tools for AI](map-tools.md).
1821
+
1822
+ `editNode` and `editNewNode` return false, and the others `null`, when there's nothing to do: an id that isn't in the map, removing or moving the root, or moving a node inside itself. They work on any interactive map. `editable` adds the ways people edit by themselves: the +, editing on a long press, a right click, the context menu key or F2, and dragging.
1823
+
1824
+ Besides `text`, `addNode` and `updateNode` take the node's attributes: `icon`, `color`, `status`, `priority`, `tags`, `collapsed`, `type`, `link`, `note`, `noteFormat`, `image`, `video`, `embed`, and `attrs` for your own.
1825
+
1826
+ - **Ids come from the text,** as when a map is read from markdown: "Night markets" is `night-markets`, and a second "Ideas" is `ideas-2`. New text gives a node a new id, so the saved file needs no `id` attribute. A node keeps its id when a `#id` link points at it, or when the id was set by hand. Read a node's current id from what the method returns.
1827
+ - **Titles are one line:** line breaks become spaces, typed or pasted.
1828
+ - **The reader keeps their place.** When the node in focus, or a node above it, is removed, the nearest node left takes the focus and `focus-change` fires. A node in focus that gets a new id stays in focus.
1829
+ - **Edits change the tree in place:** `getDocument().root`, which is the tree you passed to `render()` if you passed one.
1830
+
1831
+ ## Events
1832
+
1833
+ | Event | DOM event | Option | Detail |
1834
+ | --- | --- | --- | --- |
1835
+ | `edit` | `orbit-edit` | `onEdit` | `{ kind, node, tree }`: `kind` is `add`, `update`, `remove` or `move` |
1836
+ | `editor-open` | `orbit-editor-open` | `onEditorOpen` | `{ node, parent }`: the node whose text is typed, or `node: null` and the `parent` of a new idea |
1837
+ | `editor-close` | `orbit-editor-close` | `onEditorClose` | the same |
1838
+ | `node-context` | `orbit-node-context` | `onNodeContext` | `{ node, x, y }` |
1839
+ | `node-drag` | `orbit-node-drag` | `onNodeDrag` | `{ phase, node, parent, index }`: `phase` is `start`, `over` or `end` |
1840
+
1841
+ The field's events help a page around the map: hide a button that would cover the keyboard, say, or bring a phone's keyboard up when an app opens the field itself.
1842
+
1843
+ ## Saving
1844
+
1845
+ Save the map on every `edit`:
1846
+
1847
+ ```js
1848
+ map.on('edit', () => localStorage.setItem('map', map.toMarkdown()));
1849
+ ```
1850
+
1851
+ - **`toMarkdown()` reads back as the same map,** ids included, and keeps the file as it was written: only the ideas an edit changed are written again, so a note written by hand or by another app keeps its formatting and everything Orbit doesn't read (see [What saving keeps](format.md#what-saving-keeps)). New ideas are written the way people write maps: headings for the root and its children, lists below them, checkboxes for statuses, hashtags for tags. For YAML, use `serializeOrbitYAML(map.getDocument())`.
1852
+ - **A map written for a newer version of the format** has an `unsupported-schema-version` diagnostic: open it without `editable`, so nothing is changed that this version doesn't understand.
1853
+ - **Drawing the saved text again is fine.** When `render()` gets the map as it already is, Orbit keeps the view, and an edit's animation carries on.
1854
+ - **Undo is yours to keep:** save the markdown before an edit, and `render()` it to go back. Orbit animates to the restored map and keeps the reader's place.
1855
+
1856
+ ## In React
1857
+
1858
+ `<OrbitMindMap>` takes `editable`, `onEdit`, `onNodeContext`, `onNodeDrag`, `onEditorOpen` and `onEditorClose`, and the ref's `renderer` has the methods:
1859
+
1860
+ ```tsx
1861
+ import { useRef, useState } from 'react';
1862
+ import { OrbitMindMap, type OrbitMindMapRef } from '@restless-dreams/orbit/react';
1863
+
1864
+ export const Brainstorm = ({ initial }: { initial: string }) => {
1865
+ const map = useRef<OrbitMindMapRef>(null);
1866
+ const [source, setSource] = useState(initial);
1867
+ return (
1868
+ <div style={{ height: 480 }}>
1869
+ <OrbitMindMap
1870
+ ref={map}
1871
+ source={source}
1872
+ editable
1873
+ onEdit={() => setSource(map.current?.renderer?.toMarkdown() ?? source)}
1874
+ />
1875
+ </div>
1876
+ );
1877
+ };
1878
+ ```
1879
+
1880
+ Setting `source` to what the map just wrote doesn't draw it again.
1881
+
1882
+ ## In `<orbit-map>`
1883
+
1884
+ ```html
1885
+ <orbit-map editable>
1886
+ # Weekend in Lisbon
1887
+ ## Food
1888
+ ## Places
1889
+ </orbit-map>
1890
+ ```
1891
+
1892
+ The element's `renderer` has the same methods, and its events bubble out of it: listen for `orbit-edit` and save `element.renderer.toMarkdown()`. Edits change the map, not the element's text. `<orbit-map editable editing-keys>` turns on the [keys for editing](#keys-for-editing) too.
1893
+
1894
+ ## Keyboard and screen readers
1895
+
1896
+ - **F2** edits the text of the node with keyboard focus. With `editingKeys`, Tab, Shift+Enter and Delete add and remove ideas too (see [Keys for editing](#keys-for-editing)).
1897
+ - **The +** takes its turn among the planets with `←` and `→`; Enter or Space on it adds an idea.
1898
+ - **The context menu key** and Shift+F10 open your menu for the node with keyboard focus, so whatever a long press offers works from the keyboard too.
1899
+ - **The field** is named after what it's for ("Text of Food", "New idea for Trip"), and when it closes with Enter or Escape, keyboard focus returns to the node, or to the +.
1900
+ - **Announcements:** Orbit announces added, removed and moved nodes in its live region ("Added Night markets").
1901
+ - **Moving without dragging:** dragging needs a pointer, so offer a way to move nodes in your menu too, with `moveNode` (earlier, later, in, out), as the keyboard can reach it.
1902
+
1903
+ ---
1904
+
1905
+ <!-- docs/map-tools.md -->
1906
+
1907
+ # Map tools for AI
1908
+
1909
+ Map tools let a language model read a map and change it: add ideas and whole branches, rename them, mark them done, move, sort and remove them. They come in the shape each provider's API takes, so an app can offer an assistant with whichever model its people choose. They work on a map on screen, where every change animates into place, or on a document on a server. A call a model gets wrong changes nothing, and its result says what to send instead.
1910
+
1911
+ ## Add an assistant to a map
1912
+
1913
+ `createMapTools(map)` gives the tools for one map. `tools.for(provider)` lists them for the model, and `tools.run(name, input)` carries out each call it makes. With Anthropic's API:
1914
+
1915
+ ```js
1916
+ import Anthropic from '@anthropic-ai/sdk';
1917
+ import { createOrbitMap, createMapTools } from '@restless-dreams/orbit';
1918
+
1919
+ const map = createOrbitMap(element, markdown, { onEdit: () => save(map.toMarkdown()) });
1920
+ const tools = createMapTools(map);
1921
+ const client = new Anthropic({ apiKey });
1922
+
1923
+ const ask = async (request) => {
1924
+ const messages = [{ role: 'user', content: request }];
1925
+
1926
+ while (true) {
1927
+ const response = await client.messages.create({ model, max_tokens: 4096, system: tools.instructions, tools: tools.for('anthropic'), messages });
1928
+ messages.push({ role: 'assistant', content: response.content });
1929
+ const calls = response.content.filter((block) => block.type === 'tool_use');
1930
+
1931
+ if (calls.length === 0) {
1932
+ return response.content.find((block) => block.type === 'text')?.text;
1933
+ }
1934
+
1935
+ messages.push({
1936
+ role: 'user',
1937
+ content: calls.map((call) => {
1938
+ const result = tools.run(call.name, call.input);
1939
+ return { type: 'tool_result', tool_use_id: call.id, content: result.text, is_error: !result.ok };
1940
+ }),
1941
+ });
1942
+ }
1943
+ };
1944
+
1945
+ await ask('Add three day trips from Kyoto, each with a one-sentence note');
1946
+ ```
1947
+
1948
+ The map changes as the model works: each new idea flies out of its parent, and `edit` fires for each change, so the map saves as it does when people edit it. Keep API keys off public pages: call the model from your server, or let each person bring their own key.
1949
+
1950
+ ## Other providers
1951
+
1952
+ **OpenAI's Responses API:**
1953
+
1954
+ ```js
1955
+ const input = [{ role: 'user', content: request }];
1956
+
1957
+ while (true) {
1958
+ const response = await openai.responses.create({ model, instructions: tools.instructions, tools: tools.for('openai'), input });
1959
+ const calls = response.output.filter((item) => item.type === 'function_call');
1960
+
1961
+ if (calls.length === 0) {
1962
+ return response.output_text;
1963
+ }
1964
+
1965
+ input.push(...response.output);
1966
+
1967
+ for (const call of calls) {
1968
+ input.push({ type: 'function_call_output', call_id: call.call_id, output: tools.run(call.name, call.arguments).text });
1969
+ }
1970
+ }
1971
+ ```
1972
+
1973
+ `run` takes the arguments as OpenAI sends them, as JSON text. The tools are written without strict mode, which would make the model send every field of every call.
1974
+
1975
+ **Chat Completions,** which most other providers and local servers (OpenRouter, Ollama and many more) offer as well as OpenAI:
1976
+
1977
+ ```js
1978
+ const messages = [{ role: 'system', content: tools.instructions }, { role: 'user', content: request }];
1979
+
1980
+ while (true) {
1981
+ const response = await client.chat.completions.create({ model, messages, tools: tools.for('openai-chat') });
1982
+ const message = response.choices[0].message;
1983
+ messages.push(message);
1984
+
1985
+ if (!message.tool_calls?.length) {
1986
+ return message.content;
1987
+ }
1988
+
1989
+ for (const call of message.tool_calls) {
1990
+ messages.push({ role: 'tool', tool_call_id: call.id, content: tools.run(call.function.name, call.function.arguments).text });
1991
+ }
1992
+ }
1993
+ ```
1994
+
1995
+ **Gemini,** with `@google/genai`:
1996
+
1997
+ ```js
1998
+ const contents = [{ role: 'user', parts: [{ text: request }] }];
1999
+
2000
+ while (true) {
2001
+ const response = await ai.models.generateContent({ model, contents, config: { systemInstruction: tools.instructions, tools: tools.for('gemini') } });
2002
+ const calls = response.functionCalls ?? [];
2003
+
2004
+ if (calls.length === 0) {
2005
+ return response.text;
2006
+ }
2007
+
2008
+ contents.push(response.candidates[0].content);
2009
+ contents.push({
2010
+ role: 'user',
2011
+ parts: calls.map((call) => {
2012
+ const result = tools.run(call.name, call.args);
2013
+ return { functionResponse: { name: call.name, response: result.ok ? { output: result.text } : { error: result.text } } };
2014
+ }),
2015
+ });
2016
+ }
2017
+ ```
2018
+
2019
+ **MCP:** `tools.definitions` are tools as MCP lists them (`name`, `description`, `inputSchema` and `annotations`). A server returns them for `tools/list`, and answers `tools/call` with `{ content: [{ type: 'text', text: result.text }], isError: !result.ok }`.
2020
+
2021
+ For anything else, such as the Vercel AI SDK or LangChain, build the tools from `tools.definitions`: each has a name, a description, and its input as JSON Schema.
2022
+
2023
+ ## The tools
2024
+
2025
+ | Tool | What it does | Takes |
2026
+ | --- | --- | --- |
2027
+ | `read_map` | The map as an outline: each node's title, id and attributes, and the start of its note. A big map is cut short, whole levels first, with what's left out counted. | `node_id` (to read one branch), `depth` |
2028
+ | `find_nodes` | Searches titles, notes and tags, in any case and with or without accents, and statuses by what they mean: `done` finds `shipped` too. | `query`, `status`, `tag` |
2029
+ | `read_node` | Everything about one node: its whole note, its picture, video or page, where it is, its children, and the nodes that link to it. | `node_id` |
2030
+ | `add_nodes` | Adds nodes, or whole branches, written as a markdown list. | `parent_id`, `outline`, `index` |
2031
+ | `update_node` | Changes a title or attributes, and `clear` removes attributes. | `node_id`, `text`, `note`, `status`, `priority`, `tags`, `icon`, `color`, `link`, `image`, `video`, `embed`, `collapsed`, `clear` |
2032
+ | `move_node` | Moves a branch under another node, or to another place among its siblings. | `node_id`, `parent_id`, `index` |
2033
+ | `reorder_children` | Puts all the children of a node in a new order at once: to sort them, or to group them. | `parent_id`, `order` |
2034
+ | `remove_node` | Removes a branch. Never the root. | `node_id` |
2035
+ | `show_node` | Flies the map to a node. Only on a map on screen. | `node_id` |
2036
+
2037
+ New ideas come as orbit-md, the way models write any outline: each list item is a node, the items indented under it are its children, at any depth, and indented text that isn't an item is its note. Checkboxes, an emoji first, hashtags at the end and `<!-- orbit … -->` comments set statuses, icons, tags and other attributes, as in any map:
2038
+
2039
+ ```json
2040
+ {
2041
+ "parent_id": "marketing",
2042
+ "outline": "- Social media\n - [ ] Instagram #social\n - TikTok\n- Newsletter\n A weekly email with what shipped."
2043
+ }
2044
+ ```
2045
+
2046
+ ## What a call returns
2047
+
2048
+ `run` returns `{ ok, text, changed }`:
2049
+
2050
+ - **`text`** is for the model: send it back as the tool's result. It names nodes with their ids (`"Instagram" #instagram`), gives the new id when a new title changes one, and adds notes worth knowing, such as an id the model wrote as a title, a field the tool doesn't take, or two ideas with the same title side by side.
2051
+ - **`ok`** is false when the call couldn't be carried out. Nothing changed, and `text` says why and what to send instead:
2052
+
2053
+ ```text
2054
+ No node has the id "lanch". Did you mean "Launch" #launch? read_map and find_nodes give every node's id.
2055
+
2056
+ "Build" #build can't go under "Frontend" #frontend, which is inside it. Move "Frontend" #frontend out of it first, or choose another parent. Nothing changed.
2057
+
2058
+ outline doesn't read cleanly. Fix this, and send it again:
2059
+ - Line 2: Unknown attribute "colour". Did you mean "color"?
2060
+ Lines count from the outline's first line. Nothing changed.
2061
+ ```
2062
+
2063
+ - **`changed`** says whether the map changed.
2064
+
2065
+ `run` never throws over a call a model gets wrong. It forgives what models often send: input as JSON text, `null` for a field left out, a number as text, tags as words separated by commas, an id with its `#`, a title only one node has instead of its id, and a field under the name models often give it (`title` for `text`, `parent` for `parent_id`). An id from before a rename still finds its node, and the id of a removed node says it was removed. A misspelled field, or a value the map can't use, changes nothing and says what it takes.
2066
+
2067
+ ## On screen, or on a server
2068
+
2069
+ - **A map on screen:** `createMapTools(map)` takes what `createOrbitMap` returns, the `renderer` of an `<orbit-map>` element, or the `renderer` of `OrbitMindMap`'s ref. Each change goes through `addNode`, `updateNode`, `moveNode` and `removeNode` (see [Editing](editing.md)): it animates, Orbit announces it, and `edit` fires. `read_map` says which node the reader is looking at, and `show_node` takes them to one.
2070
+ - **A document:** `createMapTools(markdown)` or `createMapTools(parseOrbit(markdown))`, on a server or anywhere without a map on screen. The tools change the document in place, and `serializeOrbitMarkdown(tools.document)` saves it, the way it was written apart from what changed.
2071
+
2072
+ ```js
2073
+ import { writeFile } from 'node:fs/promises';
2074
+ import { createMapTools, serializeOrbitMarkdown } from '@restless-dreams/orbit';
2075
+
2076
+ const tools = createMapTools(markdown);
2077
+ tools.run('add_nodes', { parent_id: 'research', outline: '- Interviews\n- Survey' });
2078
+ await writeFile('map.md', serializeOrbitMarkdown(tools.document));
2079
+ ```
2080
+
2081
+ Options:
2082
+
2083
+ - **`readOnly: true`** offers only the tools that read (and `show_node`, on screen): for an assistant that answers questions about a map.
2084
+ - **`readLimit`**: the most nodes `read_map` shows at once. Default 250.
2085
+
2086
+ ## Good to know
2087
+
2088
+ - **Instructions.** `tools.instructions` says how to use the tools, and asks for short titles with the detail in notes, ideas in the map's language, and nodes removed only when asked: put it in the system prompt. To save the model a first call, put `tools.run('read_map').text` in the first message too.
2089
+ - **Undo.** Keep `map.toMarkdown()` from before a request: `map.render(saved)` puts the map back, animating to it.
2090
+ - **Asking first.** Each definition's `annotations` say whether the tool only reads (`readOnlyHint`) and whether it can overwrite or remove something (`destructiveHint`). An app can run the tools that read at once and ask before the others, as MCP clients do.
2091
+ - **Schemas every provider reads.** The inputs use plain JSON Schema, without references or unions, and new branches come as markdown rather than nested objects, which some providers' schemas can't describe.
2092
+ - **Newer maps.** A map written for a newer version of the format can be read but not changed, so nothing this version doesn't understand is lost.
2093
+
2094
+ ---
2095
+
2096
+ <!-- docs/media.md -->
2097
+
2098
+ # Pictures and videos
2099
+
2100
+ A node's picture shows inside its planet, and a video shows its thumbnail with a play badge ([Media](format.md#media) has how to write them). Under the node in focus, "View image" and "Play video" open them in the media viewer, over the whole window.
2101
+
2102
+ - [The media viewer](#the-media-viewer)
2103
+ - [Keys](#keys)
2104
+ - [Choosing what the buttons do](#choosing-what-the-buttons-do)
2105
+ - [From code](#from-code)
2106
+ - [Events](#events)
2107
+ - [YouTube and Vimeo](#youtube-and-vimeo)
2108
+ - [Sound and autoplay](#sound-and-autoplay)
2109
+ - [Maps that aren't interactive](#maps-that-arent-interactive)
2110
+ - [Looks](#looks)
2111
+
2112
+ ## The media viewer
2113
+
2114
+ The viewer covers the whole window, even when the map sits in a small box on a page, and follows the map's theme, light or dark.
2115
+
2116
+ - **Pictures** fit the window, never past twice their own size. The wheel, a pinch, a double click or tap, the `+`, `−` and `0` keys and the zoom buttons zoom around the point under the pointer or between the fingers, and a zoomed picture pans with a drag. Zooming goes to four times the fitted size, or until the picture shows at twice its own size if that's further.
2117
+ - **Videos** start at once: files (`.mp4`, `.webm`, `.mov`) in the browser's own player, YouTube and Vimeo in theirs. The player is as big as the window allows, at the video's shape once it's known (16:9 until then, upright for YouTube Shorts). Stepping away from a video stops it.
2118
+ - **Every picture and video of the map,** in reading order (the order the outline lists the map, a node's picture before its video): the previous and next buttons, the arrow keys and, on touch screens, a swipe step through them, starting from the one opened. A counter says where you are ("2 of 5"), and screen readers hear it.
2119
+ - **Around them,** the node's title, the caption, and the picture's description (its alt text, which is also the picture's name for screen readers), with a link to open the picture on its own, or the video on its site.
2120
+ - **Closing:** Esc, the close button, a click around the media or, on touch screens, a swipe down while the picture isn't zoomed. Focus goes back where it was, and the map's own keys, which rest while the viewer is open, work again.
2121
+
2122
+ The detail sheet shows a node's picture and video too. Its picture opens the viewer, over the sheet, and its video has an Expand button that moves it there (a video file goes on from where it was); closing the viewer goes back to the sheet.
2123
+
2124
+ ## Keys
2125
+
2126
+ | Key | Does |
2127
+ | --- | --- |
2128
+ | `←` `→` | The previous or next picture or video; on a zoomed picture, pan it |
2129
+ | `↑` `↓` | Pan a zoomed picture |
2130
+ | `Home` `End` | The first or last picture or video |
2131
+ | `+` `−` `0` | Zoom in, zoom out, fit the picture to the window |
2132
+ | `Space` | Play or pause the video |
2133
+ | `Escape` | Close the viewer |
2134
+
2135
+ On a video's own controls, the arrow keys and the space bar are the player's. Inside YouTube's and Vimeo's players every key is theirs, Escape included: Tab out of the player, or click around it, to close the viewer.
2136
+
2137
+ ## Choosing what the buttons do
2138
+
2139
+ The buttons ask your code first, as every button does: `onAction`, then the `action` event. What happens next:
2140
+
2141
+ | Your code | "View image" and "Play video" |
2142
+ | --- | --- |
2143
+ | Nothing | Open the media viewer |
2144
+ | `mediaViewer: false` | Open the detail sheet at the picture or video, as before the viewer |
2145
+ | `mediaViewer: false` and `showDetailSheet: false` | Nothing: Orbit opens neither |
2146
+ | `showDetailSheet: false` | Open the media viewer: it turns off only the sheet, for notes, embeds and details |
2147
+ | `onAction` returns `false`, or `preventDefault()` on the `action` event | Nothing: the press is yours |
2148
+
2149
+ The detail sheet's picture and Expand button are presses of "View image" and "Play video" too: your code sees them first, and when it takes one the sheet closes, so what you open is in front. With `mediaViewer: false`, the sheet's picture is only a picture.
2150
+
2151
+ ```js
2152
+ createOrbitMap(element, markdown, {
2153
+ onAction: ({ node, action }) => {
2154
+ if (action === 'image') {
2155
+ openMyLightbox(node.image.src);
2156
+ return false;
2157
+ }
2158
+
2159
+ return undefined;
2160
+ },
2161
+ });
2162
+ ```
2163
+
2164
+ ## From code
2165
+
2166
+ A page can open the viewer itself, to show a picture big or play a video on a screen of its own:
2167
+
2168
+ ```js
2169
+ map.openMedia('summit'); // a node's picture, or its video if it has no picture
2170
+ map.openMedia('trail', { kind: 'video', autoplay: false }); // its video, ready to play
2171
+ map.openMedia('trail', { kind: 'video', startAt: 42 }); // its video, playing from 0:42
2172
+ map.playMedia();
2173
+ map.seekMedia(42);
2174
+ map.pauseMedia();
2175
+ map.unmuteMedia(); // from a press: the sound of a video a browser started muted
2176
+ map.closeMedia();
2177
+ ```
2178
+
2179
+ | Method | Does |
2180
+ | --- | --- |
2181
+ | `openMedia(nodeId, { kind, autoplay, startAt })` | Shows the node's picture (`kind: 'image'`, the default when it has one) or video (`kind: 'video'`), opening the viewer if it's closed. A video starts playing unless `autoplay` is `false`, from its start or `startAt` seconds in (YouTube and Vimeo start at the whole second). Returns `false` when the map has no such node, or the node no such picture or video. |
2182
+ | `closeMedia()` | Closes the viewer. |
2183
+ | `playMedia()`, `pauseMedia()` | Plays or pauses the video it shows. Returns `false` when it shows no video it can drive. |
2184
+ | `seekMedia(seconds)` | Moves the video to `seconds` from its start. Returns `false` when it shows no video it can drive. |
2185
+ | `unmuteMedia()` | Turns on the sound of the video it shows. Browsers allow it only from a press (see [Sound and autoplay](#sound-and-autoplay)). Returns `false` when it shows no video it can drive. |
2186
+
2187
+ `openMedia` opens the viewer whatever `mediaViewer`, `showDetailSheet` and `interactive` say: those choose what the buttons do. Files, YouTube and Vimeo can be driven; a video on any other site plays in a frame, with its own controls only. `<orbit-map>` has the same six methods.
2188
+
2189
+ This is what an audience's screen does while someone presents from a phone: it opens what the presenter opens, where the presenter's video is, and follows the presenter's player, and tells the phone how the video is going.
2190
+
2191
+ ```js
2192
+ const map = createOrbitMap(element, markdown, { interactive: false, showControls: false });
2193
+
2194
+ presenter.addEventListener('message', ({ data }) => {
2195
+ if (data.type === 'media') {
2196
+ map.openMedia(data.id, { kind: data.kind, autoplay: data.playing, startAt: data.seconds });
2197
+ } else if (data.type === 'play') {
2198
+ map.playMedia();
2199
+ } else if (data.type === 'pause') {
2200
+ map.pauseMedia();
2201
+ } else if (data.type === 'seek') {
2202
+ map.seekMedia(data.seconds);
2203
+ } else if (data.type === 'close') {
2204
+ map.closeMedia();
2205
+ }
2206
+ });
2207
+
2208
+ map.on('media-state', ({ node, playing, time }) => {
2209
+ presenter.postMessage({ type: 'state', id: node.id, playing, time });
2210
+ });
2211
+ ```
2212
+
2213
+ ## Events
2214
+
2215
+ | Event | DOM event | Detail |
2216
+ | --- | --- | --- |
2217
+ | `media-open` | `orbit-media-open` | `{ node, kind, index, total }`: the viewer opened, or stepped, on a node's picture (`kind: 'image'`) or video (`'video'`), `index` of `total` in reading order, from 0 |
2218
+ | `media-close` | `orbit-media-close` | `{ node, kind, index, total }`: the viewer closed, showing that |
2219
+ | `media-state` | `orbit-media-state` | `{ node, playing, time, duration, ended, muted }`: the video started, paused, moved to another time, ended or was muted, at once; while it plays, its time a few times a second. `time` and `duration` are in seconds; `duration` is `null` until it's known |
2220
+
2221
+ `onMediaOpen`, `onMediaClose` and `onMediaState` receive the same, and so do the React props of the same names. The DOM events bubble from the map's container and out of `<orbit-map>`.
2222
+
2223
+ ## YouTube and Vimeo
2224
+
2225
+ YouTube videos play in YouTube's privacy-enhanced player (`youtube-nocookie.com`), and Vimeo videos in Vimeo's with `dnt=1`, which tracks no sessions. Links to watch pages, `youtu.be`, Shorts, Vimeo channels, groups, showcases and unlisted videos all work; Orbit frames their players, never their pages, which refuse to be framed.
2226
+
2227
+ - **The referrer:** the players' frames send the page's origin, never its address (`referrerpolicy="strict-origin-when-cross-origin"`). YouTube's player won't play without knowing the site it's on (its "Error 153"), and a page that sends no referrer (`Referrer-Policy: no-referrer`) still gives its origin to these frames. A page opened from a file has no origin to give, so YouTube's videos don't play there.
2228
+ - **Driving them:** `playMedia`, `pauseMedia` and `seekMedia` reach the players through the messages their own JavaScript APIs send (YouTube's `enablejsapi=1`, with the page's `origin`; Vimeo's player.js messages), and `media-state` follows what the players say. Nothing is loaded from either site but the player itself.
2229
+ - **Other sites:** a video on any other site plays in a frame, as the detail sheet shows an embed, with only its own controls: `playMedia` and its kin return `false` for it, and it reports no `media-state`.
2230
+
2231
+ ## Sound and autoplay
2232
+
2233
+ Browsers let a video start with sound only once the reader has interacted with the page. A press of "Play video", the arrows or a swipe counts, so those videos play with sound.
2234
+
2235
+ A video opened from code on a page nobody has touched yet, such as an audience's screen, may not be allowed to. A video file then starts muted, and its `media-state` says `muted: true`. YouTube's and Vimeo's players decide for themselves: they start muted, or wait for a press. Screens that should play sound from the start can allow it: a kiosk Chrome with `--autoplay-policy=no-user-gesture-required`, or an Android `WebView` with `setMediaPlaybackRequiresUserGesture(false)`.
2236
+
2237
+ Elsewhere, offer a button for the sound while `media-state` says the video is muted, or isn't playing when it should. Its press counts as interacting with the page, so `unmuteMedia()` and `playMedia()` from its click play the video with sound, YouTube's and Vimeo's included. The viewer is a modal `<dialog>` in the top layer, and a modal dialog makes the rest of the page inert, popovers included: show the button in a `<dialog>` of its own with `showModal()`, after the viewer opens, so it's on top and can be pressed.
2238
+
2239
+ ```js
2240
+ soundButton.addEventListener('click', () => {
2241
+ map.unmuteMedia();
2242
+ map.playMedia();
2243
+ soundDialog.close();
2244
+ });
2245
+ ```
2246
+
2247
+ ## Maps that aren't interactive
2248
+
2249
+ On a map with `interactive: false`, a still picture, the viewer is the page's to drive. It shows the picture or video with its title and caption, but no buttons, no counter and no controls on videos, and it takes no input of its own: Esc and clicks don't close it, and keys and presses reach the page, so a presentation's own keys keep working. Open, close and play it with the methods above.
2250
+
2251
+ ## Looks
2252
+
2253
+ The viewer takes its colors from the detail sheet's custom properties: `--orbit-sheet-background` behind the media (blurred by `--orbit-sheet-blur` where it's see-through, as in the glass themes), `--orbit-sheet-text` and `--orbit-sheet-muted-text` for the title, caption and counter, and `--orbit-sheet-shadow` under a video. Its buttons are the map's controls (`--orbit-control-*`, `--orbit-focus-ring`). See [Theming](theming.md#custom-properties).
2254
+
2255
+ With reduced motion (`prefers-reduced-motion: reduce`), zooms and steps happen at once instead of gliding and fading.
2256
+
2257
+ ---
2258
+
2259
+ <!-- docs/languages.md -->
2260
+
2261
+ # Languages
2262
+
2263
+ Orbit speaks the language of the page it's on: its buttons, the detail sheet, the media viewer, the list of the map, its messages and everything screen readers hear about the map. English and Spanish are built in. You can add any other language, change single words, or pick a language for one map.
2264
+
2265
+ Only Orbit's own words change. The text of a map is the author's, and so are the status words written in it (`status: done` shows as "done"). Problems Orbit reports about a map's source (in the console, `orbit validate` and diagnostics) stay in English: they're for the map's author and their tools.
2266
+
2267
+ ## Which language a map speaks
2268
+
2269
+ A map speaks the language of the element it's drawn in: the closest `lang` attribute around it, through shadow roots, and then the page's own `<html lang>`. A page in Spanish gets its maps in Spanish with nothing to set:
2270
+
2271
+ ```html
2272
+ <html lang="es">
2273
+ …
2274
+ <orbit-map># Viaje
2275
+ ## Comida
2276
+ ## Lugares</orbit-map>
2277
+ ```
2278
+
2279
+ The `language` option picks a language for one map, whatever the page says:
2280
+
2281
+ ```ts
2282
+ createOrbitMap(element, markdown, { language: 'es' });
2283
+ ```
2284
+
2285
+ ```tsx
2286
+ <OrbitMindMap source={markdown} language="es" />
2287
+ ```
2288
+
2289
+ On `<orbit-map>`, set its `lang` attribute, as on any element: `<orbit-map lang="es">`.
2290
+
2291
+ Languages are [BCP 47 tags](https://www.rfc-editor.org/info/bcp47). A regional tag speaks its language with the region's numbers and plurals: `es-MX` and `es-ES` both speak Spanish, writing 24,000 and 24.000. A language Orbit doesn't have speaks English, and so does a map on a page that names none.
2292
+
2293
+ Changing the language later, with `setOptions({ language })`, the `language` prop or the element's `lang` attribute, writes the map's words again where they are.
2294
+
2295
+ ## Adding a language
2296
+
2297
+ `registerLanguage` adds a language for every map, by its tag. Words it leaves out are English, or, for a regional tag like `pt-BR`, its base language's (`pt`) when Orbit has it:
2298
+
2299
+ ```ts
2300
+ import { registerLanguage } from '@restless-dreams/orbit';
2301
+
2302
+ registerLanguage('fr', {
2303
+ focus: 'Focus',
2304
+ overview: 'Vue d’ensemble',
2305
+ zoomIn: 'Zoomer',
2306
+ zoomOut: 'Dézoomer',
2307
+ ideaCount: { one: '{count} idée', many: '{count} d’idées', other: '{count} idées' },
2308
+ // …the rest of the words in the table below.
2309
+ });
2310
+ ```
2311
+
2312
+ It returns a function that takes the language away again. Registering a language Orbit already has changes its words for every map: `registerLanguage('es', { video: 'Vídeo' })`. `languageTags()` lists the languages maps can speak.
2313
+
2314
+ ### Placeholders, plurals and lists
2315
+
2316
+ - **Placeholders:** words in braces (`{name}`, `{count}`) are filled in for you. Keep them in a translation, in whatever order the language needs.
2317
+ - **Plurals:** text with a number is an object of plural forms, one for each [plural category](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/select) the language uses (`zero`, `one`, `two`, `few`, `many`) and `other`, which is required and covers any form left out.
2318
+ - **Numbers and lists:** numbers are written the way the language writes them, and lists like "has note, image, and link" are joined the language's way ("tiene nota, imagen y enlace").
2319
+
2320
+ ## Changing single words
2321
+
2322
+ The `strings` option changes words on one map, over its language's:
2323
+
2324
+ ```ts
2325
+ createOrbitMap(element, markdown, { strings: { zoomIn: 'Closer', zoomOut: 'Further' } });
2326
+ ```
2327
+
2328
+ A word Orbit doesn't have, or one of the wrong kind, is skipped with a warning in the console.
2329
+
2330
+ ## The root Orbit makes up
2331
+
2332
+ A map that's just a list, or has several top headings, and has no title gets a root Orbit makes up, named "Untitled map" in the map's language. When you parse maps yourself, `parseOrbit(source, { untitled: 'Mapa sin título' })` gives it that name. Its id is `untitled-map` in every language, so links and saved places keep working, and saving the map writes it as the list it was.
2333
+
2334
+ ## Without a browser
2335
+
2336
+ `renderToSVGString(markdown, { language: 'es' })` writes the drawing's few words in a language: an untitled idea's name, the empty map's message and the map's title when it has none. On the command line: `orbit render map.md --language es`.
2337
+
2338
+ ## Every word
2339
+
2340
+ | Key | English | Spanish |
2341
+ | --- | --- | --- |
2342
+ | `mindMap` | Mind map | Mapa mental |
2343
+ | `mindMapRole` | mind map | mapa mental |
2344
+ | `untitled` | Untitled | Sin título |
2345
+ | `untitledMap` | Untitled map | Mapa sin título |
2346
+ | `emptyMap` | This map is empty | Este mapa está vacío |
2347
+ | `emptyMapHint` | Start it with a # heading or a bullet list | Empiézalo con un encabezado # o una lista con viñetas |
2348
+ | `mapNotDrawn` | This map couldn’t be drawn | No se pudo dibujar este mapa |
2349
+ | `mapNotDrawnReason` | The map could not be drawn. | No se pudo dibujar el mapa. |
2350
+ | `problemOnLine` | Line {line}: {message} | Línea {line}: {message} |
2351
+ | `mapPath` | Map path | Ruta del mapa |
2352
+ | `back` | Back | Atrás |
2353
+ | `backTo` | Back to {name} | Volver a {name} |
2354
+ | `backToParent` | Back to the parent node | Volver a la idea superior |
2355
+ | `view` | View | Vista |
2356
+ | `focus` | Focus | Enfoque |
2357
+ | `overview` | Overview | Vista general |
2358
+ | `showOutline` | Show the map as a list | Mostrar el mapa como lista |
2359
+ | `zoom` | Zoom | Zoom |
2360
+ | `zoomIn` | Zoom in | Acercar |
2361
+ | `zoomOut` | Zoom out | Alejar |
2362
+ | `fitMap` | Fit the map | Ajustar el mapa |
2363
+ | `scrollToZoom` | Use {key} + scroll to zoom the map | Usa {key} + desplazamiento para acercar o alejar el mapa |
2364
+ | `outline` | Map outline | Esquema del mapa |
2365
+ | `overviewAnnouncement` | Overview of the whole map | Vista general de todo el mapa |
2366
+ | `ideaCount` | one: {count} item; other: {count} items | one: {count} idea; many: {count} ideas; other: {count} ideas |
2367
+ | `added` | Added {name} | Se agregó {name} |
2368
+ | `removed` | Removed {name} | Se eliminó {name} |
2369
+ | `moved` | Moved {name} to {parent} | Se movió {name} a {parent} |
2370
+ | `movedToTop` | Moved {name} to the top | Se movió {name} al nivel superior |
2371
+ | `anIdea` | a node | una idea |
2372
+ | `status` | status {status} | estado {status} |
2373
+ | `statusLabel` | Status: {status} | Estado: {status} |
2374
+ | `has` | has {list} | tiene {list} |
2375
+ | `hasDetails` | details | detalles |
2376
+ | `hasNote` | note | nota |
2377
+ | `hasImage` | image | imagen |
2378
+ | `hasVideo` | video | video |
2379
+ | `hasEmbed` | embed | contenido insertado |
2380
+ | `hasLink` | link | enlace |
2381
+ | `textOf` | Text of {name} | Texto de {name} |
2382
+ | `newIdeaFor` | New idea for {name} | Idea nueva en {name} |
2383
+ | `newIdea` | New idea | Idea nueva |
2384
+ | `addIdeaTo` | Add an idea to {name} | Agregar una idea a {name} |
2385
+ | `moveHere` | Move {name} here | Mover {name} aquí |
2386
+ | `thisIdea` | this node | esta idea |
2387
+ | `theIdea` | the idea | la idea |
2388
+ | `ideaActions` | {name} actions | Acciones de {name} |
2389
+ | `ideaActionsUntitled` | Node actions | Acciones de la idea |
2390
+ | `showDetails` | Show details | Ver detalles |
2391
+ | `viewImage` | View image | Ver imagen |
2392
+ | `viewImageOf` | View image: {description} | Ver imagen: {description} |
2393
+ | `playVideo` | Play video | Reproducir video |
2394
+ | `openEmbed` | Open embed | Abrir contenido insertado |
2395
+ | `readNote` | Read note | Leer nota |
2396
+ | `openLink` | Open link | Abrir enlace |
2397
+ | `goTo` | Go to {name} | Ir a {name} |
2398
+ | `details` | Details | Detalles |
2399
+ | `close` | Close | Cerrar |
2400
+ | `previous` | Previous | Anterior |
2401
+ | `next` | Next | Siguiente |
2402
+ | `present` | Present | Presentar |
2403
+ | `priority` | Priority {priority} | Prioridad {priority} |
2404
+ | `expand` | Expand | Ampliar |
2405
+ | `expandVideo` | Expand the video | Ampliar el video |
2406
+ | `picture` | Picture | Imagen |
2407
+ | `video` | Video | Video |
2408
+ | `position` | {index} of {total} | {index} de {total} |
2409
+ | `fitPicture` | Fit the picture | Ajustar la imagen |
2410
+ | `pictureUnavailable` | This picture can’t be shown. | No se puede mostrar esta imagen. |
2411
+ | `videoUnavailable` | This video can’t be played. | No se puede reproducir este video. |
2412
+ | `videoUnavailableHere` | This video can’t be played here. | Este video no se puede reproducir aquí. |
2413
+ | `openPictureInNewTab` | Open the picture in a new tab | Abrir la imagen en una pestaña nueva |
2414
+ | `openVideoInNewTab` | Open the video in a new tab | Abrir el video en una pestaña nueva |
2415
+ | `watchOn` | Watch on {provider} | Ver en {provider} |
2416
+
2417
+ `OrbitStrings` in the [API reference](api/README.md) says where each one shows.
2418
+
2419
+ ---
2420
+
2421
+ <!-- docs/accessibility.md -->
2422
+
2423
+ # Accessibility
2424
+
2425
+ Orbit maps are built to work with a keyboard, a screen reader, a phone and a preference for less motion.
2426
+
2427
+ ## Keyboard
2428
+
2429
+ The map is one tab stop: Tab moves into the map and out of it again, and the arrow keys move inside it.
2430
+
2431
+ | Key | Focus mode | Overview |
2432
+ | --- | --- | --- |
2433
+ | `←` `→` | Previous or next planet, and the + on editable maps; the orbit turns so the chosen planet is on top | Previous or next sibling |
2434
+ | `↓` | From the sun into the orbit | First child |
2435
+ | `↑` | Back to the sun | Parent |
2436
+ | `Home` `End` | First or last planet | The root, or the last sibling |
2437
+ | `Enter` `Space` | Fly into the focused node (on the sun: go back up) | Fly into the focused node |
2438
+ | `Escape` `Backspace` | Go back up a level | Back to focus mode |
2439
+ | `+` `−` `0` | Zoom in, zoom out, fit | Zoom in, zoom out, fit |
2440
+ | `F2` | Edit the focused node's text where it is ([editing](editing.md)) | The same |
2441
+ | Context menu key, `Shift` `F10` | The map's own menu for the focused node, when the page has one | The same |
2442
+
2443
+ The focused node has a visible ring and shows a tooltip with its full text, status and the start of its note. The breadcrumbs, the view switch, the list button, the zoom buttons and the content buttons under the focused node are ordinary buttons, reached with Tab.
2444
+
2445
+ In the media viewer, `←` and `→` step through the map's pictures and videos (or pan a zoomed picture), `Home` and `End` go to the first and last, `+`, `−` and `0` zoom a picture, `Space` plays or pauses a video, and `Escape` closes it. Its buttons are ordinary buttons too ([Keys](media.md#keys)).
2446
+
2447
+ ## Screen readers
2448
+
2449
+ - Everything below is said in the map's language: the page's, or the one its `language` option names ([Languages](languages.md)).
2450
+ - The map is a group named after the document's title (or `ariaLabel`), with the role description "mind map".
2451
+ - Each node is a button whose name says what it is: its text, its status, how many items it holds and what content it has ("Discovery, 4 items, has note"). The focused node is marked as the current one.
2452
+ - A visually hidden outline lists the whole map as nested lists, so screen readers can browse every node without moving the map. The list button shows the same outline to everyone.
2453
+ - Moving around is announced: "Discovery, 4 items", or "Overview of the whole map". So are edits made on the map: "Added Night markets", "Removed Beaches".
2454
+ - The detail sheet is a modal dialog: focus moves into it, Escape closes it, and focus returns to the button that opened it.
2455
+ - So is the media viewer, named after the node it shows. A picture's description (its alt text) is the picture's name, and also shows under it. The counter ("2 of 5", with the node's title) is read out as you step, and a video's own controls are the browser's or its site's.
2456
+
2457
+ ## Motion
2458
+
2459
+ When the reader asks for reduced motion (`prefers-reduced-motion: reduce`):
2460
+
2461
+ - views cross-fade in place instead of flying;
2462
+ - a flicked orbit stops where it is let go instead of coasting;
2463
+ - the orbit's drift (`orbitAnimation`) stays still, and transitions such as the hover lift and the sheet's slide-in are off;
2464
+ - in the media viewer, zooms and steps happen at once instead of gliding and fading.
2465
+
2466
+ `transitionDuration: 0` turns flights off for everyone.
2467
+
2468
+ ## Reading
2469
+
2470
+ - Names inside nodes are never smaller than 9 px on screen, and they keep their size whatever the zoom. A node too small for its name shows none until you zoom in; the outline lists every node, whatever the view.
2471
+ - Labels meet WCAG AA contrast (4.5:1) in every built-in theme, on every branch color and shade, including where a planet's shading or a translucent planet's background shows through. A test checks this for every theme. Labels use whichever of the theme's dark and light text has more contrast with the node, custom colors included.
2472
+ - The `contrast` theme is for readers who need more: black outlines and text, bold labels, solid orbits and no shading, with labels above 7:1 (AAA).
2473
+ - Labels too long for their node show in full in the tooltip and the outline.
2474
+ - In forced-colors mode (Windows High Contrast), rims and the focus ring use system colors.
2475
+
2476
+ ## Touch
2477
+
2478
+ - Buttons are at least 40 px on touch screens.
2479
+ - On a page that scrolls, one finger scrolls the page past the map, except on the orbit itself, where it spins the orbit; two fingers zoom.
2480
+ - A long press on a node opens the page's own menu for it, when the page has one, or edits its text on editable maps; the context menu key, Shift+F10 and F2 do the same from the keyboard.
2481
+ - The field for typing on the map keeps a 16 px font, scaled to the label's size, so phones don't zoom into it.
2482
+ - The detail sheet is a bottom sheet you can swipe down to close.
2483
+ - In the media viewer, two fingers zoom a picture, one pans it once it's zoomed, a swipe across steps to the previous or next picture or video, and a swipe down closes it. On narrow screens, the previous and next buttons sit under a video, clear of its player's controls.
2484
+
2485
+ ## What authors can do
2486
+
2487
+ - Give images alt text: `![The summit at sunrise](summit.jpg)`. It is the picture's name in the media viewer and the detail sheet, and shows under it in the viewer.
2488
+ - Give the map a title in its frontmatter (`title: Product roadmap`), or pass `ariaLabel`.
2489
+ - Keep node text short; put details in notes, which are read in full in the detail sheet.
2490
+ - Prefer statuses (`<!-- orbit status: blocked -->`) over color alone to convey state: statuses are announced and shown as words in the detail sheet.
2491
+
2492
+ ---
2493
+
2494
+ <!-- docs/agents.md -->
2495
+
2496
+ # Coding agents
2497
+
2498
+ Orbit is built so coding agents (Codex, Claude Code, Kimi Code, Cursor, GitHub Copilot and others) can use it correctly on the first try: the whole format and API are in files the package ships, problems come with the fix, and maps can be checked without a browser.
2499
+
2500
+ ## Give your agent the docs
2501
+
2502
+ - **`llms.txt`** is an index of the docs, and **`llms-full.txt`** is all of them in one file: the format, the API, recipes for each framework, theming and accessibility. Both ship in the package (`node_modules/@restless-dreams/orbit/llms-full.txt`, matching the installed version) and online for the latest release at `https://cdn.jsdelivr.net/npm/@restless-dreams/orbit/llms.txt` and `https://cdn.jsdelivr.net/npm/@restless-dreams/orbit/llms-full.txt`.
2503
+ - Tell your agent about them in your project's `AGENTS.md`, which Codex, Kimi Code and most other agents read. Claude Code reads `CLAUDE.md`, where a line with `@AGENTS.md` brings in the same file.
2504
+
2505
+ ```markdown
2506
+ ## Mind maps
2507
+
2508
+ This project draws mind maps with Orbit (@restless-dreams/orbit). Before
2509
+ writing a map or code that uses it, read
2510
+ node_modules/@restless-dreams/orbit/llms-full.txt. Check every map with
2511
+ `npx @restless-dreams/orbit validate --json <file>` and fix every diagnostic
2512
+ it lists.
2513
+ ```
2514
+
2515
+ ## Install the skill
2516
+
2517
+ The package includes an agent skill in `skills/orbit`, in the [Agent Skills](https://agentskills.io) format that Codex, Claude Code, Kimi Code, Cursor, GitHub Copilot and other agents read: a `SKILL.md` with instructions they load on their own when a task involves mind maps, and the guides to the format, recipes, theming, extending, editing, pictures and videos, and languages next to it.
2518
+
2519
+ The [skills CLI](https://github.com/vercel-labs/skills) puts it where each agent in your project looks for skills:
2520
+
2521
+ ```bash
2522
+ npx skills add restless-dreams/orbit
2523
+ # From the installed package, so the skill matches its version:
2524
+ npx skills add ./node_modules/@restless-dreams/orbit
2525
+ # For particular agents (codex, claude-code, kimi-code-cli, cursor, github-copilot…), or with -g for all your projects:
2526
+ npx skills add ./node_modules/@restless-dreams/orbit -a codex -a claude-code -a kimi-code-cli
2527
+ ```
2528
+
2529
+ Or copy the folder by hand, to where your agent looks:
2530
+
2531
+ | Agent | In a project | For all your projects |
2532
+ | --- | --- | --- |
2533
+ | Codex, Kimi Code | `.agents/skills/orbit` | `~/.agents/skills/orbit` |
2534
+ | Claude Code | `.claude/skills/orbit` | `~/.claude/skills/orbit` |
2535
+ | Cursor, GitHub Copilot | `.agents/skills/orbit` | their own folders (`npx skills add -g` knows them) |
2536
+
2537
+ ```bash
2538
+ mkdir -p .agents/skills && cp -r node_modules/@restless-dreams/orbit/skills/orbit .agents/skills/
2539
+ mkdir -p .claude/skills && cp -r node_modules/@restless-dreams/orbit/skills/orbit .claude/skills/
2540
+ ```
2541
+
2542
+ Agents use the skill when a task calls for it, or when you ask for it by name: `$orbit` in Codex, `/orbit` in Claude Code, `/skill:orbit` in Kimi Code.
2543
+
2544
+ ## What agents can rely on
2545
+
2546
+ - **One way to do each thing.** `createOrbitMap(element, markdown, options)` for any page, `<orbit-map>` without a build step, `OrbitMindMap` from `@restless-dreams/orbit/react`, `@restless-dreams/orbit/marked` for markdown pipelines and `renderToSVGString` without a browser.
2547
+ - **Typed options.** Every option is in the type declarations, and TypeScript rejects one that doesn't exist (plain JavaScript ignores it). A few are spelled their own way where they're set: the frontmatter's `view` is `viewMode` in code and `view-mode` on `<orbit-map>`, and its `animation`, `speed` and `title` are `orbitAnimation`, `orbitSpeed` and `ariaLabel` in code.
2548
+ - **A starting point.** `npx @restless-dreams/orbit example > map.md` writes a map that shows the main features; `--yaml` writes it as YAML.
2549
+ - **Problems with the fix.** Parsing is forgiving, and every problem is reported with what to do about it, and with its line wherever it has one: `Unknown frontmatter key "rings" (ignored). Did you mean "showRings"?`, `Unknown theme "dark". Themes: paper, night, … Did you mean "night"?`, `view must be "focus" or "all" (the overview), got "overview".`
2550
+ - **Checks without a browser.**
2551
+
2552
+ ```bash
2553
+ npx @restless-dreams/orbit validate --json map.md
2554
+ ```
2555
+
2556
+ ```json
2557
+ {
2558
+ "valid": false,
2559
+ "flavor": "markdown",
2560
+ "root": "Product roadmap",
2561
+ "nodes": 22,
2562
+ "depth": 3,
2563
+ "diagnostics": [
2564
+ {
2565
+ "code": "unknown-link-target",
2566
+ "message": "\"Offline sync\" links to #search-indx, but no node has that id. Did you mean #search-index?",
2567
+ "line": 30,
2568
+ "severity": "warning"
2569
+ }
2570
+ ]
2571
+ }
2572
+ ```
2573
+
2574
+ The command exits with 1 while the map has a problem: fix it and validate again until `valid` is true. Add `--tree` to see the map as Orbit reads it, a node a line with its `#id`: what became a node and what stayed in a note, and the ids that `#id` links and `--focus` take. A few diagnostics only note something and don't make a map invalid: an attribute Orbit doesn't know, kept as one of your own (`unknown-attribute`: often a typo, so read its "Did you mean"), a root Orbit made up for several top-level nodes (`synthetic-root`), a caption with nothing to caption, and an older version of the format, upgraded. `validate` doesn't know what your code registers: it reports a theme of your own as unknown (`--loose` accepts that), and the attributes your node types read as ones it doesn't know, which only notes them. In code, `parseOrbit(source).diagnostics` holds the same list, and `{ strict: true }` throws on the first problem that makes a map invalid.
2575
+ - **A page to review it.** `npx @restless-dreams/orbit export --html map.md --out map.html` makes one web page with Orbit, the map and its pictures inside. Send it to someone, or to the user's phone, and it opens in any browser, offline, to explore the map or present it.
2576
+ - **A picture of the result.** `npx @restless-dreams/orbit render map.md --view all --out map.svg` draws the whole map as a standalone SVG. Without `--view all` it draws the map as it opens, centered on the root, where the levels below the root's children have no labels. Open the SVG, or turn it into a PNG with any converter, to look at it.
2577
+ - **Messages for the usual setup mistakes:** drawing into an element that isn't there throws (`Orbit needs an element to draw the map in, but got null…`), and the console says when the container has no height (`The map has no height, so nothing shows…`) and when a `theme` option, the frontmatter or `<orbit-map theme>` names a theme that doesn't exist.
2578
+
2579
+ ## Asking for a map
2580
+
2581
+ Agents do well with the map's content and where it goes. For example:
2582
+
2583
+ - "Turn the outline in NOTES.md into an orbit-md map in docs/roadmap.md and add it to the docs page with `<orbit-map>`. Check it with `orbit validate`."
2584
+ - "Show our product areas as an Orbit map on the dashboard, in the cosmos theme, with each team's status."
2585
+ - "Generate a YAML map for Orbit from `teams.json` at build time and render it to SVG for the README."