reladraw 0.25.1 → 0.26.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.
@@ -15,7 +15,7 @@ That matters here specifically. You can't see the SVG you produced. With auto-la
15
15
 
16
16
  ## Check it is installed, and the same version as this skill
17
17
 
18
- This skill teaches reladraw 0.25.1. Once per session, before the first render, run:
18
+ This skill teaches reladraw 0.26.0. Once per session, before the first render, run:
19
19
 
20
20
  ```
21
21
  reladraw --version
@@ -151,7 +151,7 @@ style store fill: theme-primary-subtle border: theme-primary badge: database
151
151
  node records "Records" style: store
152
152
  ```
153
153
 
154
- `fill` is a box's area, `border` its outline, `line` an edge's line, and a text's color goes in its bracket. `corners: sharp` squares a box's rounded corners, and `border: (color: theme-primary, thickness: thick)` thickens its outline, the box growing to keep its padding. Prefer theme colors, which read in light and dark themes alike: `theme-primary` and `theme-secondary` are the accents, `-subtle` is an accent softened toward the page (the one to fill a box with), `theme-muted` is quieter text. A hex or CSS color stays fixed in every theme.
154
+ `fill` is a box's area, `border` its outline, `line` an edge's line, and a text's color goes in its bracket. `corners: sharp` squares a box's rounded corners, and `border: (color: theme-primary, thickness: thick)` thickens its outline, the box growing to keep its padding. Prefer theme colors, which read in light and dark themes alike: `theme-primary` and `theme-secondary` are the accents, `-subtle` is an accent softened toward the page (the one to fill a box with), `theme-muted` is quieter text. A hex or CSS color stays fixed in every theme. Leave out `diagram theme:` unless the user asks for a theme; the page lets each reader pick their own.
155
155
 
156
156
  ### Links
157
157
 
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.25.1
1
+ # Syntax reference — 0.26.0
2
2
 
3
3
  What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
4
4
 
@@ -1288,11 +1288,13 @@ The output is a standalone SVG: every picture is drawn into it as path data, nev
1288
1288
  reladraw render arch.reladraw --page
1289
1289
  ```
1290
1290
 
1291
- `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Under the title it says which file it was drawn from and when, such as "drawn from arch.reladraw, 2026-11-05 14:32": the page is a snapshot of the file, and is behind it once the file is edited, until it is rendered again. Drag to pan; scroll, pinch or double-click to zoom; Fit shows the whole diagram and 100% returns it to its own scale. The zoom is always shown, with "Fit" beside it while the whole diagram is in view. There is a full-screen button. The Save menu saves the page itself, as it was written rather than at its current zoom; the `.reladraw` source it was drawn from; or the picture as an SVG or a PNG, as `render` would draw it; or copies the SVG. Each is named after the source file: `arch.html`, `arch.reladraw`, `arch.svg`, `arch.png`. In Chrome and Edge it asks where to save; other browsers put the file with their downloads. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer.
1291
+ `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Under the title it says which file it was drawn from and when, such as "drawn from arch.reladraw, 2026-11-05 14:32": the page is a snapshot of the file, and is behind it once the file is edited, until it is rendered again. Drag to pan, or use `h` `j` `k` `l`, or press `g` to grab the diagram and pan it with the arrow keys (Shift for bigger steps either way, `g` or Escape to let go); scroll, pinch or double-click to zoom; Fit shows the whole diagram and 100% returns it to its own scale. The zoom is always shown, with "Fit" beside it while the whole diagram is in view. There is a full-screen button. The Save menu saves the page itself, as it was written rather than at its current zoom; the `.reladraw` source it was drawn from; or the picture as an SVG or a PNG, as `render` would draw it; or copies the SVG. Each is named after the source file: `arch.html`, `arch.reladraw`, `arch.svg`, `arch.png`. In Chrome and Edge it asks where to save; other browsers put the file with their downloads. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer. The theme picker draws the diagram in another theme, as `--theme` would, keeping the view and the selection; a page or text color the file states holds in every theme, and Save SVG, PNG and Copy SVG take the theme shown. It starts on "Diagram's theme", the one the file names (or dark), which follows the file when it is rendered again. A theme picked by name is the reader's: it holds on every reladraw page in that browser, whatever the file says, until "Diagram's theme" is picked again.
1292
+
1293
+ The side panel can be hidden with the Panel button or `p`, and then stays hidden whatever is selected. With Auto-hide ticked it shows only while something is selected or the findings are open, and the findings start folded. Both choices are remembered by the browser for every page.
1292
1294
 
1293
1295
  **A click on a box or a line selects it**, and never follows a link. The selection is amber; what feeds it is green and what it feeds is red, the lines dashed and moving the way they run; the rest dims. A line runs from the end without an arrowhead when only one end has one, and otherwise from the end written first. The panel beside the drawing names the selection and lists its links, each with its badge, and the boxes it comes from and goes to; a link opens in a new tab, and a box selects it. One line out lights by default, and the panel's button lights everything upstream and downstream. Its description, if it has one, comes last ([Descriptions](#descriptions)). A box with links underlines its text under the pointer. A click on the background or Escape clears the selection.
1294
1296
 
1295
- Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen, Escape clears the selection.
1297
+ Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen, `p` shows or hides the side panel, Escape clears the selection.
1296
1298
 
1297
1299
  ### Formatting
1298
1300
 
@@ -1414,6 +1416,13 @@ Designed and absent from the code.
1414
1416
 
1415
1417
  Pre-1.0, so the minor number is where a breaking change goes. Every removal is refused by name with the replacement quoted, rather than dropped in silence: an older file stops with an error saying what to write instead.
1416
1418
 
1419
+ **0.26.0**
1420
+
1421
+ - **The arrow keys pan a diagram**, on the `--page` page and in the playground. Press `g` to grab the diagram: a border and a hint show it is held, the arrows pan it a step at a time, and Shift makes the steps bigger. `g` again or Escape lets go, and the arrows go back to whatever else uses them. Clicking into a text field lets go too. `h` `j` `k` `l` pan the same way without a grab.
1422
+ - **The `--page` page has a theme picker**, which draws the diagram in any theme without moving anything, and Save SVG and PNG save the theme shown. It starts on the diagram's own theme, which follows the file; a theme the reader picks holds on every page in that browser until they pick the diagram's theme again.
1423
+ - **The `--page` page's side panel can be hidden**, with the Panel button or `p`, or set to Auto-hide, showing only while something is selected or the findings are open. A long description now fills the panel's height instead of scrolling in the top part of it. Closing the findings clears the highlight a finding lit.
1424
+ - The agent skill now leaves out `diagram theme:` unless the user asks for a theme.
1425
+
1417
1426
  **0.25.1**
1418
1427
 
1419
1428
  - The agent skill now says that a distance and a direction after `and` start a second placement, and how to name a point to use as a target. No change to what the tool accepts or draws.
package/README.md CHANGED
@@ -94,7 +94,7 @@ To install the skill from the main branch instead, which can run ahead of the re
94
94
 
95
95
  ## Status
96
96
 
97
- Version 0.25.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
97
+ Version 0.26.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
98
98
 
99
99
  ## License
100
100
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.25.1
1
+ # Syntax reference — 0.26.0
2
2
 
3
3
  What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
4
4
 
@@ -1288,11 +1288,13 @@ The output is a standalone SVG: every picture is drawn into it as path data, nev
1288
1288
  reladraw render arch.reladraw --page
1289
1289
  ```
1290
1290
 
1291
- `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Under the title it says which file it was drawn from and when, such as "drawn from arch.reladraw, 2026-11-05 14:32": the page is a snapshot of the file, and is behind it once the file is edited, until it is rendered again. Drag to pan; scroll, pinch or double-click to zoom; Fit shows the whole diagram and 100% returns it to its own scale. The zoom is always shown, with "Fit" beside it while the whole diagram is in view. There is a full-screen button. The Save menu saves the page itself, as it was written rather than at its current zoom; the `.reladraw` source it was drawn from; or the picture as an SVG or a PNG, as `render` would draw it; or copies the SVG. Each is named after the source file: `arch.html`, `arch.reladraw`, `arch.svg`, `arch.png`. In Chrome and Edge it asks where to save; other browsers put the file with their downloads. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer.
1291
+ `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Under the title it says which file it was drawn from and when, such as "drawn from arch.reladraw, 2026-11-05 14:32": the page is a snapshot of the file, and is behind it once the file is edited, until it is rendered again. Drag to pan, or use `h` `j` `k` `l`, or press `g` to grab the diagram and pan it with the arrow keys (Shift for bigger steps either way, `g` or Escape to let go); scroll, pinch or double-click to zoom; Fit shows the whole diagram and 100% returns it to its own scale. The zoom is always shown, with "Fit" beside it while the whole diagram is in view. There is a full-screen button. The Save menu saves the page itself, as it was written rather than at its current zoom; the `.reladraw` source it was drawn from; or the picture as an SVG or a PNG, as `render` would draw it; or copies the SVG. Each is named after the source file: `arch.html`, `arch.reladraw`, `arch.svg`, `arch.png`. In Chrome and Edge it asks where to save; other browsers put the file with their downloads. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer. The theme picker draws the diagram in another theme, as `--theme` would, keeping the view and the selection; a page or text color the file states holds in every theme, and Save SVG, PNG and Copy SVG take the theme shown. It starts on "Diagram's theme", the one the file names (or dark), which follows the file when it is rendered again. A theme picked by name is the reader's: it holds on every reladraw page in that browser, whatever the file says, until "Diagram's theme" is picked again.
1292
+
1293
+ The side panel can be hidden with the Panel button or `p`, and then stays hidden whatever is selected. With Auto-hide ticked it shows only while something is selected or the findings are open, and the findings start folded. Both choices are remembered by the browser for every page.
1292
1294
 
1293
1295
  **A click on a box or a line selects it**, and never follows a link. The selection is amber; what feeds it is green and what it feeds is red, the lines dashed and moving the way they run; the rest dims. A line runs from the end without an arrowhead when only one end has one, and otherwise from the end written first. The panel beside the drawing names the selection and lists its links, each with its badge, and the boxes it comes from and goes to; a link opens in a new tab, and a box selects it. One line out lights by default, and the panel's button lights everything upstream and downstream. Its description, if it has one, comes last ([Descriptions](#descriptions)). A box with links underlines its text under the pointer. A click on the background or Escape clears the selection.
1294
1296
 
1295
- Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen, Escape clears the selection.
1297
+ Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen, `p` shows or hides the side panel, Escape clears the selection.
1296
1298
 
1297
1299
  ### Formatting
1298
1300
 
@@ -1414,6 +1416,13 @@ Designed and absent from the code.
1414
1416
 
1415
1417
  Pre-1.0, so the minor number is where a breaking change goes. Every removal is refused by name with the replacement quoted, rather than dropped in silence: an older file stops with an error saying what to write instead.
1416
1418
 
1419
+ **0.26.0**
1420
+
1421
+ - **The arrow keys pan a diagram**, on the `--page` page and in the playground. Press `g` to grab the diagram: a border and a hint show it is held, the arrows pan it a step at a time, and Shift makes the steps bigger. `g` again or Escape lets go, and the arrows go back to whatever else uses them. Clicking into a text field lets go too. `h` `j` `k` `l` pan the same way without a grab.
1422
+ - **The `--page` page has a theme picker**, which draws the diagram in any theme without moving anything, and Save SVG and PNG save the theme shown. It starts on the diagram's own theme, which follows the file; a theme the reader picks holds on every page in that browser until they pick the diagram's theme again.
1423
+ - **The `--page` page's side panel can be hidden**, with the Panel button or `p`, or set to Auto-hide, showing only while something is selected or the findings are open. A long description now fills the panel's height instead of scrolling in the top part of it. Closing the findings clears the highlight a finding lit.
1424
+ - The agent skill now leaves out `diagram theme:` unless the user asks for a theme.
1425
+
1417
1426
  **0.25.1**
1418
1427
 
1419
1428
  - The agent skill now says that a distance and a direction after `and` start a second placement, and how to name a point to use as a target. No change to what the tool accepts or draws.
package/dist/page.js CHANGED
@@ -5,7 +5,7 @@ import { linkLabel, linksOf } from './links.js';
5
5
  import { markdownHtml } from './markdown.js';
6
6
  import { iconSvg, renderTagged, themeOf } from './render.js';
7
7
  import { resolve } from './resolve.js';
8
- import { luminance } from './themes.js';
8
+ import { DEFAULT_THEME, luminance, THEME_NAMES, THEMES } from './themes.js';
9
9
  import { PAN_ZOOM } from './viewer.js';
10
10
  /**
11
11
  * `reladraw render --page`: one diagram in one self-contained HTML page, made
@@ -23,12 +23,28 @@ export function drawPage(source, file, options = {}, at = new Date()) {
23
23
  const drawn = `drawn from ${file}, ${stamp(at)}`;
24
24
  const run = (text) => {
25
25
  const layout = resolve(parse(text), options);
26
- const { svg, tagged } = renderTagged(layout, options);
27
26
  const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
28
27
  const title = layout.diagram['title'] ?? name;
29
- const theme = themeOf(layout, options);
28
+ // The theme it was drawn in: the one --theme named, else the file's.
29
+ const named = layout.diagram['theme'];
30
+ const current = (options.theme && THEME_NAMES.find((one) => THEMES[one] === options.theme)) ??
31
+ (named !== undefined && THEMES[named] !== undefined ? named : DEFAULT_THEME);
32
+ // The same diagram drawn in every theme, for the page's theme picker. A
33
+ // theme moves nothing, so only the colors differ between them. A page or
34
+ // text color the file states still holds in each, as in the playground.
35
+ const themes = {};
36
+ for (const one of THEME_NAMES) {
37
+ const drawnIn = { ...options, theme: options.theme && one === current ? options.theme : THEMES[one] };
38
+ const theme = themeOf(layout, drawnIn);
39
+ themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
40
+ }
41
+ // A --theme that is not one of the named ones is drawn as given, and offered as such.
42
+ if (themes[current] === undefined) {
43
+ const theme = themeOf(layout, options);
44
+ themes[current] = { ...renderTagged(layout, options), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
45
+ }
30
46
  return {
31
- html: page({ title, name, drawn, source, svg, tagged, findings: found, theme, graph: graphOf(layout, theme) }),
47
+ html: page({ title, name, drawn, source, findings: found, themes, current, graph: graphOf(layout, themes) }),
32
48
  findings: found,
33
49
  };
34
50
  };
@@ -42,7 +58,7 @@ export function drawPage(source, file, options = {}, at = new Date()) {
42
58
  /** A description as the panel shows it: its Markdown as HTML, or nothing. */
43
59
  const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
44
60
  const BADGE_SIDE = 20;
45
- function graphOf(layout, theme) {
61
+ function graphOf(layout, themes) {
46
62
  const views = (links) => (links ?? []).map((link) => {
47
63
  const label = linkLabel(link, links);
48
64
  const icon = link.icon === undefined
@@ -50,7 +66,10 @@ function graphOf(layout, theme) {
50
66
  : 'picture' in link.icon
51
67
  ? layout.linkIcons[link.icon.picture]
52
68
  : link.icon;
53
- return { href: link.href, label, badge: iconSvg(icon, BADGE_SIDE, theme) };
69
+ const badges = {};
70
+ for (const [one, view] of Object.entries(themes))
71
+ badges[one] = iconSvg(icon, BADGE_SIDE, view.theme);
72
+ return { href: link.href, label, badges };
54
73
  });
55
74
  // A node with no links of its own has its container's, as a click on it in
56
75
  // the SVG follows its container's.
@@ -87,36 +106,82 @@ function stamp(at) {
87
106
  const two = (n) => String(n).padStart(2, '0');
88
107
  return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
89
108
  }
90
- function page({ title, name, drawn, source, svg, tagged, findings, theme, graph }) {
91
- const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
92
- // The browser's own controls (scroll bars, focus rings) follow the page, so
93
- // a dark diagram doesn't get a light one's. A color written by hand in a
94
- // form that can't be measured leaves the choice to the browser.
95
- const scheme = hex(theme.text) && hex(theme.background) ? (luminance(theme.text) > luminance(theme.background) ? 'dark' : 'light') : 'light dark';
109
+ const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
110
+ /**
111
+ * What may differ between one theme's drawing and another's: colors,
112
+ * opacities, and the ids made from a drawing's hash and an arrowhead's color.
113
+ * Split on, its matches are the odd pieces. Matching too much costs nothing;
114
+ * a drawing whose other pieces differ is carried whole.
115
+ */
116
+ const VARIES = /(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|opacity="[^"]*"|opacity:[^;"]*|\br[0-9a-f]{8}\b|\b[0-9a-f]{6}\b)/gi;
117
+ // The browser's own controls (scroll bars, focus rings) follow the page, so
118
+ // a dark diagram doesn't get a light one's. A color written by hand in a
119
+ // form that can't be measured leaves the choice to the browser.
120
+ function schemeOf(theme) {
121
+ return hex(theme.text) && hex(theme.background)
122
+ ? luminance(theme.text) > luminance(theme.background)
123
+ ? 'dark'
124
+ : 'light'
125
+ : 'light dark';
126
+ }
127
+ function colorsOf(theme) {
128
+ return {
129
+ '--page': theme.background,
130
+ '--ink': theme.text,
131
+ '--muted': theme.mutedText,
132
+ '--rule': theme.boxStroke,
133
+ '--accent': theme.primary.color,
134
+ '--on-accent': theme.primary.on,
135
+ };
136
+ }
137
+ function page({ title, name, drawn, source, findings, themes, current, graph }) {
138
+ const shown = themes[current];
139
+ // First the diagram's own theme, which follows the file; then every theme
140
+ // as the reader's choice, dark ones together and light together, as the
141
+ // playground's picker has them, so stepping through the list doesn't flash
142
+ // between black and white.
143
+ const group = (light) => Object.keys(themes)
144
+ .filter((one) => (schemeOf(themes[one].theme) === 'light') === light)
145
+ .map((one) => `<option value="${escapeHtml(one)}">${escapeHtml(one)}</option>`)
146
+ .join('');
147
+ const picker = `<option value="" selected>Diagram's theme (${escapeHtml(current)})</option>` +
148
+ `<optgroup label="Dark">${group(false)}</optgroup><optgroup label="Light">${group(true)}</optgroup>`;
149
+ // The shown theme goes in whole; another is only the colors that differ
150
+ // from it, when the rest of the drawing is the same, as it is since a theme
151
+ // moves nothing. That keeps the page near its size with one theme.
152
+ const pack = (other, base) => {
153
+ const theirs = other.split(VARIES);
154
+ const ours = base.split(VARIES);
155
+ const same = theirs.length === ours.length && theirs.every((piece, i) => i % 2 === 1 || piece === ours[i]);
156
+ return same ? theirs.filter((_, i) => i % 2 === 1) : other;
157
+ };
96
158
  const data = {
97
- svg,
98
159
  name,
99
160
  source,
161
+ current,
162
+ varies: VARIES.source,
163
+ themes: Object.fromEntries(Object.entries(themes).map(([one, view]) => [
164
+ one,
165
+ one === current
166
+ ? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
167
+ : { svg: pack(view.svg, shown.svg), tagged: pack(view.tagged, shown.tagged), colors: view.colors, scheme: view.scheme },
168
+ ])),
100
169
  findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
101
170
  graph,
102
171
  };
103
172
  // Replaced by functions, so a `$` in a title or a drawing is taken as written.
104
173
  // The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
105
174
  return PAGE.replace('{{panzoom}}', () => PAN_ZOOM)
106
- .replace('{{scheme}}', () => scheme)
175
+ .replace('{{scheme}}', () => shown.scheme)
107
176
  .replace('{{title}}', () => escapeHtml(title))
108
177
  .replace('{{heading}}', () => escapeHtml(title))
109
178
  .replace('{{drawn}}', () => escapeHtml(drawn))
110
179
  .replace('{{colors}}', () => [
111
- `--page: ${theme.background};`,
112
- `--ink: ${theme.text};`,
113
- `--muted: ${theme.mutedText};`,
114
- `--rule: ${theme.boxStroke};`,
115
- `--accent: ${theme.primary.color};`,
116
- `--on-accent: ${theme.primary.on};`,
180
+ ...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
117
181
  '--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
118
182
  ].join(' '))
119
- .replace('{{drawing}}', () => tagged)
183
+ .replace('{{themes}}', () => picker)
184
+ .replace('{{drawing}}', () => shown.tagged)
120
185
  .replace('{{data}}', () => scriptJson(data));
121
186
  }
122
187
  function escapeHtml(text) {
@@ -177,6 +242,16 @@ const PAGE = `<!doctype html>
177
242
  main { position: relative; flex: 1; display: flex; min-height: 0; }
178
243
  #view { position: relative; flex: 1; overflow: hidden; touch-action: none; cursor: grab; }
179
244
  #view.dragging { cursor: grabbing; }
245
+ #view.grabbed::after {
246
+ content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
247
+ border: 2px solid var(--accent);
248
+ }
249
+ .grab-hint {
250
+ position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 3;
251
+ padding: 3px 10px; border-radius: 4px; font-size: 12px; white-space: nowrap; pointer-events: none;
252
+ background: var(--accent); color: var(--on-accent);
253
+ }
254
+ .grab-hint[hidden] { display: none; }
180
255
  #stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
181
256
  #stage svg { display: block; }
182
257
  #view g[data-name].hover { filter: drop-shadow(0 0 3px var(--accent)); }
@@ -191,12 +266,24 @@ const PAGE = `<!doctype html>
191
266
  width: min(24rem, 36vw); display: flex; flex-direction: column; min-height: 0;
192
267
  border-left: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
193
268
  }
194
- aside { display: flex; flex-direction: column; min-height: 0; flex: 1;
269
+ #side[hidden] { display: none; }
270
+ /* What is selected takes the room it needs and the findings the rest; when
271
+ both want more than there is, each gives up room and scrolls. */
272
+ aside { display: flex; flex-direction: column; min-height: min(8rem, 40%); flex: 1 1 auto;
195
273
  border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
196
274
  aside[hidden] { display: none; }
275
+ select {
276
+ font: inherit; font-size: 13px; color: var(--ink); background: var(--page);
277
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
278
+ padding: 3px 6px; cursor: pointer;
279
+ }
280
+ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
281
+ .check { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; white-space: nowrap; }
282
+ .check input { margin: 0; accent-color: var(--accent); cursor: inherit; }
283
+ .check:has(input:disabled) { opacity: 0.45; cursor: default; }
197
284
 
198
285
  /* ---- what is selected ---- */
199
- #selected { padding: 10px 16px 12px; overflow: auto; flex: none; max-height: 60%; }
286
+ #selected { padding: 10px 16px 12px; overflow: auto; flex: 0 1 auto; min-height: 0; }
200
287
  #selected .empty { margin: 0; color: var(--muted); font-size: 13px; }
201
288
  #selected h2 { margin: 0 0 8px; font-size: 15px; font-weight: 600; color: var(--sel); overflow-wrap: anywhere; }
202
289
  #selected h3 {
@@ -285,6 +372,10 @@ const PAGE = `<!doctype html>
285
372
  <button id="copy-svg" type="button" title="Copy the picture's SVG to the clipboard">Copy SVG</button>
286
373
  </span>
287
374
  </span>
375
+ <select id="theme" aria-label="Theme" title="Draw the diagram in another theme">{{themes}}</select>
376
+ <span class="gap"></span>
377
+ <button id="toggle-panel" type="button" aria-controls="side" title="Show or hide the side panel (p)">Panel</button>
378
+ <label class="check" title="Show the side panel only while something is selected or the findings are open"><input id="auto-hide" type="checkbox"> Auto-hide</label>
288
379
  <button id="toggle-findings" type="button" aria-controls="findings"></button>
289
380
  </div>
290
381
  </header>
@@ -314,6 +405,29 @@ const PAGE = `<!doctype html>
314
405
  var view = document.getElementById('view');
315
406
  var stage = document.getElementById('stage');
316
407
  var svg = stage.querySelector('svg');
408
+ // The theme shown, which the picker changes; Save SVG, PNG and Copy SVG
409
+ // save the drawing in it.
410
+ var shownTheme = DATA.current;
411
+ // Another theme is carried as the colors that differ from the first, put
412
+ // back in place the first time it is shown.
413
+ var VARIES = new RegExp(DATA.varies, 'gi');
414
+ var first = DATA.themes[DATA.current];
415
+ function unpack(packed, base) {
416
+ if (typeof packed === 'string') return packed;
417
+ var pieces = base.split(VARIES);
418
+ for (var i = 1, j = 0; i < pieces.length; i += 2) pieces[i] = packed[j++];
419
+ return pieces.join('');
420
+ }
421
+ function themed(name) {
422
+ var view = DATA.themes[name];
423
+ if (!view.unpacked) {
424
+ view.svg = unpack(view.svg, first.svg);
425
+ view.tagged = unpack(view.tagged, first.tagged);
426
+ view.unpacked = true;
427
+ }
428
+ return view;
429
+ }
430
+ function shown() { return themed(shownTheme); }
317
431
  var tip = document.getElementById('tip');
318
432
  var scaleLabel = document.getElementById('scale');
319
433
  {{panzoom}}
@@ -425,7 +539,7 @@ const PAGE = `<!doctype html>
425
539
  copySvg.onclick = function () {
426
540
  function fallback() {
427
541
  var area = document.createElement('textarea');
428
- area.value = DATA.svg;
542
+ area.value = shown().svg;
429
543
  document.body.appendChild(area);
430
544
  area.select();
431
545
  var done = false;
@@ -434,14 +548,14 @@ const PAGE = `<!doctype html>
434
548
  flash(copySvg, done ? 'Copied' : 'Blocked');
435
549
  }
436
550
  if (navigator.clipboard && navigator.clipboard.writeText) {
437
- navigator.clipboard.writeText(DATA.svg).then(function () { flash(copySvg, 'Copied'); }, fallback);
551
+ navigator.clipboard.writeText(shown().svg).then(function () { flash(copySvg, 'Copied'); }, fallback);
438
552
  } else fallback();
439
553
  };
440
554
 
441
555
  var saveSvg = document.getElementById('save-svg');
442
556
  saveSvg.onclick = function () {
443
557
  saveFile(saveSvg, DATA.name + '.svg', 'SVG image', 'image/svg+xml', function () {
444
- return ready(new Blob([DATA.svg], { type: 'image/svg+xml' }));
558
+ return ready(new Blob([shown().svg], { type: 'image/svg+xml' }));
445
559
  });
446
560
  };
447
561
 
@@ -462,7 +576,7 @@ const PAGE = `<!doctype html>
462
576
  canvas.toBlob(function (blob) { blob ? done(blob) : fail(); }, 'image/png');
463
577
  };
464
578
  image.onerror = fail;
465
- image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
579
+ image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(shown().svg);
466
580
  });
467
581
  }
468
582
  savePng.onclick = function () {
@@ -509,9 +623,49 @@ const PAGE = `<!doctype html>
509
623
  var toggle = document.getElementById('toggle-findings');
510
624
  var count = DATA.findings.length;
511
625
 
626
+ /* ---- the side panel: shown, hidden, or shown only when wanted ---- */
627
+
628
+ // Hidden, it stays hidden whatever is selected. Auto-hide shows it only
629
+ // while something is selected or the findings are open. Both choices are
630
+ // remembered in this browser for every reladraw page.
631
+ var side = document.getElementById('side');
632
+ var panelButton = document.getElementById('toggle-panel');
633
+ var autoHideBox = document.getElementById('auto-hide');
634
+ var KEPT = 'reladraw-page-panel';
635
+ var panelOn = true;
636
+ var autoHide = false;
637
+ try {
638
+ var kept = JSON.parse(localStorage.getItem(KEPT) || 'null');
639
+ if (kept) { panelOn = kept.on !== false; autoHide = kept.autoHide === true; }
640
+ } catch (e) {}
641
+ function keepPanel() {
642
+ try { localStorage.setItem(KEPT, JSON.stringify({ on: panelOn, autoHide: autoHide })); } catch (e) {}
643
+ }
644
+ function layoutSide() {
645
+ side.hidden = !(panelOn && (!autoHide || selection || !panel.hidden));
646
+ // A finding lights what it is about only while the finding can be seen.
647
+ if ((panel.hidden || side.hidden) && lit && lit.length) unlight();
648
+ panelButton.setAttribute('aria-pressed', String(panelOn));
649
+ if (!toggle.disabled) toggle.setAttribute('aria-pressed', String(panelOn && !panel.hidden));
650
+ autoHideBox.checked = autoHide;
651
+ autoHideBox.disabled = !panelOn;
652
+ }
653
+ function setPanel(on) {
654
+ panelOn = on;
655
+ keepPanel();
656
+ layoutSide();
657
+ }
658
+ panelButton.onclick = function () { setPanel(!panelOn); };
659
+ autoHideBox.onchange = function () {
660
+ autoHide = autoHideBox.checked;
661
+ keepPanel();
662
+ layoutSide();
663
+ };
664
+
512
665
  function showPanel(open) {
513
666
  panel.hidden = !open;
514
667
  toggle.setAttribute('aria-pressed', String(open));
668
+ layoutSide();
515
669
  }
516
670
  if (count === 0) {
517
671
  toggle.textContent = 'No findings';
@@ -519,8 +673,14 @@ const PAGE = `<!doctype html>
519
673
  panel.hidden = true;
520
674
  } else {
521
675
  toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
522
- toggle.onclick = function () { showPanel(panel.hidden); };
523
- showPanel(true);
676
+ // Asking for the findings shows the panel they are in.
677
+ toggle.onclick = function () {
678
+ if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
679
+ else showPanel(panel.hidden);
680
+ };
681
+ // Open from the start, unless the panel is to stay out of the way.
682
+ panel.hidden = autoHide;
683
+ toggle.setAttribute('aria-pressed', String(!autoHide));
524
684
  }
525
685
 
526
686
  var lit = [];
@@ -593,24 +753,28 @@ const PAGE = `<!doctype html>
593
753
  function nodeGroups(name) { return svg.querySelectorAll('g[data-node="' + CSS.escape(name) + '"]'); }
594
754
  function edgeGroups(index) { return svg.querySelectorAll('g[data-edge="' + index + '"]'); }
595
755
 
596
- // A line is too thin to hit, so each gets an unseen wider copy along it.
597
- each(svg.querySelectorAll('g[data-edge]'), function (group) {
598
- each(group.querySelectorAll('line, path[fill="none"], polyline'), function (stroke) {
599
- if (stroke.getAttribute('stroke-opacity') === '0') { stroke.classList.add('target'); return; }
600
- var wide = stroke.cloneNode(false);
601
- ['marker-start', 'marker-end', 'mask', 'stroke-dasharray'].forEach(function (name) { wide.removeAttribute(name); });
602
- wide.setAttribute('stroke-width', '12');
603
- wide.setAttribute('pointer-events', 'stroke');
604
- wide.classList.add('target');
605
- stroke.parentNode.insertBefore(wide, stroke.nextSibling);
756
+ // Run on each drawing the stage is given: at the start, and after a theme change.
757
+ function prepare() {
758
+ // A line is too thin to hit, so each gets an unseen wider copy along it.
759
+ each(svg.querySelectorAll('g[data-edge]'), function (group) {
760
+ each(group.querySelectorAll('line, path[fill="none"], polyline'), function (stroke) {
761
+ if (stroke.getAttribute('stroke-opacity') === '0') { stroke.classList.add('target'); return; }
762
+ var wide = stroke.cloneNode(false);
763
+ ['marker-start', 'marker-end', 'mask', 'stroke-dasharray'].forEach(function (name) { wide.removeAttribute(name); });
764
+ wide.setAttribute('stroke-width', '12');
765
+ wide.setAttribute('pointer-events', 'stroke');
766
+ wide.classList.add('target');
767
+ stroke.parentNode.insertBefore(wide, stroke.nextSibling);
768
+ });
606
769
  });
607
- });
608
- Object.keys(graph.nodes).forEach(function (name) {
609
- if (graph.nodes[name].links.length > 0) each(nodeGroups(name), function (group) { group.classList.add('has-links'); });
610
- });
611
- graph.edges.forEach(function (edge, index) {
612
- if (edge.links.length > 0) each(edgeGroups(index), function (group) { group.classList.add('has-links'); });
613
- });
770
+ Object.keys(graph.nodes).forEach(function (name) {
771
+ if (graph.nodes[name].links.length > 0) each(nodeGroups(name), function (group) { group.classList.add('has-links'); });
772
+ });
773
+ graph.edges.forEach(function (edge, index) {
774
+ if (edge.links.length > 0) each(edgeGroups(index), function (group) { group.classList.add('has-links'); });
775
+ });
776
+ }
777
+ prepare();
614
778
 
615
779
  function clearMarks() {
616
780
  svg.classList.remove('selecting');
@@ -692,6 +856,7 @@ const PAGE = `<!doctype html>
692
856
  }
693
857
 
694
858
  function showSelected() {
859
+ layoutSide();
695
860
  selectedPanel.textContent = '';
696
861
  if (!selection) {
697
862
  selectedPanel.append(element('p', 'empty', 'Click a box or a line to see its links and what it connects to.'));
@@ -725,7 +890,7 @@ const PAGE = `<!doctype html>
725
890
  row.target = '_blank';
726
891
  row.rel = 'noopener';
727
892
  row.title = link.href;
728
- row.innerHTML = link.badge;
893
+ row.innerHTML = link.badges[shownTheme];
729
894
  row.append(element('span', 'label', link.label));
730
895
  selectedPanel.append(row);
731
896
  });
@@ -773,17 +938,64 @@ const PAGE = `<!doctype html>
773
938
  if (link && svg.contains(link)) event.preventDefault();
774
939
  });
775
940
 
941
+ /* ---- the theme picker ---- */
942
+
943
+ // "Diagram's theme" follows the file, so an edit to its theme shows on the
944
+ // next render. Any other choice is the reader's, and holds on every reladraw
945
+ // page in this browser, whatever its file says, until the diagram's theme
946
+ // is chosen again.
947
+ var picker = document.getElementById('theme');
948
+ var schemeMeta = document.querySelector('meta[name="color-scheme"]');
949
+ var CHOSEN_THEME = 'reladraw-page-theme';
950
+
951
+ // The same drawing in other colors: nothing moves, so the view, the
952
+ // selection and the panel stay as they were.
953
+ function showTheme(name) {
954
+ if (!DATA.themes[name] || name === shownTheme) return;
955
+ shownTheme = name;
956
+ var next = shown();
957
+ Object.keys(next.colors).forEach(function (key) { document.documentElement.style.setProperty(key, next.colors[key]); });
958
+ schemeMeta.setAttribute('content', next.scheme);
959
+ unlight();
960
+ hovered = null;
961
+ tip.style.display = 'none';
962
+ stage.innerHTML = next.tagged;
963
+ svg = stage.querySelector('svg');
964
+ prepare();
965
+ zoom.show();
966
+ select(selection);
967
+ }
968
+
969
+ picker.onchange = function () {
970
+ try {
971
+ if (picker.value) localStorage.setItem(CHOSEN_THEME, picker.value);
972
+ else localStorage.removeItem(CHOSEN_THEME);
973
+ } catch (e) {}
974
+ showTheme(picker.value || DATA.current);
975
+ };
976
+
977
+ // A theme chosen on an earlier page, if this page has it.
978
+ try {
979
+ var chosenTheme = localStorage.getItem(CHOSEN_THEME);
980
+ if (chosenTheme && DATA.themes[chosenTheme]) {
981
+ picker.value = chosenTheme;
982
+ showTheme(chosenTheme);
983
+ }
984
+ } catch (e) {}
985
+
776
986
  /* ---- keys, beyond the zoom's own ---- */
777
987
 
778
988
  document.addEventListener('keydown', function (event) {
779
989
  if (event.ctrlKey || event.metaKey || event.altKey) return;
780
- if (event.target.closest && event.target.closest('input, textarea')) return;
781
- if (event.key === 'f' && !full.hidden) toggleFull();
990
+ if (event.target.closest && event.target.closest('input, textarea, select')) return;
991
+ if (event.key === 'p') setPanel(!panelOn);
992
+ else if (event.key === 'f' && !full.hidden) toggleFull();
782
993
  else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
783
994
  else return;
784
995
  event.preventDefault();
785
996
  });
786
997
 
998
+ layoutSide();
787
999
  zoom.show();
788
1000
  })();
789
1001
  </script>
package/dist/viewer.d.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * The pan and zoom shared by the page `render --page` writes and the
3
3
  * playground, so the two look at a diagram the same way: − and +, Fit, 100%,
4
4
  * the zoom always shown and saying "Fit" while fitted, the wheel, a pinch, a
5
- * drag, a double-click, and the keys + - 0 1.
5
+ * drag, a double-click, the keys + - 0 1, h j k l to pan, and g to grab the
6
+ * diagram so the arrows pan it too.
6
7
  *
7
8
  * It is browser code kept as a string, since it runs in a page, not here: the
8
9
  * page writes it into its script, and the playground's build writes it into
package/dist/viewer.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * The pan and zoom shared by the page `render --page` writes and the
3
3
  * playground, so the two look at a diagram the same way: − and +, Fit, 100%,
4
4
  * the zoom always shown and saying "Fit" while fitted, the wheel, a pinch, a
5
- * drag, a double-click, and the keys + - 0 1.
5
+ * drag, a double-click, the keys + - 0 1, h j k l to pan, and g to grab the
6
+ * diagram so the arrows pan it too.
6
7
  *
7
8
  * It is browser code kept as a string, since it runs in a page, not here: the
8
9
  * page writes it into its script, and the playground's build writes it into
@@ -169,9 +170,57 @@ function panZoom(view, stage, label, buttons) {
169
170
  // The view's own size, not the window's: a dragged divider changes it too.
170
171
  new ResizeObserver(refit).observe(view);
171
172
 
173
+ function typing(target) {
174
+ return !!(target && target.closest && target.closest('input, textarea, select, [contenteditable]'));
175
+ }
176
+
177
+ // g grabs the diagram, and while it is held the arrows pan it; g again or
178
+ // Escape lets go, and the arrows go back to whatever else uses them. The
179
+ // keys are taken before the page's own handlers see them, so a held
180
+ // diagram's arrows don't also move a list or a divider, and its Escape lets
181
+ // go without also clearing what is selected.
182
+ var held = false;
183
+ var hint = document.createElement('div');
184
+ hint.className = 'grab-hint';
185
+ hint.textContent = 'Arrows or h j k l pan · Shift for bigger steps · g or Esc to let go';
186
+ hint.hidden = true;
187
+ view.appendChild(hint);
188
+
189
+ function grab(on) {
190
+ held = on;
191
+ hint.hidden = !on;
192
+ view.classList.toggle('grabbed', on);
193
+ }
194
+
195
+ var ARROWS = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
196
+ // h j k l pan as the arrows do, without a grab: nothing else uses them.
197
+ var LETTERS = { h: [1, 0], l: [-1, 0], k: [0, 1], j: [0, -1] };
198
+
199
+ document.addEventListener('keydown', function (event) {
200
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
201
+ if (typing(event.target)) return;
202
+ var arrow = (held && ARROWS[event.key]) || LETTERS[event.key.toLowerCase()];
203
+ if (event.key === 'g' || event.key === 'G') grab(!held);
204
+ else if (held && event.key === 'Escape') grab(false);
205
+ else if (arrow) {
206
+ // A tenth of the view a press, half of it with Shift; held down, the
207
+ // key repeats.
208
+ var room = size();
209
+ var part = event.shiftKey ? 0.5 : 0.1;
210
+ pan(arrow[0] * room.w * part, arrow[1] * room.h * part);
211
+ } else return;
212
+ event.preventDefault();
213
+ event.stopImmediatePropagation();
214
+ }, true);
215
+
216
+ // Starting to type lets go, so the arrows move the cursor again.
217
+ document.addEventListener('focusin', function (event) {
218
+ if (held && typing(event.target)) grab(false);
219
+ });
220
+
172
221
  document.addEventListener('keydown', function (event) {
173
222
  if (event.ctrlKey || event.metaKey || event.altKey) return;
174
- if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
223
+ if (typing(event.target)) return;
175
224
  if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
176
225
  else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
177
226
  else if (event.key === '0') fit();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {