reladraw 0.8.1 → 0.10.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.10.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.10.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
 
@@ -6,9 +6,19 @@ The worked example is `examples/arch.reladraw`, transcribed from the reference r
6
6
 
7
7
  ## Shape of the file
8
8
 
9
- One statement per line. No multi-line statements, no line continuations, no blocks.
9
+ A statement starts at the beginning of a line. A long one may continue onto the lines after it by indenting them:
10
10
 
11
- Blank lines and `//` comments are ignored. A comment runs to the end of the line and may trail a statement, so `node a "Docker" // the one that matters` is fine. Indentation is ignored entirely — a formatter may add it for readability, and stale indentation cannot change what a file means.
11
+ ```
12
+ node desktop1 "Desktop 1 / Linux"
13
+ above-left of hub gap: tight
14
+ badge: desktop style: synced
15
+ ```
16
+
17
+ A line that starts with whitespace continues the statement above it, and a blank line or the next unindented line ends it. How much whitespace makes no difference — one space and a tab mean the same — so nothing can depend on indentation you cannot see. A bracket may stay open across lines, as in `line: (color: red,` with `pattern: dashed)` on the next. There are no blocks: indentation continues a statement, and never puts one thing inside another.
18
+
19
+ An indented line with no statement to continue — at the top of the file, or after a blank line or an unindented comment — is an error rather than a statement of its own, and so is an indented line starting with a statement keyword such as `node`. Both are almost always a stray indent, and both say to remove it.
20
+
21
+ Blank lines and `//` comments are ignored. A comment runs to the end of the line and may trail a statement, so `node a "Docker" // the one that matters` is fine. An indented comment line sits inside the statement it follows; an unindented one ends it.
12
22
 
13
23
  A lone `/` is an ordinary character rather than the start of a comment, and `#` is ordinary too — it opens a hex color. Comments were spelled `#` in an earlier version and moved to `//` so that `fill: #14532d` could be written the way every other tool writes a color.
14
24
 
@@ -116,18 +126,18 @@ They are bracketed onto the text rather than written among the node's attributes
116
126
  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
127
 
118
128
  ```
119
- edge a -> b "rclone" (color: muted, size: small)
129
+ edge a -> b "rclone" (color: theme-muted, size: small)
120
130
  ```
121
131
 
122
132
  A **style** has no text of its own for a bracket to hang off, so it hangs the bracket off a key instead:
123
133
 
124
134
  ```
125
- style aside text: (size: small, color: muted)
135
+ style aside text: (size: small, color: theme-muted)
126
136
  ```
127
137
 
128
138
  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
139
 
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.
140
+ `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
141
 
132
142
  `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
143
 
@@ -144,7 +154,7 @@ Where the node is exactly the size of what it holds — which is most leaves, si
144
154
  A word or a stretch of a text can borrow the look of a style:
145
155
 
146
156
  ```
147
- style dim text: (color: muted)
157
+ style dim text: (color: theme-muted)
148
158
 
149
159
  node pc.files "shared folder / [dim]synced[/dim]"
150
160
  ```
@@ -395,7 +405,7 @@ edge <from> <-> <to> ["<text>"] [between <a> and <b> [vertically|horizontally]]
395
405
 
396
406
  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
407
 
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.
408
+ 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
409
 
400
410
  ### A text makes room for itself
401
411
 
@@ -872,19 +882,19 @@ A color is never written in quotes, and a quoted one is refused. There is nothin
872
882
 
873
883
  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
884
 
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:
885
+ 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
886
 
877
887
  ```
878
888
  style synced fill: #142814 border: #486544
879
- style dim text: (color: muted)
889
+ style dim text: (color: theme-muted)
880
890
  node pc.files "shared folder / [dim]synced[/dim]" style: synced
881
891
  ```
882
892
 
883
893
  **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
894
 
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.
895
+ `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
896
 
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.
897
+ `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
898
 
889
899
  ## The diagram itself
890
900
 
@@ -921,15 +931,50 @@ diagram theme: solarized-dark
921
931
  | `catppuccin-mocha`, `catppuccin-latte` | [Catppuccin](https://catppuccin.com) |
922
932
  | `nord` | [Nord](https://www.nordtheme.com) |
923
933
  | `dracula` | [Dracula](https://draculatheme.com) |
934
+ | `vesper` | [Vesper](https://github.com/raunofreiberg/vesper) |
924
935
  | `high-contrast-dark`, `high-contrast-light` | no fills to lean on, every line at full strength |
925
936
  | `print` | a white page, black lines and no fills, for paper |
926
937
 
927
938
  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
939
 
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.
940
+ 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
941
 
931
942
  An unknown name is an error that lists the themes.
932
943
 
944
+ ### Theme colors
945
+
946
+ 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.
947
+
948
+ The theme's own color for each part — what a box or a line gets when the file says nothing:
949
+
950
+ | color | is |
951
+ |---|---|
952
+ | `theme-page` | the page |
953
+ | `theme-text` | text |
954
+ | `theme-muted` | quieter, secondary text |
955
+ | `theme-fill` | a box's fill |
956
+ | `theme-border` | a box's outline |
957
+ | `theme-line` | a line |
958
+
959
+ And its two accents, each at two strengths, with the text that reads on it:
960
+
961
+ | color | is |
962
+ |---|---|
963
+ | `theme-primary` | the theme's signature color — Dracula's purple, Solarized's blue |
964
+ | `theme-primary-subtle` | the same mixed toward the page: dark on a dark theme, pale on a light one |
965
+ | `theme-on-primary` | text that reads on a solid `theme-primary` |
966
+ | `theme-secondary`, `theme-secondary-subtle`, `theme-on-secondary` | the same for its second color |
967
+
968
+ ```
969
+ style synced fill: theme-primary-subtle border: theme-primary
970
+ style live fill: theme-primary
971
+ style dim text: (color: theme-muted)
972
+ ```
973
+
974
+ 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.
975
+
976
+ A `theme-` name the theme does not define is refused with the list above.
977
+
933
978
  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
979
 
935
980
  ### Defaults
@@ -1021,6 +1066,19 @@ That one was found by testing the lexer, not by rendering — and it could not h
1021
1066
 
1022
1067
  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
1068
 
1069
+ **0.10.0**
1070
+
1071
+ - A statement may continue onto the lines after it by indenting them. See "Shape of the file". An error in such a statement names the line the mistake is on.
1072
+ - **Breaking:** indentation used to be ignored, so an indented `node` line was a statement of its own. It now continues the statement above and is refused, with a message saying to remove the indentation.
1073
+
1074
+ **0.9.0**
1075
+
1076
+ - 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".
1077
+ - A box with a theme fill and no text color of its own gets text that reads on that fill.
1078
+ - **Breaking:** `muted` is now `theme-muted`. `color: muted` is refused with the new name.
1079
+ - The examples use theme colors where they used to hand-pick colors for a dark page, so they read in every theme.
1080
+ - A fourteenth theme, `vesper`, from [Vesper](https://github.com/raunofreiberg/vesper).
1081
+
1024
1082
  **0.8.1**
1025
1083
 
1026
1084
  - 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/grammar.d.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  * The scanner is the JavaScript consumer of the same spec, and the playground is
20
20
  * its only caller.
21
21
  *
22
- * Nothing in the language spans lines: `//` runs to the end of one, a string
23
- * closes on one, and there are no blocks. That is the property those editor
24
- * formats need and the reason a `.reladraw` grammar is small in all of them.
22
+ * No token spans lines: `//` runs to the end of one, a string closes on one,
23
+ * and there are no blocks. A statement may continue onto indented lines, but
24
+ * each of those is colored by the same rules, and a word that is a keyword at
25
+ * the start of one is refused by the parser anyway. That is the property those
26
+ * editor formats need and the reason a `.reladraw` grammar is small in all of
27
+ * them.
25
28
  */
26
29
  /** What a span of source is, for coloring. */
27
30
  export type TokenKind =
package/dist/grammar.js CHANGED
@@ -19,9 +19,12 @@
19
19
  * The scanner is the JavaScript consumer of the same spec, and the playground is
20
20
  * its only caller.
21
21
  *
22
- * Nothing in the language spans lines: `//` runs to the end of one, a string
23
- * closes on one, and there are no blocks. That is the property those editor
24
- * formats need and the reason a `.reladraw` grammar is small in all of them.
22
+ * No token spans lines: `//` runs to the end of one, a string closes on one,
23
+ * and there are no blocks. A statement may continue onto indented lines, but
24
+ * each of those is colored by the same rules, and a word that is a keyword at
25
+ * the start of one is refused by the parser anyway. That is the property those
26
+ * editor formats need and the reason a `.reladraw` grammar is small in all of
27
+ * them.
25
28
  */
26
29
  import { DIRECTIONS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
27
30
  /**
package/dist/index.d.ts CHANGED
@@ -10,5 +10,9 @@ export { render, type RenderOptions } from './render.js';
10
10
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES, type Theme } from './themes.js';
11
11
  import { type RenderOptions } from './render.js';
12
12
  import { type ResolveOptions } from './resolve.js';
13
- /** Source text in, SVG out. The whole pipeline in one call. */
13
+ /**
14
+ * Source text in, SVG out. The whole pipeline in one call. An error in a
15
+ * statement written over several lines names the line at fault, not the one
16
+ * the statement starts on; see `blame`.
17
+ */
14
18
  export declare function compile(source: string, options?: ResolveOptions & RenderOptions): string;
package/dist/index.js CHANGED
@@ -8,10 +8,21 @@ export { parse } from './parser.js';
8
8
  export { resolve } from './resolve.js';
9
9
  export { render } from './render.js';
10
10
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES } from './themes.js';
11
- import { parse } from './parser.js';
11
+ import { SourceError } from './errors.js';
12
+ import { blame, parse } from './parser.js';
12
13
  import { render } from './render.js';
13
14
  import { resolve } from './resolve.js';
14
- /** Source text in, SVG out. The whole pipeline in one call. */
15
+ /**
16
+ * Source text in, SVG out. The whole pipeline in one call. An error in a
17
+ * statement written over several lines names the line at fault, not the one
18
+ * the statement starts on; see `blame`.
19
+ */
15
20
  export function compile(source, options = {}) {
16
- return render(resolve(parse(source), options), options);
21
+ const run = (text) => render(resolve(parse(text), options), options);
22
+ try {
23
+ return run(source);
24
+ }
25
+ catch (error) {
26
+ throw error instanceof SourceError ? blame(source, error, run) : error;
27
+ }
17
28
  }
package/dist/lexer.d.ts CHANGED
@@ -18,8 +18,11 @@ export interface Token {
18
18
  * opening bracket counts only where a token starts, and a closing one only
19
19
  * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
20
20
  *
21
+ * `depth` is how many brackets are still open from the lines before, when a
22
+ * statement continues onto this one.
23
+ *
21
24
  * Returns an empty array for a blank or comment-only line.
22
25
  */
23
- export declare function tokenizeLine(line: string, lineNumber: number): Token[];
26
+ export declare function tokenizeLine(line: string, lineNumber: number, depth?: number): Token[];
24
27
  /** A bare token ending in `:` opens the attribute section of a statement. */
25
28
  export declare function isAttrKey(token: Token): boolean;
package/dist/lexer.js CHANGED
@@ -14,12 +14,14 @@ import { SourceError } from './errors.js';
14
14
  * opening bracket counts only where a token starts, and a closing one only
15
15
  * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
16
16
  *
17
+ * `depth` is how many brackets are still open from the lines before, when a
18
+ * statement continues onto this one.
19
+ *
17
20
  * Returns an empty array for a blank or comment-only line.
18
21
  */
19
- export function tokenizeLine(line, lineNumber) {
22
+ export function tokenizeLine(line, lineNumber, depth = 0) {
20
23
  const tokens = [];
21
24
  let i = 0;
22
- let depth = 0;
23
25
  while (i < line.length) {
24
26
  const ch = line[i];
25
27
  if (ch === ' ' || ch === '\t') {
package/dist/parser.d.ts CHANGED
@@ -1,3 +1,28 @@
1
1
  import type { Document } from './ast.js';
2
- /** Parse a whole source file. One statement per line; blanks and comments drop out. */
2
+ import { SourceError } from './errors.js';
3
+ /**
4
+ * Parse a whole source file. A statement starts on an unindented line and runs
5
+ * on through any line that starts with whitespace; a blank line or the next
6
+ * unindented one ends it. How much whitespace never matters, only whether
7
+ * there is any, so two lines that look alike cannot mean different things.
8
+ *
9
+ * An indented comment stays inside the statement and an unindented one ends
10
+ * it. An indented line with nothing open above it is refused rather than read
11
+ * as a statement of its own: it is a stray indent or a deleted first line, and
12
+ * both are worth hearing about.
13
+ */
3
14
  export declare function parse(source: string): Document;
15
+ /**
16
+ * Find the line at fault for an error reported on the first line of a
17
+ * continued statement. Everything after the parser reports a statement by the
18
+ * line it starts on, which for a continued one is often not where the mistake
19
+ * is, so this asks which line brings the error in: `run` is tried on the
20
+ * source with the statement cut after each of its lines in turn, and the first
21
+ * cut that fails with the same message names the line. A cut that fails
22
+ * differently — a bracket not yet closed, a placement not yet written — is not
23
+ * the culprit and is passed over. Lines keep their numbers, because what is cut
24
+ * is blanked rather than removed.
25
+ *
26
+ * This costs a few extra runs, and only when there is an error to report.
27
+ */
28
+ export declare function blame(source: string, error: SourceError, run: (source: string) => unknown): SourceError;
package/dist/parser.js CHANGED
@@ -1,19 +1,108 @@
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
+ import { STATEMENT_KEYWORDS } from './grammar.js';
3
4
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
- import { THEME_NAMES, THEMES } from './themes.js';
5
- /** Parse a whole source file. One statement per line; blanks and comments drop out. */
5
+ import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
6
+ /**
7
+ * Parse a whole source file. A statement starts on an unindented line and runs
8
+ * on through any line that starts with whitespace; a blank line or the next
9
+ * unindented one ends it. How much whitespace never matters, only whether
10
+ * there is any, so two lines that look alike cannot mean different things.
11
+ *
12
+ * An indented comment stays inside the statement and an unindented one ends
13
+ * it. An indented line with nothing open above it is refused rather than read
14
+ * as a statement of its own: it is a stray indent or a deleted first line, and
15
+ * both are worth hearing about.
16
+ */
6
17
  export function parse(source) {
7
18
  const statements = [];
19
+ /** The statement being gathered, and the line it starts on. */
20
+ let open = null;
21
+ // Brackets may close on a later line, and the lexer only reads `)` as a
22
+ // bracket while one is open, so the depth is carried from line to line.
23
+ let depth = 0;
24
+ const close = () => {
25
+ if (open)
26
+ statements.push(parseStatement(open.tokens, open.line));
27
+ open = null;
28
+ depth = 0;
29
+ };
8
30
  source.split(/\r?\n/).forEach((line, index) => {
9
31
  const lineNumber = index + 1;
10
- const tokens = tokenizeLine(line, lineNumber);
32
+ if (line.trim() === '') {
33
+ close();
34
+ return;
35
+ }
36
+ const indented = line[0] === ' ' || line[0] === '\t';
37
+ if (!indented)
38
+ close();
39
+ const tokens = tokenizeLine(line, lineNumber, depth);
11
40
  if (tokens.length === 0)
12
41
  return;
13
- statements.push(parseStatement(tokens, lineNumber));
42
+ if (!indented) {
43
+ open = { tokens, line: lineNumber };
44
+ }
45
+ else {
46
+ const first = tokens[0];
47
+ if (!open) {
48
+ throw new SourceError('this line is indented, which continues the statement above, but there is none to ' +
49
+ 'continue — remove the indentation to start a new statement', lineNumber);
50
+ }
51
+ if (!first.quoted && STATEMENT_KEYWORDS.includes(first.text)) {
52
+ throw new SourceError(`\`${first.text}\` cannot continue the statement above — an indented line continues ` +
53
+ `the one before it, so remove the indentation to start a new statement`, lineNumber);
54
+ }
55
+ open.tokens.push(...tokens);
56
+ }
57
+ for (const token of tokens) {
58
+ if (token.quoted)
59
+ continue;
60
+ if (token.text === '(')
61
+ depth += 1;
62
+ else if (token.text === ')')
63
+ depth -= 1;
64
+ }
14
65
  });
66
+ close();
15
67
  return { statements };
16
68
  }
69
+ /**
70
+ * Find the line at fault for an error reported on the first line of a
71
+ * continued statement. Everything after the parser reports a statement by the
72
+ * line it starts on, which for a continued one is often not where the mistake
73
+ * is, so this asks which line brings the error in: `run` is tried on the
74
+ * source with the statement cut after each of its lines in turn, and the first
75
+ * cut that fails with the same message names the line. A cut that fails
76
+ * differently — a bracket not yet closed, a placement not yet written — is not
77
+ * the culprit and is passed over. Lines keep their numbers, because what is cut
78
+ * is blanked rather than removed.
79
+ *
80
+ * This costs a few extra runs, and only when there is an error to report.
81
+ */
82
+ export function blame(source, error, run) {
83
+ const lines = source.split(/\r?\n/);
84
+ const start = error.line - 1;
85
+ const first = lines[start];
86
+ if (first === undefined || first.trim() === '' || first[0] === ' ' || first[0] === '\t')
87
+ return error;
88
+ let end = start;
89
+ while (end + 1 < lines.length && lines[end + 1].trim() !== '' && /^[ \t]/.test(lines[end + 1]))
90
+ end += 1;
91
+ if (end === start)
92
+ return error;
93
+ for (let last = start; last < end; last++) {
94
+ const cut = lines.map((line, index) => (index > last && index <= end ? '' : line)).join('\n');
95
+ try {
96
+ run(cut);
97
+ }
98
+ catch (partial) {
99
+ if (partial instanceof SourceError && partial.message === error.message) {
100
+ return last === start ? error : new SourceError(error.message, last + 1);
101
+ }
102
+ }
103
+ }
104
+ return new SourceError(error.message, end + 1);
105
+ }
17
106
  function parseStatement(tokens, line) {
18
107
  const keyword = tokens[0];
19
108
  if (!keyword || keyword.quoted) {
@@ -142,7 +231,7 @@ const BRACKET_KEYS = {
142
231
  };
143
232
  /** How each bracketed key's error quotes itself back, and what it is about. */
144
233
  const BRACKET_ABOUT = {
145
- text: { kind: 'a text', example: 'color: muted' },
234
+ text: { kind: 'a text', example: 'color: theme-muted' },
146
235
  contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
147
236
  line: { kind: 'a line', example: 'path: square' },
148
237
  };
@@ -183,7 +272,7 @@ function readAttr(tokens, at, attrs, line, subject) {
183
272
  const key = keyToken.text.slice(0, -1);
184
273
  const bracketKeys = BRACKET_KEYS[key];
185
274
  if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
186
- // `text: (color: muted)` — the whole bracket belongs to one part, and it is
275
+ // `text: (color: theme-muted)` — the whole bracket belongs to one part, and it is
187
276
  // stored under dotted keys so that a style merges into a node exactly the
188
277
  // way every other attribute does.
189
278
  const read = readBracket(tokens, at + 1, bracketKeys, {
@@ -298,7 +387,7 @@ function readAttr(tokens, at, attrs, line, subject) {
298
387
  // applied by counting, so a reader of the text could not see it. Markup
299
388
  // says what is quiet where it is quiet, and reaches a word in the middle of
300
389
  // 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)` ' +
390
+ throw new SourceError('`subtext:` has been replaced by markup in the text — write `style dim text: (color: theme-muted)` ' +
302
391
  'and mark the quiet words as `"Dropbox / [dim]synced[/dim]"`', line);
303
392
  }
304
393
  if (key === 'text') {
@@ -342,6 +431,8 @@ function readAttr(tokens, at, attrs, line, subject) {
342
431
  }
343
432
  throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
344
433
  }
434
+ if (COLOR_KEYS.includes(key))
435
+ checkColorWord(valueToken.text, subject, line);
345
436
  setOnce(attrs, key, valueToken.text, subject, line);
346
437
  return at + 2;
347
438
  }
@@ -457,7 +548,7 @@ function parseEdge(head, line) {
457
548
  subject,
458
549
  what: 'the text',
459
550
  kind: "an edge's text",
460
- example: 'color: muted',
551
+ example: 'color: theme-muted',
461
552
  line,
462
553
  });
463
554
  at = bracket.next;
@@ -839,6 +930,8 @@ function readBracket(tokens, start, keys, about) {
839
930
  // the comma are the same statement.
840
931
  const value = valueToken.text;
841
932
  const clean = value.endsWith(',') && value.length > 1 ? value.slice(0, -1) : value;
933
+ if (key === 'color')
934
+ checkColorWord(clean, about.subject, about.line);
842
935
  const had = values[key];
843
936
  if (had !== undefined) {
844
937
  throw new SourceError(`${about.subject}: "${key}" is written twice in the brackets after ${about.what} (${had}, ${clean}) — keep one`, about.line);
@@ -848,6 +941,20 @@ function readBracket(tokens, start, keys, about) {
848
941
  }
849
942
  return { values, next: i + 1 };
850
943
  }
944
+ /**
945
+ * A color is any CSS color, which cannot be checked, or a `theme-` word, which
946
+ * can. So a `theme-` word the theme does not define is refused rather than
947
+ * handed to the viewer as a color that draws nothing, and `muted`, the one
948
+ * theme word from before the prefix, is refused with its new name.
949
+ */
950
+ function checkColorWord(value, subject, line) {
951
+ if (value === 'muted') {
952
+ throw new SourceError(`${subject}: \`muted\` is now \`theme-muted\` — every color that follows the theme begins \`theme-\``, line);
953
+ }
954
+ if (value.startsWith('theme-') && !THEME_COLORS.includes(value)) {
955
+ throw new SourceError(`${subject}: there is no theme color "${value}" — the theme colors are ${THEME_COLORS.join(', ')}`, line);
956
+ }
957
+ }
851
958
  function follows(tokens, at, word) {
852
959
  const token = tokens[at];
853
960
  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.10.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {