reladraw 0.8.1 → 0.9.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.
package/NOTICE CHANGED
@@ -31,6 +31,8 @@ Nord Copyright (c) 2016-present Sven Greb
31
31
  https://github.com/nordtheme/nord
32
32
  Dracula Copyright (c) 2023 Dracula Theme
33
33
  https://github.com/dracula/dracula-theme
34
+ Vesper Copyright (c) 2023 Rauno Freiberg
35
+ https://github.com/raunofreiberg/vesper
34
36
 
35
37
  Permission is hereby granted, free of charge, to any person obtaining a copy
36
38
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -66,7 +66,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
66
66
 
67
67
  ## Status
68
68
 
69
- Version 0.8.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.
69
+ Version 0.9.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.
70
70
 
71
71
  The language isn't stable yet, so expect the syntax to change.
72
72
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.8.1
1
+ # Syntax reference — 0.9.0
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -116,18 +116,18 @@ They are bracketed onto the text rather than written among the node's attributes
116
116
  An edge's text takes the same brackets, less `at`: a node's text sits somewhere in a box and an edge's rides at the middle of its line, so there is no position to name.
117
117
 
118
118
  ```
119
- edge a -> b "rclone" (color: muted, size: small)
119
+ edge a -> b "rclone" (color: theme-muted, size: small)
120
120
  ```
121
121
 
122
122
  A **style** has no text of its own for a bracket to hang off, so it hangs the bracket off a key instead:
123
123
 
124
124
  ```
125
- style aside text: (size: small, color: muted)
125
+ style aside text: (size: small, color: theme-muted)
126
126
  ```
127
127
 
128
128
  Bundling a text's properties into a style is how they come to mean something: `style aside` applied to several nodes says those are the same kind of remark, which `(size: small)` written out at each of them does not.
129
129
 
130
- `color` takes a color written as the viewer will receive it — `#8b8b8b`, or any CSS color — or the one reserved word `muted`, which means the theme's secondary text color and so survives a change of theme.
130
+ `color` takes a color written as the viewer will receive it — `#8b8b8b`, or any CSS color — or a theme color such as `theme-muted`, the theme's secondary text color, which survives a change of theme. See "Theme colors".
131
131
 
132
132
  `size: small | normal | large` sets how big the text is set, and so does a plain number of pixels, `size: 22`. The names are the default and the preferred form: they follow the document's size if it is ever set differently, where `22` stays 22. A number says nothing about *why* one text is bigger than another, so bundle it into a style that does. `14px` is refused, since the number is already in pixels, and so is zero or less. Every kind of text has a default and `size` overrides it, exactly as `fill:` overrides the theme's color; only `shape: none` defaults to anything but `normal`, and it defaults to `small`.
133
133
 
@@ -144,7 +144,7 @@ Where the node is exactly the size of what it holds — which is most leaves, si
144
144
  A word or a stretch of a text can borrow the look of a style:
145
145
 
146
146
  ```
147
- style dim text: (color: muted)
147
+ style dim text: (color: theme-muted)
148
148
 
149
149
  node pc.files "shared folder / [dim]synced[/dim]"
150
150
  ```
@@ -395,7 +395,7 @@ edge <from> <-> <to> ["<text>"] [between <a> and <b> [vertically|horizontally]]
395
395
 
396
396
  Endpoints may be nested (`desktop1.files`). An edge never says where a node goes and routing is the renderer's problem, with one exception: an edge with text claims room in the gap it crosses, which is the next section.
397
397
 
398
- An edge's text breaks on ` / ` exactly as a node's does, and the block centers on the point the text would otherwise have occupied, so ``"run `deploy` / shell command"`` stacks its two lines around the midpoint of the line rather than running off along it. An edge's text takes the same brackets a node's does, less `at`, so `(wrap: 20)` folds it and `(color: muted)` quiets it.
398
+ An edge's text breaks on ` / ` exactly as a node's does, and the block centers on the point the text would otherwise have occupied, so ``"run `deploy` / shell command"`` stacks its two lines around the midpoint of the line rather than running off along it. An edge's text takes the same brackets a node's does, less `at`, so `(wrap: 20)` folds it and `(color: theme-muted)` quiets it.
399
399
 
400
400
  ### A text makes room for itself
401
401
 
@@ -872,19 +872,19 @@ A color is never written in quotes, and a quoted one is refused. There is nothin
872
872
 
873
873
  There is no list of color words the tool knows. An earlier version had one, and it was wrong in the way such lists always are: `dark-green` existed only because somebody added it to a map in the renderer, and the next color a diagram wanted would have needed a code change to say. Writing the color directly removes both the list and the reason to grow it. `green` still works, because it is a CSS color, not because this tool has heard of it.
874
874
 
875
- A style carrying `text: (color: muted)` is what a marked-up word borrows from, which is how a node carries a name with a quieter qualifier under it:
875
+ A style carrying `text: (color: theme-muted)` is what a marked-up word borrows from, which is how a node carries a name with a quieter qualifier under it:
876
876
 
877
877
  ```
878
878
  style synced fill: #142814 border: #486544
879
- style dim text: (color: muted)
879
+ style dim text: (color: theme-muted)
880
880
  node pc.files "shared folder / [dim]synced[/dim]" style: synced
881
881
  ```
882
882
 
883
883
  **Removed: `subtext`.** It colored every text line after the first, which is a positional slice: the rule lived in a style elsewhere in the file and was applied by counting, so a reader of `"shared folder / synced"` could not see that the second line was quiet. The mark says what is quiet where it is quiet, and reaches a word in the middle of a line, which the slice never could. An older file carrying it gets an error naming the mark to write instead.
884
884
 
885
- `badge`, `icon` and `shape` belong in a style for the same reason a color does: they say what kind of thing this is, and a kind wants to look alike everywhere it appears. `style artifact fill: #460000 shape: document` puts the folded corner on every dump in the diagram, and the use site stays one word.
885
+ `badge`, `icon` and `shape` belong in a style for the same reason a color does: they say what kind of thing this is, and a kind wants to look alike everywhere it appears. `style artifact fill: theme-secondary-subtle shape: document` puts the folded corner on every dump in the diagram, and the use site stays one word.
886
886
 
887
- `muted` is the one reserved word left, and it earns the exception: it means the theme's secondary text color rather than a fixed one, so a quiet line stays readable when the theme changes. Writing `#8b8b8b` instead would pin it to one theme. Say nothing and every line of a text reads alike, which is what most texts want — `Computer 1 / Ubuntu` is two lines of one name, not a name and a qualifier, and the distinction is the author's to make rather than the renderer's to guess.
887
+ `theme-muted` means the theme's secondary text color rather than a fixed one, so a quiet line stays readable when the theme changes. Writing `#8b8b8b` instead would pin it to one theme. Say nothing and every line of a text reads alike, which is what most texts want — `Computer 1 / Ubuntu` is two lines of one name, not a name and a qualifier, and the distinction is the author's to make rather than the renderer's to guess.
888
888
 
889
889
  ## The diagram itself
890
890
 
@@ -921,15 +921,50 @@ diagram theme: solarized-dark
921
921
  | `catppuccin-mocha`, `catppuccin-latte` | [Catppuccin](https://catppuccin.com) |
922
922
  | `nord` | [Nord](https://www.nordtheme.com) |
923
923
  | `dracula` | [Dracula](https://draculatheme.com) |
924
+ | `vesper` | [Vesper](https://github.com/raunofreiberg/vesper) |
924
925
  | `high-contrast-dark`, `high-contrast-light` | no fills to lean on, every line at full strength |
925
926
  | `print` | a white page, black lines and no fills, for paper |
926
927
 
927
928
  The borrowed palettes were made for code, where a color marks a keyword or a string. A diagram needs a page, a box, a border, text and a line, so each theme is its scheme read as a diagram rather than a copy of it. Their licenses are in `NOTICE`.
928
929
 
929
- A theme only ever supplies a color, so changing it never moves anything. A color you write yourself — `fill: #14532d`, or a style's — stays that color under every theme, which means a hand-picked color can clash with a theme it was not chosen against. `muted` is the one word that follows the theme.
930
+ A theme only ever supplies a color, so changing it never moves anything. A color you write yourself — `fill: #14532d`, or a style's — stays that color under every theme, which means a hand-picked color can clash with a theme it was not chosen against. To color something and have it follow the theme, name a theme color instead.
930
931
 
931
932
  An unknown name is an error that lists the themes.
932
933
 
934
+ ### Theme colors
935
+
936
+ Every theme defines the same set of colors, and any attribute that takes a color takes their names. All of them begin `theme-`, so a color that follows the theme says so, and no CSS color name is taken: `red` is still CSS red.
937
+
938
+ The theme's own color for each part — what a box or a line gets when the file says nothing:
939
+
940
+ | color | is |
941
+ |---|---|
942
+ | `theme-page` | the page |
943
+ | `theme-text` | text |
944
+ | `theme-muted` | quieter, secondary text |
945
+ | `theme-fill` | a box's fill |
946
+ | `theme-border` | a box's outline |
947
+ | `theme-line` | a line |
948
+
949
+ And its two accents, each at two strengths, with the text that reads on it:
950
+
951
+ | color | is |
952
+ |---|---|
953
+ | `theme-primary` | the theme's signature color — Dracula's purple, Solarized's blue |
954
+ | `theme-primary-subtle` | the same mixed toward the page: dark on a dark theme, pale on a light one |
955
+ | `theme-on-primary` | text that reads on a solid `theme-primary` |
956
+ | `theme-secondary`, `theme-secondary-subtle`, `theme-on-secondary` | the same for its second color |
957
+
958
+ ```
959
+ style synced fill: theme-primary-subtle border: theme-primary
960
+ style live fill: theme-primary
961
+ style dim text: (color: theme-muted)
962
+ ```
963
+
964
+ A subtle fill is how a box is set apart: its text is the theme's ordinary text, and it reads in every theme. A solid fill is a badge. A box with a theme fill and no text color of its own gets text that reads on that fill, so `style live` above needs no `text:` — its text is `theme-on-primary`. A hand-picked fill gets no such help; its text is the theme's, as before.
965
+
966
+ A `theme-` name the theme does not define is refused with the list above.
967
+
933
968
  The command line can render a file in another theme without editing it — `reladraw arch.reladraw --theme light` — and the flag wins over the file's `theme:`. That is how one source becomes a light and a dark image for a README that shows whichever matches the reader's settings. The file's own `background:` and `text:` still win over the flag's theme, as a hand-written color wins over any theme.
934
969
 
935
970
  ### Defaults
@@ -1021,6 +1056,14 @@ That one was found by testing the lexer, not by rendering — and it could not h
1021
1056
 
1022
1057
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below 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.
1023
1058
 
1059
+ **0.9.0**
1060
+
1061
+ - Theme colors: `theme-page`, `theme-text`, `theme-muted`, `theme-fill`, `theme-border` and `theme-line` name the theme's own color for each part, and `theme-primary` and `theme-secondary`, with `-subtle` and `theme-on-` forms, name its two accents. Any attribute that takes a color takes them, and they follow the theme when it changes, so a diagram colored with them reads in light and dark themes alike. See "Theme colors".
1062
+ - A box with a theme fill and no text color of its own gets text that reads on that fill.
1063
+ - **Breaking:** `muted` is now `theme-muted`. `color: muted` is refused with the new name.
1064
+ - The examples use theme colors where they used to hand-pick colors for a dark page, so they read in every theme.
1065
+ - A fourteenth theme, `vesper`, from [Vesper](https://github.com/raunofreiberg/vesper).
1066
+
1024
1067
  **0.8.1**
1025
1068
 
1026
1069
  - A fix, no syntax change. An edge naming a side at both ends could be drawn through one of its own nodes in two cases. With the nodes stacked and the sides at right angles, such as `from: bottom to: left`, the line went down past the lower node and came back up through it; it is now one curve round the corner. With both ends naming the same side, such as `from: left to: left`, the line ran straight through the node behind; it now goes over the top of that node, or round the right in a column. Existing diagrams with such an edge will look different, and correct.
package/dist/ast.d.ts CHANGED
@@ -225,7 +225,7 @@ export declare function describeAxis(axis: Axis): string;
225
225
  /** `key: value` pairs trailing a statement. Values are always strings here. */
226
226
  export type Attrs = Record<string, string>;
227
227
  /**
228
- * What a text's brackets may say: `"Docker" (at: bottom-center, color: muted)`.
228
+ * What a text's brackets may say: `"Docker" (at: bottom-center, color: theme-muted)`.
229
229
  *
230
230
  * They are bracketed onto the text rather than written among the node's
231
231
  * attributes for the same reason a gap is bracketed onto its placement — they
@@ -234,7 +234,7 @@ export type Attrs = Record<string, string>;
234
234
  * `shape`, `icon`, `fill`, `border`, `gap`, `overlap`, `style`.
235
235
  *
236
236
  * In a style, which has no string for a bracket to hang off, the bracket hangs
237
- * off the key instead: `style synced text: (color: muted)`.
237
+ * off the key instead: `style synced text: (color: theme-muted)`.
238
238
  *
239
239
  * `at` and `align` are independent and neither implies the other. `at` is where
240
240
  * the block of text sits in the node — one of the nine named positions — and
package/dist/ast.js CHANGED
@@ -206,7 +206,7 @@ export function describeAxis(axis) {
206
206
  return axis === 'y' ? 'vertically' : 'horizontally';
207
207
  }
208
208
  /**
209
- * What a text's brackets may say: `"Docker" (at: bottom-center, color: muted)`.
209
+ * What a text's brackets may say: `"Docker" (at: bottom-center, color: theme-muted)`.
210
210
  *
211
211
  * They are bracketed onto the text rather than written among the node's
212
212
  * attributes for the same reason a gap is bracketed onto its placement — they
@@ -215,7 +215,7 @@ export function describeAxis(axis) {
215
215
  * `shape`, `icon`, `fill`, `border`, `gap`, `overlap`, `style`.
216
216
  *
217
217
  * In a style, which has no string for a bracket to hang off, the bracket hangs
218
- * off the key instead: `style synced text: (color: muted)`.
218
+ * off the key instead: `style synced text: (color: theme-muted)`.
219
219
  *
220
220
  * `at` and `align` are independent and neither implies the other. `at` is where
221
221
  * the block of text sits in the node — one of the nine named positions — and
package/dist/parser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
- import { THEME_NAMES, THEMES } from './themes.js';
4
+ import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
5
5
  /** Parse a whole source file. One statement per line; blanks and comments drop out. */
6
6
  export function parse(source) {
7
7
  const statements = [];
@@ -142,7 +142,7 @@ const BRACKET_KEYS = {
142
142
  };
143
143
  /** How each bracketed key's error quotes itself back, and what it is about. */
144
144
  const BRACKET_ABOUT = {
145
- text: { kind: 'a text', example: 'color: muted' },
145
+ text: { kind: 'a text', example: 'color: theme-muted' },
146
146
  contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
147
147
  line: { kind: 'a line', example: 'path: square' },
148
148
  };
@@ -183,7 +183,7 @@ function readAttr(tokens, at, attrs, line, subject) {
183
183
  const key = keyToken.text.slice(0, -1);
184
184
  const bracketKeys = BRACKET_KEYS[key];
185
185
  if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
186
- // `text: (color: muted)` — the whole bracket belongs to one part, and it is
186
+ // `text: (color: theme-muted)` — the whole bracket belongs to one part, and it is
187
187
  // stored under dotted keys so that a style merges into a node exactly the
188
188
  // way every other attribute does.
189
189
  const read = readBracket(tokens, at + 1, bracketKeys, {
@@ -298,7 +298,7 @@ function readAttr(tokens, at, attrs, line, subject) {
298
298
  // applied by counting, so a reader of the text could not see it. Markup
299
299
  // says what is quiet where it is quiet, and reaches a word in the middle of
300
300
  // a line, which the slice never could.
301
- throw new SourceError('`subtext:` has been replaced by markup in the text — write `style dim text: (color: muted)` ' +
301
+ throw new SourceError('`subtext:` has been replaced by markup in the text — write `style dim text: (color: theme-muted)` ' +
302
302
  'and mark the quiet words as `"Dropbox / [dim]synced[/dim]"`', line);
303
303
  }
304
304
  if (key === 'text') {
@@ -342,6 +342,8 @@ function readAttr(tokens, at, attrs, line, subject) {
342
342
  }
343
343
  throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
344
344
  }
345
+ if (COLOR_KEYS.includes(key))
346
+ checkColorWord(valueToken.text, subject, line);
345
347
  setOnce(attrs, key, valueToken.text, subject, line);
346
348
  return at + 2;
347
349
  }
@@ -457,7 +459,7 @@ function parseEdge(head, line) {
457
459
  subject,
458
460
  what: 'the text',
459
461
  kind: "an edge's text",
460
- example: 'color: muted',
462
+ example: 'color: theme-muted',
461
463
  line,
462
464
  });
463
465
  at = bracket.next;
@@ -839,6 +841,8 @@ function readBracket(tokens, start, keys, about) {
839
841
  // the comma are the same statement.
840
842
  const value = valueToken.text;
841
843
  const clean = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
844
+ if (key === 'color')
845
+ checkColorWord(clean, about.subject, about.line);
842
846
  const had = values[key];
843
847
  if (had !== undefined) {
844
848
  throw new SourceError(`${about.subject}: "${key}" is written twice in the brackets after ${about.what} (${had}, ${clean}) — keep one`, about.line);
@@ -848,6 +852,20 @@ function readBracket(tokens, start, keys, about) {
848
852
  }
849
853
  return { values, next: i + 1 };
850
854
  }
855
+ /**
856
+ * A color is any CSS color, which cannot be checked, or a `theme-` word, which
857
+ * can. So a `theme-` word the theme does not define is refused rather than
858
+ * handed to the viewer as a color that draws nothing, and `muted`, the one
859
+ * theme word from before the prefix, is refused with its new name.
860
+ */
861
+ function checkColorWord(value, subject, line) {
862
+ if (value === 'muted') {
863
+ throw new SourceError(`${subject}: \`muted\` is now \`theme-muted\` — every color that follows the theme begins \`theme-\``, line);
864
+ }
865
+ if (value.startsWith('theme-') && !THEME_COLORS.includes(value)) {
866
+ throw new SourceError(`${subject}: there is no theme color "${value}" — the theme colors are ${THEME_COLORS.join(', ')}`, line);
867
+ }
868
+ }
851
869
  function follows(tokens, at, word) {
852
870
  const token = tokens[at];
853
871
  return token !== undefined && !token.quoted && token.text === word;
package/dist/render.js CHANGED
@@ -3,7 +3,7 @@ import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
- import { DARK_THEME, THEMES } from './themes.js';
6
+ import { DARK_THEME, THEMES, textOnFill, themeColor } from './themes.js';
7
7
  import { plain } from './text.js';
8
8
  const CORNER = 8;
9
9
  /** Turn solved geometry into a standalone SVG document. */
@@ -17,10 +17,12 @@ export function render(layout, options = {}) {
17
17
  // everything downstream sees one theme.
18
18
  const named = layout.diagram['theme'];
19
19
  const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
20
+ const page = layout.diagram['background'];
21
+ const words = layout.diagram['text.color'];
20
22
  const theme = {
21
23
  ...base,
22
- ...(layout.diagram['background'] !== undefined && { background: layout.diagram['background'] }),
23
- ...(layout.diagram['text.color'] !== undefined && { text: layout.diagram['text.color'] }),
24
+ ...(page !== undefined && { background: themeColor(page, base) }),
25
+ ...(words !== undefined && { text: themeColor(words, base) }),
24
26
  };
25
27
  const body = [];
26
28
  for (const root of layout.roots) {
@@ -73,7 +75,7 @@ export function render(layout, options = {}) {
73
75
  width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
74
76
  height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
75
77
  };
76
- const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme.edge)));
78
+ const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme, theme.edge)));
77
79
  return [
78
80
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
79
81
  ' <defs>',
@@ -183,11 +185,13 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
183
185
  // including a lone badge beside its text. Every rule that tried to tell a
184
186
  // badge from contents was a guess; this one is visible in the source.
185
187
  const container = node.children.length > 0;
186
- const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
187
- const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
188
+ const border = borderOf(node.appearance, theme, container ? theme.containerStroke : theme.boxStroke);
189
+ const fill = fillOf(node.appearance, theme, container ? theme.containerFill : theme.boxFill);
188
190
  // A box is the one kind with two inkable parts, which is why its text needs
189
- // a word of its own — `border:` cannot stand in for it.
190
- const text = textColorOf(node.textAttrs, theme, theme.text);
191
+ // a word of its own — `border:` cannot stand in for it. Left unsaid on a
192
+ // theme fill, it is whatever reads on that fill: `fill: theme-primary` alone
193
+ // makes a badge whose text is legible in every theme.
194
+ const text = textColorOf(node.textAttrs, theme, textOnFill(node.appearance['fill'] ?? '', theme) ?? theme.text);
191
195
  // Deck copies sit behind the front face, furthest back drawn first.
192
196
  for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
193
197
  const x = face.x - depth * DECK_STEP;
@@ -296,7 +300,7 @@ function drawIcon(icon, x, y, side, theme) {
296
300
  }
297
301
  function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize, markup) {
298
302
  const { start, end } = ends;
299
- const color = lineOf(edge.appearance, theme.edge);
303
+ const color = lineOf(edge.appearance, theme, theme.edge);
300
304
  const stroke = strokeOf(color, edge.look);
301
305
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
302
306
  const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
@@ -409,7 +413,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
409
413
  parts.push(sized(textBlock(lines, midX - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
410
414
  // A colored edge carries its meaning into its text; an uncolored
411
415
  // one leaves the words to read as ordinary text.
412
- color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
416
+ color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
413
417
  align: 'middle',
414
418
  ink: (run, own) => runInk(run, own, markup, theme),
415
419
  }), size, fontSize));
@@ -2654,37 +2658,37 @@ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
2654
2658
  function runInk(run, own, markup, theme) {
2655
2659
  if (run.style === undefined)
2656
2660
  return own;
2657
- return namedColor(markup[run.style], theme);
2661
+ return themeColor(markup[run.style], theme);
2658
2662
  }
2659
2663
  /**
2660
- * A text's own color. `muted` is the one reserved word: it defers to the theme,
2661
- * so a quiet line stays readable when the theme changes. Anything else is a
2662
- * color, the same as `fill:` and `border:` take.
2664
+ * A text's own color, the same words `fill:` and `border:` take — so
2665
+ * `theme-muted` is a quiet line that stays readable when the theme changes.
2663
2666
  */
2664
2667
  function textColorOf(textAttrs, theme, fallback) {
2665
2668
  const value = textAttrs['color'];
2666
- return value === undefined ? fallback : namedColor(value, theme);
2667
- }
2668
- function namedColor(value, theme) {
2669
- return value === 'muted' ? theme.mutedText : value;
2669
+ return value === undefined ? fallback : themeColor(value, theme);
2670
2670
  }
2671
2671
  /**
2672
2672
  * A color is written as the viewer will receive it — `#14532d`, or any CSS
2673
- * color. The renderer keeps no list of color words of its own, so a diagram
2674
- * is never limited to the ones somebody remembered to add here.
2673
+ * color — or as one of the `theme-` words, which the theme answers. The
2674
+ * renderer keeps no list of color words of its own, so a diagram is never
2675
+ * limited to the ones somebody remembered to add here.
2675
2676
  *
2676
2677
  * Each names the part it colors, so each reads exactly one key. The word these
2677
2678
  * replaced, `stroke:`, named no part and meant a different one on every kind,
2678
2679
  * which is why a box's text could not be colored at all until `text:`.
2679
2680
  */
2680
- function borderOf(appearance, fallback) {
2681
- return appearance['border'] ?? fallback;
2681
+ function borderOf(appearance, theme, fallback) {
2682
+ const value = appearance['border'];
2683
+ return value === undefined ? fallback : themeColor(value, theme);
2682
2684
  }
2683
- function lineOf(appearance, fallback) {
2684
- return appearance['line'] ?? fallback;
2685
+ function lineOf(appearance, theme, fallback) {
2686
+ const value = appearance['line'];
2687
+ return value === undefined ? fallback : themeColor(value, theme);
2685
2688
  }
2686
- function fillOf(appearance, fallback) {
2687
- return appearance['fill'] ?? fallback;
2689
+ function fillOf(appearance, theme, fallback) {
2690
+ const value = appearance['fill'];
2691
+ return value === undefined ? fallback : themeColor(value, theme);
2688
2692
  }
2689
2693
  function round(value) {
2690
2694
  return Math.round(value * 100) / 100;
package/dist/resolve.js CHANGED
@@ -391,7 +391,7 @@ function checkStyleKeys(statements) {
391
391
  }
392
392
  /**
393
393
  * The attribute a key belongs to. A bracketed value arrives under dotted keys —
394
- * `text: (color: muted)` is stored as `text.color` — so that a style merges into
394
+ * `text: (color: theme-muted)` is stored as `text.color` — so that a style merges into
395
395
  * a node exactly the way every other attribute does; every check above asks
396
396
  * about the part, which is the half before the dot.
397
397
  */
package/dist/themes.d.ts CHANGED
@@ -28,7 +28,33 @@ export interface Theme {
28
28
  iconInk: string;
29
29
  /** The body an icon's lines enclose. */
30
30
  iconShade: string;
31
+ /** The theme's signature color, which `theme-primary` names. */
32
+ primary: Accent;
33
+ /** Its second color, which `theme-secondary` names. */
34
+ secondary: Accent;
31
35
  }
36
+ /**
37
+ * An accent at the two strengths a file can ask for, and the text that reads
38
+ * on it. `subtle` is the accent mixed toward the page — dark on a dark theme,
39
+ * pale on a light one — so `fill: theme-primary-subtle` is a box set apart in
40
+ * every theme, and its text is the theme's ordinary text.
41
+ */
42
+ export interface Accent {
43
+ color: string;
44
+ subtle: string;
45
+ /** Text on a solid fill of `color`: `theme-on-primary`. */
46
+ on: string;
47
+ }
48
+ export declare const THEME_COLORS: readonly string[];
49
+ /** A color as written, with a `theme-` word replaced by the theme's value. */
50
+ export declare function themeColor(value: string, theme: Theme): string;
51
+ /**
52
+ * The text a box gets when its fill is a theme color and its text says
53
+ * nothing: the accent's own `on` color for a solid accent, and otherwise
54
+ * whichever of the theme's text and page colors stands further from the fill.
55
+ * A hand-picked fill is left alone — the theme has no say over it.
56
+ */
57
+ export declare function textOnFill(fill: string, theme: Theme): string | undefined;
32
58
  /**
33
59
  * Sampled out of `examples/reference/arch.png` rather than invented,
34
60
  * so the benchmark render and the drawing it is measured against differ by
package/dist/themes.js CHANGED
@@ -1,3 +1,74 @@
1
+ /** How much of an accent survives in its subtle form; the rest is the page. */
2
+ const SUBTLE = 0.25;
3
+ function accent(color, page, on, subtle = mix(color, page, SUBTLE)) {
4
+ return { color, subtle, on };
5
+ }
6
+ /** `amount` of `color` over `page`, both `#rrggbb`. */
7
+ function mix(color, page, amount) {
8
+ const channel = (hex, at) => parseInt(hex.slice(at, at + 2), 16);
9
+ let out = '#';
10
+ for (const at of [1, 3, 5]) {
11
+ const value = Math.round(channel(page, at) + (channel(color, at) - channel(page, at)) * amount);
12
+ out += value.toString(16).padStart(2, '0');
13
+ }
14
+ return out;
15
+ }
16
+ /**
17
+ * Every color a file may name from the theme, and what it is in a given one.
18
+ * The first six are the theme's own defaults for each part — what a line or a
19
+ * box gets when the file says nothing — and the rest are its two accents. All
20
+ * begin `theme-`, so a word that follows the theme says so, and no CSS color
21
+ * name is taken.
22
+ */
23
+ const THEME_COLOR_OF = {
24
+ 'theme-page': (t) => t.background,
25
+ 'theme-text': (t) => t.text,
26
+ 'theme-muted': (t) => t.mutedText,
27
+ 'theme-fill': (t) => t.boxFill,
28
+ 'theme-border': (t) => t.boxStroke,
29
+ 'theme-line': (t) => t.edge,
30
+ 'theme-primary': (t) => t.primary.color,
31
+ 'theme-primary-subtle': (t) => t.primary.subtle,
32
+ 'theme-on-primary': (t) => t.primary.on,
33
+ 'theme-secondary': (t) => t.secondary.color,
34
+ 'theme-secondary-subtle': (t) => t.secondary.subtle,
35
+ 'theme-on-secondary': (t) => t.secondary.on,
36
+ };
37
+ export const THEME_COLORS = Object.keys(THEME_COLOR_OF);
38
+ /** A color as written, with a `theme-` word replaced by the theme's value. */
39
+ export function themeColor(value, theme) {
40
+ const of = THEME_COLOR_OF[value];
41
+ return of === undefined ? value : of(theme);
42
+ }
43
+ /**
44
+ * The text a box gets when its fill is a theme color and its text says
45
+ * nothing: the accent's own `on` color for a solid accent, and otherwise
46
+ * whichever of the theme's text and page colors stands further from the fill.
47
+ * A hand-picked fill is left alone — the theme has no say over it.
48
+ */
49
+ export function textOnFill(fill, theme) {
50
+ if (fill === 'theme-primary')
51
+ return theme.primary.on;
52
+ if (fill === 'theme-secondary')
53
+ return theme.secondary.on;
54
+ if (THEME_COLOR_OF[fill] === undefined)
55
+ return undefined;
56
+ // `diagram background:` and `text:` may be any CSS color, which cannot be
57
+ // measured here, so an overruled theme keeps its ordinary text.
58
+ const measured = [themeColor(fill, theme), theme.text, theme.background];
59
+ if (!measured.every((color) => /^#[0-9a-f]{6}$/i.test(color)))
60
+ return theme.text;
61
+ const under = luminance(themeColor(fill, theme));
62
+ const apart = (color) => Math.abs(luminance(color) - under);
63
+ return apart(theme.text) >= apart(theme.background) ? theme.text : theme.background;
64
+ }
65
+ function luminance(hex) {
66
+ const [r, g, b] = [1, 3, 5].map((at) => {
67
+ const c = parseInt(hex.slice(at, at + 2), 16) / 255;
68
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
69
+ });
70
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
71
+ }
1
72
  /**
2
73
  * Sampled out of `examples/reference/arch.png` rather than invented,
3
74
  * so the benchmark render and the drawing it is measured against differ by
@@ -20,6 +91,11 @@ export const DARK_THEME = {
20
91
  // should read as part of the diagram's palette, not as clip art dropped in.
21
92
  iconInk: '#8d8d8e',
22
93
  iconShade: '#3e3d58',
94
+ // The reference's two colored boxes: `synced` is the green, `dump` the red.
95
+ // Their fills are sampled rather than mixed, so the benchmark written in
96
+ // theme colors renders exactly as it did in hex.
97
+ primary: accent('#486544', '#111111', '#d9d9d9', '#142814'),
98
+ secondary: accent('#8f3a3a', '#111111', '#d9d9d9', '#460000'),
23
99
  };
24
100
  /** The dark theme's counterpart: the same roles, on a white page. */
25
101
  const LIGHT_THEME = {
@@ -33,6 +109,8 @@ const LIGHT_THEME = {
33
109
  edge: '#7c83a0',
34
110
  iconInk: '#57606a',
35
111
  iconShade: '#d6d9e6',
112
+ primary: accent('#3a7d44', '#ffffff', '#ffffff'),
113
+ secondary: accent('#b3423a', '#ffffff', '#ffffff'),
36
114
  };
37
115
  /**
38
116
  * Every theme a file may name, in the order they are offered. Pairs sit
@@ -53,6 +131,8 @@ export const THEMES = {
53
131
  edge: '#268bd2',
54
132
  iconInk: '#839496',
55
133
  iconShade: '#0f4a58',
134
+ primary: accent('#268bd2', '#002b36', '#fdf6e3'),
135
+ secondary: accent('#cb4b16', '#002b36', '#fdf6e3'),
56
136
  },
57
137
  // base3 page, base2 leaves, the same blue.
58
138
  'solarized-light': {
@@ -66,6 +146,8 @@ export const THEMES = {
66
146
  edge: '#268bd2',
67
147
  iconInk: '#657b83',
68
148
  iconShade: '#e0d9c3',
149
+ primary: accent('#268bd2', '#fdf6e3', '#fdf6e3'),
150
+ secondary: accent('#cb4b16', '#fdf6e3', '#fdf6e3'),
69
151
  },
70
152
  // Gruvbox, Pavel Pertsev. bg0 page, bg1 leaves, the warm yellow for lines.
71
153
  'gruvbox-dark': {
@@ -79,6 +161,8 @@ export const THEMES = {
79
161
  edge: '#d79921',
80
162
  iconInk: '#a89984',
81
163
  iconShade: '#504945',
164
+ primary: accent('#fe8019', '#282828', '#282828'),
165
+ secondary: accent('#8ec07c', '#282828', '#282828'),
82
166
  },
83
167
  'gruvbox-light': {
84
168
  background: '#fbf1c7',
@@ -91,6 +175,8 @@ export const THEMES = {
91
175
  edge: '#b57614',
92
176
  iconInk: '#7c6f64',
93
177
  iconShade: '#d5c4a1',
178
+ primary: accent('#af3a03', '#fbf1c7', '#fbf1c7'),
179
+ secondary: accent('#427b58', '#fbf1c7', '#fbf1c7'),
94
180
  },
95
181
  // Catppuccin. Mocha's base page and surface leaves, blue lines.
96
182
  'catppuccin-mocha': {
@@ -104,6 +190,8 @@ export const THEMES = {
104
190
  edge: '#89b4fa',
105
191
  iconInk: '#a6adc8',
106
192
  iconShade: '#45475a',
193
+ primary: accent('#cba6f7', '#1e1e2e', '#1e1e2e'),
194
+ secondary: accent('#fab387', '#1e1e2e', '#1e1e2e'),
107
195
  },
108
196
  // Latte's base page and crust leaves, lavender lines.
109
197
  'catppuccin-latte': {
@@ -117,6 +205,8 @@ export const THEMES = {
117
205
  edge: '#7287fd',
118
206
  iconInk: '#6c6f85',
119
207
  iconShade: '#ccd0da',
208
+ primary: accent('#8839ef', '#eff1f5', '#eff1f5'),
209
+ secondary: accent('#fe640b', '#eff1f5', '#eff1f5'),
120
210
  },
121
211
  // Nord, Sven Greb. Polar Night page and leaves, Frost lines.
122
212
  nord: {
@@ -130,6 +220,8 @@ export const THEMES = {
130
220
  edge: '#81a1c1',
131
221
  iconInk: '#aeb7c6',
132
222
  iconShade: '#434c5e',
223
+ primary: accent('#88c0d0', '#2e3440', '#2e3440'),
224
+ secondary: accent('#d08770', '#2e3440', '#2e3440'),
133
225
  },
134
226
  // Dracula, the free palette. Current-line leaves, comment borders, purple lines.
135
227
  dracula: {
@@ -143,6 +235,24 @@ export const THEMES = {
143
235
  edge: '#bd93f9',
144
236
  iconInk: '#b6b9cc',
145
237
  iconShade: '#565a70',
238
+ primary: accent('#bd93f9', '#282a36', '#282a36'),
239
+ secondary: accent('#ff79c6', '#282a36', '#282a36'),
240
+ },
241
+ // Vesper, Rauno Freiberg. Near-black page, its input surface for leaves, and
242
+ // the peach it spends on everything that matters for lines; mint second.
243
+ vesper: {
244
+ background: '#101010',
245
+ boxFill: '#1c1c1c',
246
+ boxStroke: '#505050',
247
+ containerFill: '#161616',
248
+ containerStroke: '#232323',
249
+ text: '#ffffff',
250
+ mutedText: '#a0a0a0',
251
+ edge: '#ffc799',
252
+ iconInk: '#a0a0a0',
253
+ iconShade: '#282828',
254
+ primary: accent('#ffc799', '#101010', '#101010'),
255
+ secondary: accent('#99ffe4', '#101010', '#101010'),
146
256
  },
147
257
  // For low vision and projectors: no fills to lean on, every line at full
148
258
  // strength, and a container told apart by a gray outline alone.
@@ -157,6 +267,8 @@ export const THEMES = {
157
267
  edge: '#ffffff',
158
268
  iconInk: '#ffffff',
159
269
  iconShade: '#3a3a3a',
270
+ primary: accent('#ffd400', '#000000', '#000000'),
271
+ secondary: accent('#00e5ff', '#000000', '#000000'),
160
272
  },
161
273
  'high-contrast-light': {
162
274
  background: '#ffffff',
@@ -169,6 +281,8 @@ export const THEMES = {
169
281
  edge: '#000000',
170
282
  iconInk: '#000000',
171
283
  iconShade: '#d0d0d0',
284
+ primary: accent('#0033cc', '#ffffff', '#ffffff'),
285
+ secondary: accent('#b00000', '#ffffff', '#ffffff'),
172
286
  },
173
287
  // For paper: no fill anywhere an ink cartridge would notice, black lines,
174
288
  // gray only where the dark theme is quiet.
@@ -183,6 +297,9 @@ export const THEMES = {
183
297
  edge: '#333333',
184
298
  iconInk: '#000000',
185
299
  iconShade: '#ffffff',
300
+ // Grays, and pale ones for fills: a box set apart without spending color.
301
+ primary: accent('#333333', '#ffffff', '#ffffff', '#e6e6e6'),
302
+ secondary: accent('#777777', '#ffffff', '#ffffff', '#f2f2f2'),
186
303
  },
187
304
  };
188
305
  /** The theme a file gets when it names none. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {