reladraw 0.8.0 → 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 +2 -0
- package/README.md +27 -94
- package/SYNTAX.md +65 -12
- package/dist/ast.d.ts +2 -2
- package/dist/ast.js +2 -2
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +8 -0
- package/dist/parser.js +23 -5
- package/dist/render.js +138 -27
- package/dist/resolve.js +21 -6
- package/dist/themes.d.ts +26 -0
- package/dist/themes.js +117 -0
- package/package.json +2 -1
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
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
# reladraw
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
reladraw is a text language for diagrams where you say where things go.
|
|
4
4
|
|
|
5
|
-
**[Try it in your browser →](https://reladraw.github.io/reladraw/)**
|
|
5
|
+
**[Try it in your browser →](https://reladraw.github.io/reladraw/)**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
I wanted to be able to create custom, expressive diagrams where I decided how to arrange the diagram, but without the inefficiency of manually drawing in draw.io. For example, this is a diagram drawn in draw.io:
|
|
8
8
|
|
|
9
|
-

|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
This is the same diagram, but written in reladraw ([`examples/arch.reladraw`](examples/arch.reladraw)).
|
|
12
12
|
|
|
13
|
-

|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## Why not Mermaid or draw.io?
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Mermaid, Graphviz and D2 let you declare boxes and connections, then determine positions for you. If you have a particular picture in mind, these aren't the right tool.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
On the other end of the spectrum are the absolute-positioning tools — draw.io, Excalidraw, Figma. They give you total control of placement, at the cost of making every edit to a complex diagram slow hand-work. And it is slow for a human but expensive for an agent, which has to work the picture out from the coordinates before it can decide which ones to change.
|
|
19
|
+
On the other hand, tools like draw.io or Excalidraw allow absolute placement, but that means much more effort, whether for humans clicking and dragging nodes around or agents recalculating coordinates and editing verbose XML source code files.
|
|
22
20
|
|
|
23
21
|

|
|
24
22
|
|
|
25
|
-
reladraw
|
|
23
|
+
reladraw sits between those two extremes, aiming to have the benefits of a diagram language, like Mermaid, but also having the expressiveness and custom placement that you can get with draw.io. Positions are relative, so you don't have to manually pick coordinates. For example:
|
|
26
24
|
|
|
27
25
|
```
|
|
28
26
|
node app "Web app"
|
|
@@ -34,41 +32,9 @@ node store "Database" right of app level with app
|
|
|
34
32
|
edge app.api -> store "queries" from: right to: left
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
The draft is in [SYNTAX.md](SYNTAX.md), with a worked example in [examples/](examples/).
|
|
40
|
-
|
|
41
|
-
## Why an agent needs this
|
|
42
|
-
|
|
43
|
-
The common case is not drawing a diagram, it is changing one. Ask for the auth service to move left and a queue to go behind it. With pixel coordinates, an agent has to rebuild the picture from the numbers before it can work out which numbers to change. With auto-layout there is nothing to read at all, because the arrangement was never written down — it can only reword the source and re-render. With stated placement the arrangement is in the file as sentences, and changing the picture is changing the sentence that says where the thing goes.
|
|
44
|
-
|
|
45
|
-
Writing has the same shape. An agent emitting Mermaid is guessing at a layout that an algorithm settles later, and its only way to find out is to render and look — a round trip that comes back as a picture rather than as a list of what is wrong.
|
|
46
|
-
|
|
47
|
-
Intent is confirmable, outcomes are not, and the difference is worth being precise about. An agent can re-read its own file and see that the database is under the API and all four machines hang off the sync hub. It cannot see that two clusters anchored to different things now overlap, that a text overflowed its node, or that an edge crosses four others — those are resolved from the statements rather than stated, so they need the diagnostics in the scope section below.
|
|
48
|
-
|
|
49
|
-
### Using it with an agent
|
|
50
|
-
|
|
51
|
-
reladraw is too new to be in any model's training data, so an agent has to be told the language before it can write it. This repository ships an [agent skill](https://agentskills.io) that does exactly that — the syntax, when to reach for the language, and what re-reading its own source can and cannot confirm.
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
npx skills add reladraw/reladraw -g
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
That installs it for whichever agent you use — Claude Code, Codex, Cursor, Copilot and others — each into its own skills directory. Drop the `-g` to install it into the current project instead.
|
|
58
|
-
|
|
59
|
-
To install it for one agent rather than all of them, name it with `-a`:
|
|
60
|
-
|
|
61
|
-
```
|
|
62
|
-
npx skills add reladraw/reladraw -g -a claude-code
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Re-run whichever command you used after a release that changes the syntax. The skill is a copy taken at install time, not a live link, so nothing refreshes it on its own.
|
|
66
|
-
|
|
67
|
-
It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax reference beside it, so it is worth reading whatever you use, and copying the directory by hand works just as well.
|
|
68
|
-
|
|
69
|
-
## Status
|
|
35
|
+
See also [SYNTAX.md](SYNTAX.md) and [examples/](examples/).
|
|
70
36
|
|
|
71
|
-
|
|
37
|
+
## Install
|
|
72
38
|
|
|
73
39
|
```
|
|
74
40
|
npm install -g reladraw
|
|
@@ -82,65 +48,32 @@ npm install && npm run build
|
|
|
82
48
|
node dist/cli.js examples/arch.reladraw -o out.svg
|
|
83
49
|
```
|
|
84
50
|
|
|
85
|
-
|
|
51
|
+
## Using it with an agent
|
|
86
52
|
|
|
87
|
-
|
|
88
|
-
- **Edge routing around nodes.** An edge can be told which side of a node to leave and arrive on, and which gap to run down on the way. An edge that says none of that is a straight line between two centers, and it will cut through whatever stands in the way.
|
|
89
|
-
- **More pictures.** Icons and shapes are closed sets drawn from path data inside the tool, so a diagram wanting one that is not there has nowhere to go.
|
|
53
|
+
To install a skill to let your agent know how to use reladraw:
|
|
90
54
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
A gap is a *minimum* distance, never an exact one. Say two things sit side by side, then say a third goes between them, and the first two are pushed apart by exactly what the third needs; delete the third and they close back up. That is the step an author otherwise does by hand — shove things apart to make room, then drag everything back so the diagram is not full of holes — and no number goes stale when a text grows.
|
|
96
|
-
|
|
97
|
-
So the resolver solves a system rather than walking a chain. Each axis is a set of minimum distances, and the tightest arrangement satisfying all of them is found by longest paths: one answer, no search, no arrangement ever tried and rejected. The engine works out distances; which side of what a thing sits on came from the file.
|
|
98
|
-
|
|
99
|
-
That is also what makes non-overlap affordable, so it holds for every pair of nodes without anyone writing it down. On its own "these two must not overlap" is a choice among four directions, which is the search this design refuses — but the file has usually settled it already: if your arrangement lets one node travel away from another and offers no way back, that is the only separation it permits. Where the file orders a pair on neither axis, the tool names them rather than guessing; where it orders them on both, the tie breaks toward the axis of least overlap, which is the smallest movement and the one place the tool decides something nobody wrote.
|
|
100
|
-
|
|
101
|
-
Nothing is nudged. Each round derives the separations the file already implied, adds them as ordinary minimum distances, and solves the whole thing again from scratch — repairing a solved layout in place is the thing being avoided.
|
|
102
|
-
|
|
103
|
-
## Scope for a first version
|
|
104
|
-
|
|
105
|
-
- Parser *(done)*
|
|
106
|
-
- Deterministic resolver: minimum distances in, tightest arrangement out *(done)*
|
|
107
|
-
- Static SVG renderer *(done)*
|
|
108
|
-
- A command-line tool: text file in, SVG out *(done)*
|
|
109
|
-
- A placement grammar that can say what a real diagram needs: several placements on one node, one thing between two others, exact side-to-side alignment *(done)*
|
|
110
|
-
- Nodes that do not overlap by default, with the separation direction derived from the stated arrangement *(done)*
|
|
111
|
-
- Minimal node-avoiding edge routing
|
|
112
|
-
- Machine-readable diagnostics from the solved geometry
|
|
113
|
-
|
|
114
|
-
Diagnostics are a real output rather than a debugging aid. What they cannot do is stand in for the grammar: a check catches only what the language genuinely leaves open, and "these must not overlap" rules arrangements out without naming one, so it can never place anything. Everything the source cannot tell you is computable once the geometry is solved, with no image involved: overlapping nodes, crossed edges, text exceeding its container, anything off-canvas, large dead regions. So the tool reports `hub overlaps laptop1` and `edge auth->db crosses 4 edges`, and the fix is written in the same vocabulary as the source. An agent working this way reads a report about a text file it wrote and edits that text file — no rendering, no vision model, no pixel arithmetic.
|
|
115
|
-
|
|
116
|
-
A diagnostic never repairs a solved layout in place. That is the line the design holds: a checker allowed to nudge nodes is a layout algorithm with a bad search strategy, fixing one overlap into the next with no view of the whole. Deriving a constraint the file already implied and solving the whole system again is a different thing, and is how non-overlap works. What is left over — anything the source genuinely does not settle — is reported, naming the statement that was broken, and the author edits the source. Open, and it decides how far this goes: may a diagnostic describe a fix in words, or only name the symptom? Describing one means the tool has an opinion about layout, which is the auto-layout instinct coming back in through the side door.
|
|
117
|
-
|
|
118
|
-
Three design problems decide how much machinery this needs, and the first outranks the other two:
|
|
119
|
-
|
|
120
|
-
**Saying enough.** The benchmark contains arrangements the grammar cannot express at all, which is why some nodes land in the wrong place no matter how the file is written. So the work is adding statements, not restricting them. Expressiveness is not the danger; the engine *choosing* an arrangement is.
|
|
121
|
-
|
|
122
|
-
**What the engine is allowed to decide.** Auto-layout is refused, because a picture chosen by an algorithm is not predictable from its source, and that predictability is the entire point. Working out coordinates from an arrangement the author stated is a different thing and is simply the job. The test between them: the engine's freedom may affect distances and never relationships. If a default can change which side of something a node sits on, the language was short a statement and the tool should say so rather than guess.
|
|
123
|
-
|
|
124
|
-
**Overlap and edge routing.** Relative placement with default spacing collides as soon as two clusters grow toward each other, and nodes are kept apart for exactly that reason. Edges are different: which side of a node a line passes is a relationship, so the tool does not pick a route around an obstacle by itself. You say where a line goes instead — the side it leaves and arrives on, the gap it runs through, the nodes it passes above or below — and the line is drawn curved, square or straight through those points. A line that crosses something it should not is one you have not said enough about yet. Whether the tool should at least point that out, naming the clause that would fix it, is an open question.
|
|
55
|
+
```
|
|
56
|
+
npx skills add reladraw/reladraw -g
|
|
57
|
+
```
|
|
125
58
|
|
|
126
|
-
|
|
59
|
+
That installs it for every agent you use (Claude Code, Codex, Cursor, Copilot and others), each in its own skills directory. Leave off `-g` to install it into the current project only. To install it for just one agent, name it with `-a`:
|
|
127
60
|
|
|
128
|
-
|
|
61
|
+
```
|
|
62
|
+
npx skills add reladraw/reladraw -g -a claude-code
|
|
63
|
+
```
|
|
129
64
|
|
|
130
|
-
|
|
65
|
+
That gets you a copy of the skill at the time you run it, so you'll need to re-run that command to get the latest skill when there is a new release.
|
|
131
66
|
|
|
132
|
-
|
|
67
|
+
## Status
|
|
133
68
|
|
|
134
|
-
|
|
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.
|
|
135
70
|
|
|
136
|
-
|
|
71
|
+
The language isn't stable yet, so expect the syntax to change.
|
|
137
72
|
|
|
138
73
|
## License
|
|
139
74
|
|
|
140
|
-
Apache-2.0.
|
|
141
|
-
|
|
142
|
-
The license covers the code, not the name: it grants no rights to use "reladraw", the project logo, or the project's other marks. Forks are welcome and should carry a different name. See [NOTICE](NOTICE).
|
|
75
|
+
Apache-2.0. See [LICENSE](LICENSE). The license covers the code, not the name. It grants no rights to "reladraw", the project logo or the project's other marks. See [NOTICE](NOTICE).
|
|
143
76
|
|
|
144
77
|
## Contributing
|
|
145
78
|
|
|
146
|
-
Issues are
|
|
79
|
+
Issues are welcome. Particularly helpful is a diagram you could not represent in reladraw. While the language is still changing quickly, an issue helps more than a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
package/SYNTAX.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Syntax reference — 0.
|
|
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
|
|
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
|
|
|
@@ -442,6 +442,10 @@ Several such lines over the same row take a lane each, the shorter inside the lo
|
|
|
442
442
|
|
|
443
443
|
Sides at right angles can face away too. `from: left to: top` with the far node to the right leaves heading away from it, so the line steps out of the left, goes up past both nodes, runs along above them and comes straight down into the top. The side named at the far end says which way round: `to: bottom` takes it underneath instead.
|
|
444
444
|
|
|
445
|
+
When the two nodes are stacked instead — `from: bottom to: left` into a node directly below — going out past both and coming straight in would pass through the lower node. There the line is one curve, bending round the corner into the side named. If even the curve would cross a node, the line goes round the node on the side it names, as `left of` that node would send it.
|
|
446
|
+
|
|
447
|
+
Both ends can name the same side. `from: left to: left` with the far node to the right leaves heading away from it, and has to get round the node behind to reach the far side. It goes over the top, as the lines above do, and down into the gap; in a column, `from: bottom to: bottom` goes round the right.
|
|
448
|
+
|
|
445
449
|
Over the top is the rule, not a guess the tool makes each time — it does not look for the shorter way round. To send the line underneath or round the left, say which side of a node it passes: `below resolver`. See "Passing on one side of a node".
|
|
446
450
|
|
|
447
451
|
#### Several edges between the same two sides
|
|
@@ -868,19 +872,19 @@ A color is never written in quotes, and a quoted one is refused. There is nothin
|
|
|
868
872
|
|
|
869
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.
|
|
870
874
|
|
|
871
|
-
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:
|
|
872
876
|
|
|
873
877
|
```
|
|
874
878
|
style synced fill: #142814 border: #486544
|
|
875
|
-
style dim text: (color: muted)
|
|
879
|
+
style dim text: (color: theme-muted)
|
|
876
880
|
node pc.files "shared folder / [dim]synced[/dim]" style: synced
|
|
877
881
|
```
|
|
878
882
|
|
|
879
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.
|
|
880
884
|
|
|
881
|
-
`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:
|
|
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.
|
|
882
886
|
|
|
883
|
-
`muted`
|
|
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.
|
|
884
888
|
|
|
885
889
|
## The diagram itself
|
|
886
890
|
|
|
@@ -917,15 +921,50 @@ diagram theme: solarized-dark
|
|
|
917
921
|
| `catppuccin-mocha`, `catppuccin-latte` | [Catppuccin](https://catppuccin.com) |
|
|
918
922
|
| `nord` | [Nord](https://www.nordtheme.com) |
|
|
919
923
|
| `dracula` | [Dracula](https://draculatheme.com) |
|
|
924
|
+
| `vesper` | [Vesper](https://github.com/raunofreiberg/vesper) |
|
|
920
925
|
| `high-contrast-dark`, `high-contrast-light` | no fills to lean on, every line at full strength |
|
|
921
926
|
| `print` | a white page, black lines and no fills, for paper |
|
|
922
927
|
|
|
923
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`.
|
|
924
929
|
|
|
925
|
-
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.
|
|
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.
|
|
926
931
|
|
|
927
932
|
An unknown name is an error that lists the themes.
|
|
928
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
|
+
|
|
929
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.
|
|
930
969
|
|
|
931
970
|
### Defaults
|
|
@@ -1017,7 +1056,21 @@ That one was found by testing the lexer, not by rendering — and it could not h
|
|
|
1017
1056
|
|
|
1018
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.
|
|
1019
1058
|
|
|
1020
|
-
**
|
|
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
|
+
|
|
1067
|
+
**0.8.1**
|
|
1068
|
+
|
|
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.
|
|
1070
|
+
|
|
1071
|
+
- A fix, no syntax change. Children of a container stack close together, and an edge with no text between two of them used to get only that step, so it drew as an arrowhead with no line behind it. The gap now widens to show a short run of line behind each head, as an edge with text already did for its text. Existing diagrams with such an edge will look different, and correct.
|
|
1072
|
+
|
|
1073
|
+
**0.8.0**
|
|
1021
1074
|
|
|
1022
1075
|
- `shape: circle`. A circle is sized to hold its text and is always round. Edges on one of its sides spread round that quarter of the circle, and a corner of it is the point on the circle halfway round. It does not yet take a badge, a deck or a node placed against its own edge. Nothing that already renders changes.
|
|
1023
1076
|
|
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/constants.d.ts
CHANGED
|
@@ -59,6 +59,14 @@ export declare const THICKNESS: Record<string, number>;
|
|
|
59
59
|
* thickness is a size, like a text's, and a size is allowed to take room.
|
|
60
60
|
*/
|
|
61
61
|
export declare function arrowLength(thickness: number): number;
|
|
62
|
+
/**
|
|
63
|
+
* Line left showing behind the head of an edge with no text, so a gap an arrow
|
|
64
|
+
* crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
|
|
65
|
+
* apart, which is less than one head, and an arrow between them drew as a head
|
|
66
|
+
* with no line. This is what the tightest named gap already leaves behind a
|
|
67
|
+
* normal head, so any gap an author could have asked for holds it unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export declare const BARE_EDGE_RUN: number;
|
|
62
70
|
/**
|
|
63
71
|
* Line left showing between an edge's text and the box at that end of the
|
|
64
72
|
* corridor it crosses.
|
package/dist/constants.js
CHANGED
|
@@ -71,6 +71,14 @@ export const THICKNESS = {
|
|
|
71
71
|
export function arrowLength(thickness) {
|
|
72
72
|
return ARROW_MARKER_WIDTH * thickness;
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Line left showing behind the head of an edge with no text, so a gap an arrow
|
|
76
|
+
* crosses is at least this plus its heads. Stacked children sit `CHILD_GAP`
|
|
77
|
+
* apart, which is less than one head, and an arrow between them drew as a head
|
|
78
|
+
* with no line. This is what the tightest named gap already leaves behind a
|
|
79
|
+
* normal head, so any gap an author could have asked for holds it unchanged.
|
|
80
|
+
*/
|
|
81
|
+
export const BARE_EDGE_RUN = GAPS['tight'] - ARROW_LENGTH;
|
|
74
82
|
/**
|
|
75
83
|
* Line left showing between an edge's text and the box at that end of the
|
|
76
84
|
* corridor it crosses.
|
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
|
-
...(
|
|
23
|
-
...(
|
|
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
|
-
|
|
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));
|
|
@@ -1560,14 +1564,37 @@ function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
|
|
|
1560
1564
|
const { start, end } = ends.get(edge);
|
|
1561
1565
|
if (start.side === undefined || end.side === undefined)
|
|
1562
1566
|
continue;
|
|
1563
|
-
// Sides at right angles, one facing away from the other end.
|
|
1567
|
+
// Sides at right angles, one facing away from the other end. Going out
|
|
1568
|
+
// past both boxes and coming straight in is right when the boxes are side
|
|
1569
|
+
// by side. When the other end lies within the span of the box facing away
|
|
1570
|
+
// — stacked boxes — coming straight in would pass through that box, so
|
|
1571
|
+
// the curve is kept, which has room to bend round the corner; and if even
|
|
1572
|
+
// the curve hits a box, the line goes round the facing-away box on the
|
|
1573
|
+
// side it names.
|
|
1564
1574
|
if (start.tx * end.tx + start.ty * end.ty === 0) {
|
|
1575
|
+
const away = awayEnd(edge, start, end);
|
|
1576
|
+
if (away && within(faceOf(away.node), away.anchor, away.other)) {
|
|
1577
|
+
if (curveHits(edge, ends.get(edge), nodes)) {
|
|
1578
|
+
implyRoute(edge, away.node, sidePass(away.anchor.side), nodes, ends, routes, measurer, fontSize);
|
|
1579
|
+
}
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1565
1582
|
const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
|
|
1566
1583
|
const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
|
|
1567
1584
|
if (plan)
|
|
1568
1585
|
tops.push(plan);
|
|
1569
1586
|
continue;
|
|
1570
1587
|
}
|
|
1588
|
+
// Both sides facing the same way, one box behind the other: the line has
|
|
1589
|
+
// to get round that box to reach the far side, which a curve cannot. It
|
|
1590
|
+
// goes over the top, or round the right for a column, as a loop does.
|
|
1591
|
+
if (start.tx === end.tx && start.ty === end.ty) {
|
|
1592
|
+
const away = awayEnd(edge, start, end);
|
|
1593
|
+
if (away && curveHits(edge, ends.get(edge), nodes)) {
|
|
1594
|
+
implyRoute(edge, away.node, start.tx !== 0 ? 'above' : 'right', nodes, ends, routes, measurer, fontSize);
|
|
1595
|
+
}
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1571
1598
|
// The two ends point opposite ways along one axis, each away from the other.
|
|
1572
1599
|
if (start.tx !== -end.tx || start.ty !== -end.ty)
|
|
1573
1600
|
continue;
|
|
@@ -1755,6 +1782,90 @@ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
|
|
|
1755
1782
|
},
|
|
1756
1783
|
};
|
|
1757
1784
|
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Whether the single curve an edge would otherwise be drawn as passes through
|
|
1787
|
+
* a box: either of its own, or any other that holds neither end. Measured on
|
|
1788
|
+
* the curve itself, so the answer is the picture's and not a rule about which
|
|
1789
|
+
* sides were named.
|
|
1790
|
+
*
|
|
1791
|
+
* Only passing through counts. Also counting a curve that merely comes close,
|
|
1792
|
+
* or whose text touches a node, was tried (2026-09-27) and sent nearly every
|
|
1793
|
+
* such edge the long way round with square corners, which the user rejected
|
|
1794
|
+
* outright.
|
|
1795
|
+
*/
|
|
1796
|
+
function curveHits(edge, { start, end, bow }, nodes) {
|
|
1797
|
+
const reach = controlReach(start, end);
|
|
1798
|
+
const lift = 4 / 3;
|
|
1799
|
+
const bx = (bow?.x ?? 0) * lift;
|
|
1800
|
+
const by = (bow?.y ?? 0) * lift;
|
|
1801
|
+
const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
|
|
1802
|
+
const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
|
|
1803
|
+
const at = (t) => {
|
|
1804
|
+
const u = 1 - t;
|
|
1805
|
+
return {
|
|
1806
|
+
x: u * u * u * start.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * end.x,
|
|
1807
|
+
y: u * u * u * start.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * end.y,
|
|
1808
|
+
};
|
|
1809
|
+
};
|
|
1810
|
+
// The line starts and ends on its own boxes' sides, so those are shrunk a
|
|
1811
|
+
// pixel to let it touch them there.
|
|
1812
|
+
const boxes = [
|
|
1813
|
+
...[edge.from, edge.to].map((node) => grow(extentOfBox(faceOf(node)), -1)),
|
|
1814
|
+
...nodes
|
|
1815
|
+
.filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
|
|
1816
|
+
.map((node) => extentOfBox(faceOf(node))),
|
|
1817
|
+
];
|
|
1818
|
+
const steps = 32;
|
|
1819
|
+
let previous = start;
|
|
1820
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
1821
|
+
const point = at(step / steps);
|
|
1822
|
+
if (boxes.some((box) => segmentHits(previous, point, box)))
|
|
1823
|
+
return true;
|
|
1824
|
+
previous = point;
|
|
1825
|
+
}
|
|
1826
|
+
return false;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* The end of an edge whose side faces away from the other end, with its node
|
|
1830
|
+
* and the other end's anchor; the start if both do.
|
|
1831
|
+
*/
|
|
1832
|
+
function awayEnd(edge, start, end) {
|
|
1833
|
+
const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
|
|
1834
|
+
if (facesAway(start, end))
|
|
1835
|
+
return { node: edge.from, anchor: start, other: end };
|
|
1836
|
+
if (facesAway(end, start))
|
|
1837
|
+
return { node: edge.to, anchor: end, other: start };
|
|
1838
|
+
return undefined;
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Whether a line coming straight in to `other`, across the way `anchor`'s side
|
|
1842
|
+
* faces, would pass through `box` — that is, `other` lies within the box's
|
|
1843
|
+
* span along that way.
|
|
1844
|
+
*/
|
|
1845
|
+
function within(box, anchor, other) {
|
|
1846
|
+
const run = anchor.tx !== 0 ? 'x' : 'y';
|
|
1847
|
+
return lo(box, run) < other[run] && other[run] < hi(box, run);
|
|
1848
|
+
}
|
|
1849
|
+
/** The clause that passes a node on the side named. */
|
|
1850
|
+
function sidePass(side) {
|
|
1851
|
+
return side === 'top' ? 'above' : side === 'bottom' ? 'below' : side;
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Route an edge as if it carried one clause the author did not write, passing
|
|
1855
|
+
* `node` on that side. If that cannot be drawn, the edge keeps its curve: a
|
|
1856
|
+
* refusal would name a clause that is not in the file, and would refuse a file
|
|
1857
|
+
* earlier versions drew.
|
|
1858
|
+
*/
|
|
1859
|
+
function implyRoute(edge, node, direction, nodes, ends, routes, measurer, fontSize) {
|
|
1860
|
+
const written = `${direction === 'left' || direction === 'right' ? `${direction} of` : direction} ${node.name}`;
|
|
1861
|
+
try {
|
|
1862
|
+
routes.set(edge, planRoute(edge, [{ direction, nodes: [node], written }], nodes, ends.get(edge), measurer, fontSize));
|
|
1863
|
+
}
|
|
1864
|
+
catch (error) {
|
|
1865
|
+
if (!(error instanceof SourceError))
|
|
1866
|
+
throw error;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1758
1869
|
/** The most a route's corner is rounded by. */
|
|
1759
1870
|
const ROUTE_RADIUS = 20;
|
|
1760
1871
|
/**
|
|
@@ -2547,37 +2658,37 @@ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
|
|
|
2547
2658
|
function runInk(run, own, markup, theme) {
|
|
2548
2659
|
if (run.style === undefined)
|
|
2549
2660
|
return own;
|
|
2550
|
-
return
|
|
2661
|
+
return themeColor(markup[run.style], theme);
|
|
2551
2662
|
}
|
|
2552
2663
|
/**
|
|
2553
|
-
* A text's own color
|
|
2554
|
-
*
|
|
2555
|
-
* 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.
|
|
2556
2666
|
*/
|
|
2557
2667
|
function textColorOf(textAttrs, theme, fallback) {
|
|
2558
2668
|
const value = textAttrs['color'];
|
|
2559
|
-
return value === undefined ? fallback :
|
|
2560
|
-
}
|
|
2561
|
-
function namedColor(value, theme) {
|
|
2562
|
-
return value === 'muted' ? theme.mutedText : value;
|
|
2669
|
+
return value === undefined ? fallback : themeColor(value, theme);
|
|
2563
2670
|
}
|
|
2564
2671
|
/**
|
|
2565
2672
|
* A color is written as the viewer will receive it — `#14532d`, or any CSS
|
|
2566
|
-
* color
|
|
2567
|
-
*
|
|
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.
|
|
2568
2676
|
*
|
|
2569
2677
|
* Each names the part it colors, so each reads exactly one key. The word these
|
|
2570
2678
|
* replaced, `stroke:`, named no part and meant a different one on every kind,
|
|
2571
2679
|
* which is why a box's text could not be colored at all until `text:`.
|
|
2572
2680
|
*/
|
|
2573
|
-
function borderOf(appearance, fallback) {
|
|
2574
|
-
|
|
2681
|
+
function borderOf(appearance, theme, fallback) {
|
|
2682
|
+
const value = appearance['border'];
|
|
2683
|
+
return value === undefined ? fallback : themeColor(value, theme);
|
|
2575
2684
|
}
|
|
2576
|
-
function lineOf(appearance, fallback) {
|
|
2577
|
-
|
|
2685
|
+
function lineOf(appearance, theme, fallback) {
|
|
2686
|
+
const value = appearance['line'];
|
|
2687
|
+
return value === undefined ? fallback : themeColor(value, theme);
|
|
2578
2688
|
}
|
|
2579
|
-
function fillOf(appearance, fallback) {
|
|
2580
|
-
|
|
2689
|
+
function fillOf(appearance, theme, fallback) {
|
|
2690
|
+
const value = appearance['fill'];
|
|
2691
|
+
return value === undefined ? fallback : themeColor(value, theme);
|
|
2581
2692
|
}
|
|
2582
2693
|
function round(value) {
|
|
2583
2694
|
return Math.round(value * 100) / 100;
|
package/dist/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
|
|
2
|
-
import { CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
2
|
+
import { BARE_EDGE_RUN, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
3
3
|
import { fix, reachability, tightest } from './constrain.js';
|
|
4
4
|
import { SourceError } from './errors.js';
|
|
5
5
|
import { bodyFor } from './icons.js';
|
|
@@ -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
|
*/
|
|
@@ -1404,6 +1404,9 @@ function memberOn(target, axis) {
|
|
|
1404
1404
|
* away together or not at all (`left right` with `from` first); sides at right
|
|
1405
1405
|
* angles each may, and either is enough (`left top` with `from` first, where
|
|
1406
1406
|
* the edge turns back over the top, or with `to` below, where it goes round).
|
|
1407
|
+
* The same side on both is left out on purpose: that edge goes round the box
|
|
1408
|
+
* behind, and its text rides on the stretch over that box and the gap, so the
|
|
1409
|
+
* room made in the gap is what keeps a long text clear of the line's turns.
|
|
1407
1410
|
*/
|
|
1408
1411
|
function facingAway(fromSide, toSide) {
|
|
1409
1412
|
// The axis a side is on, and whether it faces away when its own end is first.
|
|
@@ -1450,22 +1453,34 @@ function liftTo(node, indexOf, local) {
|
|
|
1450
1453
|
height: node.height,
|
|
1451
1454
|
};
|
|
1452
1455
|
}
|
|
1453
|
-
/** The edges
|
|
1456
|
+
/** The edges whose two ends are different members of this group. */
|
|
1454
1457
|
function corridorsIn(edges, locate, measurer, fontSize) {
|
|
1455
1458
|
const corridors = [];
|
|
1456
1459
|
for (const edge of edges) {
|
|
1457
|
-
// An edge
|
|
1458
|
-
// edge told to pass between two named things carries its text in *that*
|
|
1460
|
+
// An edge told to pass between two named things carries its text in *that*
|
|
1459
1461
|
// corridor rather than in the gap between its own ends, so widening this one
|
|
1460
1462
|
// would make room where the text never goes.
|
|
1461
1463
|
// An edge that says which side of something it passes is routed round it,
|
|
1462
1464
|
// and its text rides on that route for the same reason.
|
|
1463
|
-
if (edge.
|
|
1465
|
+
if (edge.between || edge.passes)
|
|
1464
1466
|
continue;
|
|
1465
1467
|
const from = locate(edge.from);
|
|
1466
1468
|
const to = locate(edge.to);
|
|
1467
1469
|
if (!from || !to || from.index === to.index)
|
|
1468
1470
|
continue;
|
|
1471
|
+
// With no text, the gap still has to show a run of line behind each head,
|
|
1472
|
+
// or two stacked children with an arrow between them draw as a head alone.
|
|
1473
|
+
if (edge.text === undefined) {
|
|
1474
|
+
const bare = BARE_EDGE_RUN + arrowLength(edge.look.thickness) * (edge.both ? 2 : 1);
|
|
1475
|
+
corridors.push({
|
|
1476
|
+
edge,
|
|
1477
|
+
from,
|
|
1478
|
+
to,
|
|
1479
|
+
need: { x: bare, y: bare },
|
|
1480
|
+
away: facingAway(edge.attrs['from'], edge.attrs['to']),
|
|
1481
|
+
});
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1469
1484
|
// The clearance is doubled because the text is drawn at the *midpoint* of
|
|
1470
1485
|
// the line, so the room it needs is symmetric about that point whatever sits
|
|
1471
1486
|
// at either end. The arrowhead is charged on both sides for the same reason:
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A diagram language where you say where things go.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc",
|
|
36
36
|
"prepublishOnly": "npm run build",
|
|
37
|
+
"preversion": "./dev.sh check-version \"$npm_new_version\"",
|
|
37
38
|
"dev": "tsc --watch",
|
|
38
39
|
"example": "npm run build && node dist/cli.js examples/arch.reladraw -o examples/out/arch.svg"
|
|
39
40
|
},
|