reladraw 0.19.1 → 0.20.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/README.md CHANGED
@@ -88,7 +88,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
88
88
 
89
89
  ## Status
90
90
 
91
- Version 0.19.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
91
+ Version 0.20.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
92
92
 
93
93
  ## License
94
94
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.19.1
1
+ # Syntax reference — 0.20.0
2
2
 
3
3
  What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
4
4
 
@@ -220,7 +220,7 @@ The text keeps its own position words, so `node d1 "Desktop 1 / Linux" (inside t
220
220
 
221
221
  With an empty text, `node flag "" badge: disk`, the node is exactly the picture and its padding, since an empty text takes no room.
222
222
 
223
- **A picture that is a thing of its own is a child**, placed like any other: `node drive.mark icon: disk inside drive top-right` in a corner. Placed against the text without `join-with:`, it is not part of the label: the text stays where it would be without it, and the box grows to hold both.
223
+ **A picture that is a thing of its own is a child**, placed like any other: `node drive.mark icon: disk 1 right of drive text` beside the text. Placed against the text without `join-with:`, it is not part of the label: the text stays where it would be without it, and the box grows to hold both. In the corner of a box its other children size, `node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos` ([Against its own parent](#against-its-own-parent)). A corner of a box with nothing in it but its text is on the text.
224
224
 
225
225
  ### Padding
226
226
 
@@ -566,7 +566,20 @@ A unit that includes a box's text is placed by the text's position words: on a l
566
566
 
567
567
  ### Against its own parent
568
568
 
569
- A child placed against its parent's side or corner, `inside server right` or `inside server top-left`, is placed against the finished box and never grows it. If it covers something, that is reported. To make the box hold something, place it relative to the other children. Something the box holds may not be placed against a thing on its own side or corner, since the box would then depend on itself; that is refused.
569
+ A child placed against its parent's side or corner, `inside server right` or `inside server top-left`, is placed against the finished box and never grows it. That holds one axis at a time: `node c.j "j" right of c.k inside c top` is beside `c.k` across, so the box holds it across, and against the box's top down. A corner and a sibling saying the same axis, `inside c top-right right of c.k`, are refused: the error offers `right of c.k inside c top` and `inside c top-right stays right of c.k` (below). If it covers something, that is reported. So is a child the box has no room for in that corner, such as a two-line picture in the corner of a box one line tall, as `node-overflow`; the box doesn't grow to fit it, since a box only just tall enough would put it level with the middle, not in the corner. To make the box hold something, place it relative to the other children, or keep it in the corner and clear of them with `stays`. Something the box holds may not be placed against a thing on its own side or corner, since the box would then depend on itself; that is refused.
570
+
571
+ **`stays` keeps a child clear of the rest of its box.** A picture in the corner of a box its children size can land on one of them. `stays above`, `stays below`, `stays left of` and `stays right of` say which way to keep clear:
572
+
573
+ ```
574
+ node laptop "Laptop" (inside top, align: left)
575
+ node laptop.docs "Documents"
576
+ node laptop.photos "Photos" right of laptop.docs
577
+ node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos
578
+ ```
579
+
580
+ The laptop is still in the corner; `stays` never says where something goes. It says the laptop is at least the usual gap above Photos, and when it isn't, the box makes room: everything else it holds moves down together, so no gap a placement states changes, and the box grows by just enough. `(gap: wide)` or a number after the target asks for a different distance. Beside the box's own text, `stays right of drive text`, the box widens until the picture clears the text.
581
+
582
+ A limit relates two things in one box that nothing else ties together: a child placed against the box's side or corner and another child, or the box's text; two children in different corners; something beside the text and the children below it. Between two children placed against each other, or across boxes, every distance is already stated, so a limit there is refused. So is one the box can't meet by growing, such as `inside laptop bottom-right stays above laptop.photos`, and one a stated size leaves no room for.
570
583
 
571
584
  A title inside the border counts as content, so the box makes room for it. So does anything placed against the text. A title outside the border hangs off the box and doesn't count.
572
585
 
@@ -1016,7 +1029,7 @@ The default order: a container before its own children, boxes before lines, an e
1016
1029
  | `(color: …)` after a text | the text | any node, an edge; `text: (color: …)` in a style or default |
1017
1030
  | `line` | the line and its end marks | an edge |
1018
1031
 
1019
- A key means one thing everywhere, so it can be wrong: `border:` on a node with no outline is refused, and the error lists what that kind has. A color is written as the viewer receives it: a hex value, any CSS color, or a theme color. There is no list of color words: `green` works because it is a CSS color. A quoted color is refused. `none` means the part isn't painted: `fill: none`.
1032
+ A key means one thing everywhere, so it can be wrong: `border:` on a node with no outline is refused, and the error lists what that kind has. A color is written as the viewer receives it: a hex value, any CSS color, or a theme color. There is no list of color words: `green` works because it is a CSS color. A quoted color is refused, and so is a hex value without its `#`. `none` means the part isn't painted: `fill: none`.
1020
1033
 
1021
1034
  **Changing a color never moves anything.** Size is different: a bigger text or a thicker line takes more room.
1022
1035
 
@@ -1126,7 +1139,8 @@ An **error** means the file can't be drawn: an unknown key, two placements on on
1126
1139
  | `edge-node-overlap` | a line across a node that isn't one of its ends, or through its own ends | on |
1127
1140
  | `edge-overlap` | two lines on one run for a stretch, however short; a collapsed bundle's bus, and lines from one statement such as `edge a -> b, c`, shared on purpose, are not reported | on |
1128
1141
  | `crowded-edge-ends` | several line ends bunched on a side too short to space them: two ends' marks would touch, or, where neither has a mark, the lines come within a third of a unit | on |
1129
- | `node-overlap` | two nodes on one spot that no layout, `inside`, `on` or `overlap: allow` accounts for | on |
1142
+ | `node-overlap` | two nodes on one spot that no layout, `inside`, `on` or `overlap: allow` accounts for; `inside hub top-right` accounts for covering `hub`, not `hub`'s other children, and a box that draws nothing (no fill, border or text) can't be covered | on |
1143
+ | `node-overflow` | a node placed against its container's side or corner that the container has no room for there: less room on the far side than it keeps from the side it names | on |
1130
1144
  | `edge-no-length` | a line between two boxes that touch, drawn as its marks at the seam | on |
1131
1145
  | `edge-crossings` | two lines crossing | off |
1132
1146
 
@@ -1281,6 +1295,16 @@ Designed and absent from the code.
1281
1295
 
1282
1296
  Pre-1.0, so the minor number is where a breaking change goes. Every removal is refused by name with the replacement quoted, rather than dropped in silence: an older file stops with an error saying what to write instead.
1283
1297
 
1298
+ **0.20.0**
1299
+
1300
+ - **`stays above`, `stays below`, `stays left of` and `stays right of`** keep a child placed in its box's corner clear of the other children: `node laptop.mark icon: laptop inside laptop top-right stays above laptop.photos`. The laptop stays in the corner, and the box makes room. See [Against its own parent](#against-its-own-parent).
1301
+ - **Breaking: a corner and a sibling saying the same axis are refused.** `node c.j "j" inside c top-right right of c.k` used to be centered between the two, neither in the corner nor beside `c.k`, straddling the box's edge. The error offers `right of c.k inside c top` to sit beside `c.k`, and `stays right of c.k` to keep the corner. The same goes for `inside c top-right above c.k` up and down.
1302
+ - **A new finding, `node-overflow`**: a child placed in its box's corner or against its side that the box has no room for there, such as `node drive.mark icon: disk inside drive top-right` on a box one line tall. It used to run out of the box with nothing said.
1303
+ - **A child in its box's corner that covers one of the box's other children is reported** as `node-overlap`, with the `stays` that would keep it clear. Being placed `inside` a box used to excuse it from covering anything in that box, not only the box.
1304
+ - **A child beside a sibling and against its parent's top stays inside the parent.** `node c.j "j" right of c.k inside c top` used to be drawn wholly outside `c`, with nothing reported. Being against the parent's top only measures its height from the finished box; across, the box holds it like any other child.
1305
+ - **Breaking: a hex color without its `#` is refused.** `fill: 444444` used to be passed to the viewer as is, which drew nothing. The error says to write `#444444`.
1306
+ - **The playground saves a PNG** with Save PNG, at twice the picture's size, in the fonts the page shows it in. Download is now Save SVG beside it.
1307
+
1284
1308
  **0.19.1**
1285
1309
 
1286
1310
  - **A copy that states its own `step-out:` or `turn:` keeps it.** Lines between the same two sides of the same two boxes fan into lanes, and each used to be drawn as the first one's shape shifted over, so a `step-out:` or `turn:` written on any later line was ignored. A line naming either is now drawn as written and leaves the fan, and so does a line named in its side's `right-edge-order:` list. Where that puts two lines on one run, it is reported as `edge-overlap`. Prompted by #19.
package/dist/ast.d.ts CHANGED
@@ -234,6 +234,21 @@ export interface OnPlacement {
234
234
  * it needs; the resolver intersects them.
235
235
  */
236
236
  export type Placement = OffsetPlacement | AlignPlacement | OnPlacement;
237
+ /**
238
+ * `stays above laptop.photos (gap: wide)` — a limit, not a placement
239
+ * (design.org 5.6). It never says where the node goes, so no axis counts it;
240
+ * it rules out pictures where the node is less than a gap that way from its
241
+ * target, and the box holding both makes room by lengthening the one distance
242
+ * nobody stated.
243
+ */
244
+ export interface Limit {
245
+ way: 'above' | 'below' | 'left' | 'right';
246
+ target: PlacementTarget;
247
+ gap?: string;
248
+ line: number;
249
+ }
250
+ /** How a limit reads back in the author's own words. */
251
+ export declare function describeLimit(limit: Limit): string;
237
252
  /**
238
253
  * The modifiers a placement understands, in brackets after its targets. Refused
239
254
  * by name when unrecognized, for the reason `DIAGRAM_KEYS` are: a modifier that
@@ -368,6 +383,8 @@ export interface NodeStmt {
368
383
  titlePlacements?: Placement[];
369
384
  /** Everything the author said about where this goes. Empty for the anchor. */
370
385
  placements: Placement[];
386
+ /** `stays above laptop.photos`: what it keeps clear of (design.org 5.6). */
387
+ limits?: Limit[];
371
388
  attrs: Attrs;
372
389
  /** `guide node frame ""`: does everything a node does except draw (design.org 8.1). */
373
390
  guide?: boolean;
package/dist/ast.js CHANGED
@@ -160,6 +160,12 @@ export function describePoint(point) {
160
160
  const of = /left|right/.test(point.direction) ? ' of' : '';
161
161
  return `${point.distance} ${point.direction}${of} ${nameTarget(point.from)}`;
162
162
  }
163
+ /** How a limit reads back in the author's own words. */
164
+ export function describeLimit(limit) {
165
+ const of = limit.way === 'left' || limit.way === 'right' ? ' of' : '';
166
+ const gap = limit.gap === undefined ? '' : ` (gap: ${limit.gap})`;
167
+ return `stays ${limit.way}${of} ${nameTarget(limit.target)}${gap}`;
168
+ }
163
169
  /**
164
170
  * The modifiers a placement understands, in brackets after its targets. Refused
165
171
  * by name when unrecognized, for the reason `DIAGRAM_KEYS` are: a modifier that
package/dist/checks.d.ts CHANGED
@@ -21,6 +21,7 @@ export declare const CHECK_DEFAULTS: {
21
21
  readonly 'edge-overlap': true;
22
22
  readonly 'crowded-edge-ends': true;
23
23
  readonly 'node-overlap': true;
24
+ readonly 'node-overflow': true;
24
25
  readonly 'edge-no-length': true;
25
26
  readonly 'edge-crossings': false;
26
27
  };
package/dist/checks.js CHANGED
@@ -25,6 +25,7 @@ export const CHECK_DEFAULTS = {
25
25
  'edge-overlap': true,
26
26
  'crowded-edge-ends': true,
27
27
  'node-overlap': true,
28
+ 'node-overflow': true,
28
29
  'edge-no-length': true,
29
30
  'edge-crossings': false,
30
31
  };
@@ -96,6 +97,8 @@ export function findings(layout, checks) {
96
97
  const found = [];
97
98
  if (checks['node-overlap'])
98
99
  found.push(...nodeOverlaps(layout));
100
+ if (checks['node-overflow'])
101
+ found.push(...nodeOverflows(layout));
99
102
  if (checks['text-overflow'])
100
103
  found.push(...textOverflows(layout));
101
104
  if (checks['edge-node-overlap'] || checks['edge-overlap'] || checks['edge-no-length'] || checks['crowded-edge-ends'] || checks['edge-crossings'] || checks['text-overlap']) {
@@ -781,12 +784,20 @@ function nodeOverlaps(layout) {
781
784
  return true;
782
785
  if (upA.some(saysOverlapAllowed) || upB.some(saysOverlapAllowed))
783
786
  return true;
784
- const placedOn = (over, under) => over.some((node) => under.some((target) => stated.get(node)?.has(target)));
785
- return placedOn(upA, upB) || placedOn(upB, upA);
787
+ // `inside hub top-right` accounts for covering hub, and what it holds
788
+ // covering hub, but not for covering hub's other children (design.org 5.6).
789
+ const placedOn = (over, under) => over.some((node) => stated.get(node)?.has(under));
790
+ return placedOn(upA, b) || placedOn(upB, a);
786
791
  };
787
792
  const pairs = [];
788
- // A guide box draws nothing, so nothing drawn can cover it or be covered by it.
789
- const sorted = layout.nodes.filter((node) => !node.guide).sort((a, b) => a.x - b.x);
793
+ // A guide box draws nothing, so nothing drawn can cover it or be covered by
794
+ // it, and neither can a box with no fill, no border, no picture and no
795
+ // text, such as a spacer holding its container open.
796
+ const blank = (node) => node.lines.every((line) => line.every((run) => run.text.trim() === '')) &&
797
+ node.body.kind !== 'icon' &&
798
+ node.appearance['fill'] === 'none' &&
799
+ node.appearance['border'] === 'none';
800
+ const sorted = layout.nodes.filter((node) => !node.guide && !blank(node)).sort((a, b) => a.x - b.x);
790
801
  let active = [];
791
802
  for (const node of sorted) {
792
803
  active = active.filter((other) => other.x + other.width > node.x + EPSILON);
@@ -809,7 +820,70 @@ function nodeOverlaps(layout) {
809
820
  const inner = ({ earlier, later }) => lineage(earlier).some((a) => lineage(later).some((b) => (a !== earlier || b !== later) && reported.has(namePair(a, b))));
810
821
  return pairs
811
822
  .filter((pair) => !inner(pair))
812
- .map(({ earlier, later, width, height }) => new Finding('node-overlap', `node ${later.name} covers ${coveredPart(earlier, later)}node ${earlier.name}; ${size(width, height)}`, later.line));
823
+ .map(({ earlier, later, width, height }) => new Finding('node-overlap', `node ${later.name} covers ${coveredPart(earlier, later)}node ${earlier.name}; ${size(width, height)}` +
824
+ keepClear(earlier, later, width, height), later.line));
825
+ }
826
+ /**
827
+ * For a child placed against its container's side or corner over one of its
828
+ * siblings, the limit that would make the box room for it (design.org 5.6):
829
+ * toward the side it is against, which is the only way growing the box moves
830
+ * it, and of a corner's two, the one that clears the shorter overlap.
831
+ */
832
+ function keepClear(a, b, width, height) {
833
+ const sides = framedSides;
834
+ if (a.parent === undefined || a.parent !== b.parent || (sides(a).length > 0) === (sides(b).length > 0))
835
+ return '';
836
+ const [own, other] = sides(a).length > 0 ? [a, b] : [b, a];
837
+ const words = { top: 'above', bottom: 'below', left: 'left of', right: 'right of' };
838
+ const ways = sides(own).map((side) => words[side]);
839
+ const way = ways.length === 1 ? ways[0] : height <= width ? ways.find((each) => each === 'above' || each === 'below') : ways.find((each) => each.endsWith('of'));
840
+ return ` — to keep ${own.name} clear of it, add "stays ${way} ${other.name}" to ${own.name}`;
841
+ }
842
+ /** The sides of its container a child is placed against: `inside c top-right` is top and right. */
843
+ function framedSides(node) {
844
+ const parent = node.parent;
845
+ if (parent === undefined)
846
+ return [];
847
+ const part = node.placements
848
+ .flatMap((placement) => placement.targets)
849
+ .find((target) => target.name === parent.name && target.part !== 'text')?.part;
850
+ if (part === undefined)
851
+ return [];
852
+ return part.split('-').filter((word) => ['top', 'bottom', 'left', 'right'].includes(word));
853
+ }
854
+ /**
855
+ * A child placed against its container's side or corner that the box has no
856
+ * room for there (design.org 5.6): less room on the far side than it keeps
857
+ * from the side it names, so it runs into the far side's padding or out of
858
+ * the box. The box never grows for it, so it is drawn as stated.
859
+ */
860
+ function nodeOverflows(layout) {
861
+ const found = [];
862
+ for (const node of layout.nodes) {
863
+ const parent = node.parent;
864
+ if (parent === undefined || node.guide)
865
+ continue;
866
+ const short = [];
867
+ for (const side of framedSides(node)) {
868
+ const vertical = side === 'top' || side === 'bottom';
869
+ const start = vertical ? node.y : node.x;
870
+ const end = start + (vertical ? node.height : node.width);
871
+ const boxStart = vertical ? parent.y : parent.x;
872
+ const boxEnd = boxStart + (vertical ? parent.height : parent.width);
873
+ const kept = side === 'top' || side === 'left' ? start - boxStart : boxEnd - end;
874
+ const far = side === 'top' || side === 'left' ? boxEnd - end : start - boxStart;
875
+ if (far >= kept - EPSILON)
876
+ continue;
877
+ const room = (vertical ? parent.height : parent.width) - kept * 2;
878
+ const [measure, against] = vertical ? ['tall', 'height'] : ['wide', 'width'];
879
+ short.push(`it is ${units(vertical ? node.height : node.width)} ${measure} and ${parent.name}'s ${against} leaves ${room > 0 ? units(room) : 'nothing'} inside its padding`);
880
+ }
881
+ if (short.length === 0)
882
+ continue;
883
+ found.push(new Finding('node-overflow', `node ${node.name} has no room in the ${framedSides(node).join('-')} of ${parent.name}: ${short.join(', and ')} — ` +
884
+ `give ${parent.name} more room, or place ${node.name} against ${parent.name}'s text or its other children`, node.line));
885
+ }
886
+ return found;
813
887
  }
814
888
  function namePair(a, b) {
815
889
  return a.name < b.name ? `${a.name}\n${b.name}` : `${b.name}\n${a.name}`;
package/dist/grammar.js CHANGED
@@ -77,6 +77,8 @@ export const RELATION_WORDS = [
77
77
  // `(inside top)`, `outside router right`, `via: inside p top`.
78
78
  'inside',
79
79
  'outside',
80
+ // `stays above laptop.photos`, a limit (design.org 5.6).
81
+ 'stays',
80
82
  // Lines (design.org 6): `straight down from z3`, `each child of pool`,
81
83
  // `leg climb`, `via: through slot horizontal`.
82
84
  'straight',
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Attrs, EdgeTextPlace, Kind, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
1
+ import type { Attrs, EdgeTextPlace, Kind, Limit, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
2
2
  /** A rectangle on the page; a side or a point has no size on one axis or both. */
3
3
  export interface Rect {
4
4
  x: number;
@@ -55,6 +55,8 @@ export interface LayoutNode {
55
55
  * (design.org 7.2).
56
56
  */
57
57
  titlePlacements?: Placement[];
58
+ /** `stays above laptop.photos`: what it keeps clear of (design.org 5.6). */
59
+ limits?: Limit[];
58
60
  x: number;
59
61
  y: number;
60
62
  width: number;
package/dist/parser.js CHANGED
@@ -1238,7 +1238,15 @@ function parseNode(head, line) {
1238
1238
  listed: NODE_TEXT_LISTED,
1239
1239
  });
1240
1240
  refuseNodeReads(bracket.values['reads'], subject, line);
1241
- const tail = parseTail(head, bracket.next, line, subject);
1241
+ const limits = [];
1242
+ const tail = parseTail(head, bracket.next, line, subject, (tokens, index) => {
1243
+ const word = tokens[index];
1244
+ if (word.quoted || word.text !== 'stays')
1245
+ return undefined;
1246
+ const read = readLimit(tokens, index, line, subject);
1247
+ limits.push(read.limit);
1248
+ return read.next;
1249
+ });
1242
1250
  refuseTextKey(tail.attrs, subject, 'after the name', line);
1243
1251
  return {
1244
1252
  kind: 'node',
@@ -1248,10 +1256,43 @@ function parseNode(head, line) {
1248
1256
  textAttrs: place === undefined ? bracket.values : { ...bracket.values, place },
1249
1257
  ...(titlePlacements.length > 0 ? { titlePlacements } : {}),
1250
1258
  placements: tail.placements,
1259
+ ...(limits.length > 0 ? { limits } : {}),
1251
1260
  attrs: tail.attrs,
1252
1261
  line,
1253
1262
  };
1254
1263
  }
1264
+ /**
1265
+ * `stays above laptop.photos (gap: wide)`: a limit (design.org 5.6). The words
1266
+ * after `stays` are read as a placement would be, then held to one straight
1267
+ * way and one target, since a limit says which side of one thing to keep to.
1268
+ */
1269
+ function readLimit(tokens, at, line, subject) {
1270
+ const way = tokens[at + 1];
1271
+ const straight = ['above', 'below', 'left', 'right'];
1272
+ if (!way || way.quoted || !straight.includes(way.text)) {
1273
+ const diagonal = way !== undefined && !way.quoted && isDirection(way.text);
1274
+ throw new SourceError(diagonal
1275
+ ? `${subject}: "stays ${way.text}" keeps to two sides at once — write one limit for each, such as \`stays above a stays right of a\``
1276
+ : `${subject}: "stays" is followed by the way it keeps to — stays above, stays below, stays left of or stays right of`, line);
1277
+ }
1278
+ const read = readPlacement(tokens, at + 1, line, subject, true);
1279
+ const placement = read.placement;
1280
+ if (placement.kind !== 'offset' || placement.own !== undefined || placement.gaps !== undefined) {
1281
+ throw new SourceError(`${subject}: "stays" names a way and one thing, such as \`stays above laptop.photos\``, line);
1282
+ }
1283
+ if (placement.targets.length !== 1) {
1284
+ throw new SourceError(`${subject}: "${describePlacement(placement)}" after "stays" names ${placement.targets.length} things — a limit keeps clear of one; write one for each`, line);
1285
+ }
1286
+ return {
1287
+ limit: {
1288
+ way: way.text,
1289
+ target: placement.targets[0],
1290
+ ...(placement.gap !== undefined ? { gap: placement.gap } : {}),
1291
+ line,
1292
+ },
1293
+ next: read.next,
1294
+ };
1295
+ }
1255
1296
  /**
1256
1297
  * A node's text takes the look keys and `reads:` (design.org 7.2). `at` is
1257
1298
  * read only to be refused with the side form, so it isn't listed as one it takes.
@@ -2394,6 +2435,12 @@ function checkColorWord(value, subject, line) {
2394
2435
  if (value.startsWith('theme-') && !THEME_COLORS.includes(value)) {
2395
2436
  throw new SourceError(`${subject}: there is no theme color "${value}" — the theme colors are ${THEME_COLORS.join(', ')}`, line);
2396
2437
  }
2438
+ // Hex digits without their `#` are no CSS color, so the viewer would draw nothing.
2439
+ if (/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) {
2440
+ // `bad` and `face` are spelled in hex digits too, so without a digit it is only a guess.
2441
+ const fix = /\d/.test(value) ? 'a hex color is written' : 'if it is a hex color, write';
2442
+ throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
2443
+ }
2397
2444
  }
2398
2445
  function follows(tokens, at, word) {
2399
2446
  const token = tokens[at];
package/dist/place.d.ts CHANGED
@@ -130,6 +130,8 @@ export declare class Placer {
130
130
  private readonly sizes;
131
131
  private readonly frames;
132
132
  private readonly outlines;
133
+ /** A container's box while a limit tries it out (`roomFor`), in place of its final one. */
134
+ private readonly trials;
133
135
  private readonly working;
134
136
  /** What is being worked out, innermost last, for reporting a circle. */
135
137
  private readonly trail;
@@ -181,6 +183,20 @@ export declare class Placer {
181
183
  };
182
184
  /** Work out everything, and write it onto the nodes. */
183
185
  run(): void;
186
+ /**
187
+ * What a limit may relate (design.org 5.6): two things in one box that
188
+ * nothing else ties together, since the box's own spacing between them is
189
+ * the only distance it may lengthen.
190
+ */
191
+ private checkLimitTargets;
192
+ /**
193
+ * Which of the things a box holds that nothing ties to each other a member
194
+ * belongs to, on one axis: its title, what is placed against the box's
195
+ * sides, or the rest, placed against each other.
196
+ */
197
+ private groupOf;
198
+ /** A limit the box couldn't make room for: no side helps, or a stated size holds it in. */
199
+ private checkLimitsMet;
184
200
  /** Where a node starts on the page. */
185
201
  page(node: LayoutNode, axis: Axis): number;
186
202
  /**
@@ -226,6 +242,26 @@ export declare class Placer {
226
242
  private frame;
227
243
  /** A container's outline around what it holds, before anything states its size. */
228
244
  private outline;
245
+ /**
246
+ * A container's outline with room made for its children's limits
247
+ * (design.org 5.6). The only distance a limit may lengthen is between what
248
+ * the box holds and its sides, which every placement inside leaves unstated.
249
+ * Each side's room moves a limit's two ends apart at a fixed rate (all of
250
+ * it for a corner, half for the middle of a side), so one trial of each
251
+ * side gives the rate, and the room is the shortfall over the rate. A side
252
+ * that doesn't help is never used; with both helping, as for two corner
253
+ * children, the box grows evenly. A limit no side helps is left unmet, and
254
+ * `checkLimits` refuses it.
255
+ */
256
+ private roomFor;
257
+ /** The limits a container's children state on one axis. */
258
+ private limitsIn;
259
+ /** How much nearer than its gap a limit's two ends are, in a trial frame of their container. */
260
+ private shortfall;
261
+ /** The distance a limit keeps: its gap, or the usual one; beside a text, as beside any text. */
262
+ private limitGap;
263
+ /** Where a child of `node` is on one axis, in a trial frame of `node`. */
264
+ private memberAt;
229
265
  private remember;
230
266
  /**
231
267
  * Work something out, refusing a circle. The trail holds what was being
@@ -238,6 +274,14 @@ export declare class Placer {
238
274
  private unitStart;
239
275
  /** A box's start from its own placements. */
240
276
  private placed;
277
+ /**
278
+ * Opposite placements center a box between two others, but not when one of
279
+ * them is its own container's box: `inside c top-right right of c.k` would
280
+ * be neither in the corner nor a gap from `c.k` (design.org 5.3). Refused,
281
+ * naming the two ways out: the corner's other half beside the sibling, or
282
+ * the corner kept and the sibling held off with `stays`.
283
+ */
284
+ private refuseBoxAndSibling;
241
285
  private edgeOf;
242
286
  /** A box's start from one statement. */
243
287
  private apply;
@@ -314,6 +358,15 @@ export declare class Placer {
314
358
  private textOf;
315
359
  /** A child placed against its own container's box, which it does not grow. */
316
360
  framed(node: LayoutNode): boolean;
361
+ /**
362
+ * Framed on one axis: what says where it goes on that axis is against its
363
+ * container's box. `right of c.k inside c top` is framed down, so its height
364
+ * is measured from the finished box, and is content across, so the box holds
365
+ * it there (design.org 5.6).
366
+ */
367
+ framedOn(node: LayoutNode, axis: Axis): boolean;
368
+ /** Whether a placement names its container's box, any part but the text. */
369
+ private againstBox;
317
370
  /**
318
371
  * A child placed against its container's text, or against its heading,
319
372
  * which makes the title its frame's anchor.
@@ -334,7 +387,8 @@ export declare class Placer {
334
387
  /**
335
388
  * The children a container's box is the outline of, besides its title: not
336
389
  * its heading, not what is joined to its text, and not those placed against
337
- * the box.
390
+ * the box. Given an axis, a child placed against the box only on the other
391
+ * axis is content on this one.
338
392
  */
339
393
  private contentOf;
340
394
  /** Whether a node has a title: its text, what is joined to it, or a heading. */
package/dist/place.js CHANGED
@@ -1,4 +1,4 @@
1
- import { describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
1
+ import { describeLimit, describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
2
2
  import { DECK_STEP, GAPS, HEADER_GAP, UNIT } from './constants.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { isWithin, joinedToText } from './model.js';
@@ -107,6 +107,8 @@ export class Placer {
107
107
  sizes = new Map();
108
108
  frames = new Map();
109
109
  outlines = new Map();
110
+ /** A container's box while a limit tries it out (`roomFor`), in place of its final one. */
111
+ trials = new Map();
110
112
  working = new Set();
111
113
  /** What is being worked out, innermost last, for reporting a circle. */
112
114
  trail = [];
@@ -175,6 +177,8 @@ export class Placer {
175
177
  /** Work out everything, and write it onto the nodes. */
176
178
  run() {
177
179
  this.checkAnchors(this.roots, undefined);
180
+ for (const node of this.byName.values())
181
+ this.checkLimitTargets(node);
178
182
  for (const node of this.byName.values()) {
179
183
  for (const axis of BOTH_AXES) {
180
184
  this.size(node, axis);
@@ -189,9 +193,76 @@ export class Placer {
189
193
  }
190
194
  for (const node of this.byName.values())
191
195
  this.checkYielded(node);
196
+ for (const node of this.byName.values())
197
+ this.checkLimitsMet(node);
192
198
  for (const node of this.byName.values())
193
199
  this.finish(node);
194
200
  }
201
+ /**
202
+ * What a limit may relate (design.org 5.6): two things in one box that
203
+ * nothing else ties together, since the box's own spacing between them is
204
+ * the only distance it may lengthen.
205
+ */
206
+ checkLimitTargets(node) {
207
+ for (const limit of node.limits ?? []) {
208
+ const parent = node.parent;
209
+ const said = `"${node.name} ${describeLimit(limit)}"`;
210
+ const named = this.byName.get(limit.target.name);
211
+ if (!named) {
212
+ throw new SourceError(`${said}: "${limit.target.name}" does not exist${partWordHint(limit.target.name, this.byName.keys())}`, limit.line);
213
+ }
214
+ const ownText = parent !== undefined && named === parent && limit.target.part === 'text';
215
+ const sibling = parent !== undefined && named.parent === parent && named !== node && limit.target.part === undefined;
216
+ if (!parent || !(ownText || sibling)) {
217
+ throw new SourceError(`${said}: a limit keeps clear of something in the same box, a sibling or ` +
218
+ `${parent ? `"${parent.name} text"` : 'the box\'s own text'}, since that box is what makes room`, limit.line);
219
+ }
220
+ if (ownText && !this.hasTitle(parent)) {
221
+ throw new SourceError(`${said}: "${parent.name}" has no text`, limit.line);
222
+ }
223
+ const axis = limitAxis(limit);
224
+ const own = this.groupOf(node, parent, axis);
225
+ if (own !== 'frame' && own === this.groupOf(ownText ? 'text' : named, parent, axis)) {
226
+ const how = own === 'title' ? `with "${parent.name}"'s text` : 'against each other';
227
+ throw new SourceError(`${said} could never change anything: "${node.name}" and "${nameTarget(limit.target)}" are placed ${how}, ` +
228
+ 'so the distance between them is already stated — a limit relates two things in one box that nothing else ties together, ' +
229
+ `such as a child placed against "${parent.name}"'s side and one of its other children`, limit.line);
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Which of the things a box holds that nothing ties to each other a member
235
+ * belongs to, on one axis: its title, what is placed against the box's
236
+ * sides, or the rest, placed against each other.
237
+ */
238
+ groupOf(member, parent, axis) {
239
+ const titleTied = this.anchoredOnTitle(parent) || (parent.titlePlacements?.length ?? 0) > 0;
240
+ if (member === 'text' || member === parent.heading || joinedToText(member))
241
+ return titleTied ? 'content' : 'title';
242
+ return this.framedOn(member, axis) ? 'frame' : 'content';
243
+ }
244
+ /** A limit the box couldn't make room for: no side helps, or a stated size holds it in. */
245
+ checkLimitsMet(node) {
246
+ for (const limit of node.limits ?? []) {
247
+ const parent = node.parent;
248
+ const axis = limitAxis(limit);
249
+ const frame = this.frame(parent, axis);
250
+ if (this.shortfall(parent, axis, { subject: node, limit }, frame) <= LIMIT_EPSILON)
251
+ continue;
252
+ const said = `"${describeLimit(limit)}"`;
253
+ if (frame.boxSize !== this.outline(parent, axis).boxSize) {
254
+ throw new SourceError(`"${node.name} ${describeLimit(limit)}", but "${parent.name}"'s stated ${axis === 'x' ? 'width' : 'height'} leaves no room for it — ` +
255
+ 'make it bigger, or leave the size unstated so the box can make room', limit.line);
256
+ }
257
+ const named = this.byName.get(limit.target.name);
258
+ const framed = [node, named].find((member) => member !== parent && this.framedOn(member, axis));
259
+ const where = framed
260
+ ? this.statements(framed, axis).map((statement) => `"${framed.name} ${describePlacement(statement.placement)}"`).join(' and ')
261
+ : undefined;
262
+ throw new SourceError(`"${node.name}" can't keep to ${said}${where ? ` with ${where}` : ''}: "${parent.name}" holds both, and growing it ` +
263
+ 'never moves them apart — keep one of the two', limit.line);
264
+ }
265
+ }
195
266
  // --- the quantities ------------------------------------------------------
196
267
  /** Where a node starts on the page. */
197
268
  page(node, axis) {
@@ -363,6 +434,9 @@ export class Placer {
363
434
  /** A container's box and title in its own frame, at the size it ends up. */
364
435
  frame(node, axis) {
365
436
  const key = `${node.name}\n${axis}`;
437
+ const trial = this.trials.get(key);
438
+ if (trial)
439
+ return trial;
366
440
  const known = this.frames.get(key);
367
441
  if (known)
368
442
  return known;
@@ -376,10 +450,91 @@ export class Placer {
376
450
  const known = this.outlines.get(key);
377
451
  if (known)
378
452
  return known;
379
- const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.workOutFrame(node, axis));
453
+ const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.roomFor(node, axis));
380
454
  this.outlines.set(key, worked);
381
455
  return worked;
382
456
  }
457
+ /**
458
+ * A container's outline with room made for its children's limits
459
+ * (design.org 5.6). The only distance a limit may lengthen is between what
460
+ * the box holds and its sides, which every placement inside leaves unstated.
461
+ * Each side's room moves a limit's two ends apart at a fixed rate (all of
462
+ * it for a corner, half for the middle of a side), so one trial of each
463
+ * side gives the rate, and the room is the shortfall over the rate. A side
464
+ * that doesn't help is never used; with both helping, as for two corner
465
+ * children, the box grows evenly. A limit no side helps is left unmet, and
466
+ * `checkLimits` refuses it.
467
+ */
468
+ roomFor(node, axis) {
469
+ const limits = this.limitsIn(node, axis);
470
+ let room = NO_ROOM;
471
+ let frame = this.workOutFrame(node, axis);
472
+ for (let round = 0; round < limits.length * 2; round++) {
473
+ const short = (at) => Math.max(...limits.map((each) => this.shortfall(node, axis, each, at)));
474
+ const owed = short(frame);
475
+ if (owed <= LIMIT_EPSILON)
476
+ break;
477
+ const rate = (more) => (owed - short(this.workOutFrame(node, axis, more))) / owed;
478
+ const low = rate({ low: room.low + owed, high: room.high });
479
+ const high = rate({ low: room.low, high: room.high + owed });
480
+ if (low > LIMIT_EPSILON && high > LIMIT_EPSILON) {
481
+ const both = rate({ low: room.low + owed, high: room.high + owed });
482
+ room = { low: room.low + owed / both, high: room.high + owed / both };
483
+ }
484
+ else if (low > LIMIT_EPSILON) {
485
+ room = { ...room, low: room.low + owed / low };
486
+ }
487
+ else if (high > LIMIT_EPSILON) {
488
+ room = { ...room, high: room.high + owed / high };
489
+ }
490
+ else {
491
+ break;
492
+ }
493
+ frame = this.workOutFrame(node, axis, room);
494
+ }
495
+ return frame;
496
+ }
497
+ /** The limits a container's children state on one axis. */
498
+ limitsIn(node, axis) {
499
+ return node.children.flatMap((subject) => (subject.limits ?? []).filter((limit) => limitAxis(limit) === axis).map((limit) => ({ subject, limit })));
500
+ }
501
+ /** How much nearer than its gap a limit's two ends are, in a trial frame of their container. */
502
+ shortfall(node, axis, { subject, limit }, at) {
503
+ const own = this.memberAt(subject, node, axis, at);
504
+ const other = limit.target.name === node.name ? { start: at.textStart, size: at.textSize } : this.memberAt(this.byName.get(limit.target.name), node, axis, at);
505
+ const gap = this.limitGap(subject, limit);
506
+ const toward = limit.way === 'above' || limit.way === 'left';
507
+ return toward ? own.start + own.size + gap - other.start : other.start + other.size + gap - own.start;
508
+ }
509
+ /** The distance a limit keeps: its gap, or the usual one; beside a text, as beside any text. */
510
+ limitGap(subject, limit) {
511
+ if (limit.gap !== undefined)
512
+ return gapPixels(limit.gap, `"${subject.name}"`, limit.line);
513
+ const parent = subject.parent;
514
+ if (limit.target.name === parent.name)
515
+ return this.hasTitle(parent) ? UNIT : 0;
516
+ return GAPS['normal'];
517
+ }
518
+ /** Where a child of `node` is on one axis, in a trial frame of `node`. */
519
+ memberAt(member, node, axis, at) {
520
+ const size = this.size(member, axis);
521
+ if (member === node.heading)
522
+ return { start: at.titleStart, size };
523
+ if (joinedToText(member))
524
+ return { start: at.textStart + this.unitStart(member, axis), size };
525
+ if (!this.framedOn(member, axis))
526
+ return { start: this.start(member, axis), size };
527
+ // Placed against the box: where the trial box puts it, worked out
528
+ // without remembering, since the box isn't final.
529
+ const key = `${node.name}\n${axis}`;
530
+ this.trials.set(key, at);
531
+ try {
532
+ return { start: this.placed(member, axis), size };
533
+ }
534
+ finally {
535
+ this.trials.delete(key);
536
+ }
537
+ }
383
538
  remember(memo, node, axis, work) {
384
539
  const key = `${node.name}\n${axis}`;
385
540
  const known = memo.get(key);
@@ -480,6 +635,7 @@ export class Placer {
480
635
  }
481
636
  const [first, second] = statements;
482
637
  if (first.kind !== 'align' && second.kind !== 'align' && first.kind !== second.kind) {
638
+ this.refuseBoxAndSibling(node, axis, first.placement, second.placement);
483
639
  // Opposite placements: centered between the two at its own size. Each
484
640
  // target stays where its own statement put it.
485
641
  const after = first.kind === 'after' ? first : second;
@@ -494,6 +650,36 @@ export class Placer {
494
650
  return pins.start;
495
651
  throw this.overStated(node, axis, statements);
496
652
  }
653
+ /**
654
+ * Opposite placements center a box between two others, but not when one of
655
+ * them is its own container's box: `inside c top-right right of c.k` would
656
+ * be neither in the corner nor a gap from `c.k` (design.org 5.3). Refused,
657
+ * naming the two ways out: the corner's other half beside the sibling, or
658
+ * the corner kept and the sibling held off with `stays`.
659
+ */
660
+ refuseBoxAndSibling(node, axis, first, second) {
661
+ const parent = node.parent;
662
+ if (!parent || this.againstBox(first, parent) === this.againstBox(second, parent))
663
+ return;
664
+ const [box, other] = this.againstBox(first, parent) ? [first, second] : [second, first];
665
+ if (other.kind !== 'offset')
666
+ return;
667
+ const ways = [];
668
+ const corner = box.targets[0]?.part;
669
+ if (box.kind === 'offset' && box.written !== undefined && corner !== undefined && corner.includes('-')) {
670
+ const [down, across] = corner.split('-');
671
+ const kept = axis === 'x' ? down : across;
672
+ ways.push(`to sit ${describePlacement(other)} at the ${kept} of ${parent.name}, write "${describePlacement(other)} ${box.written} ${parent.name} ${kept}"`);
673
+ }
674
+ const way = component(other.direction, axis);
675
+ const straight = axis === 'x' ? (way === 'after' ? 'right' : 'left') : way === 'after' ? 'below' : 'above';
676
+ const of = axis === 'x' ? ' of' : '';
677
+ if (other.targets.length === 1) {
678
+ ways.push(`to stay ${describePlacement(box)} and clear of ${nameTarget(other.targets[0])}, write "stays ${straight}${of} ${nameTarget(other.targets[0])}"`);
679
+ }
680
+ throw new SourceError(`"${node.name}" says twice where it goes ${AXIS_SAID[axis]}, with "${describePlacement(box)}" and "${describePlacement(other)}"` +
681
+ (ways.length > 0 ? `; ${ways.join('; ')}` : ' — keep one'), other.line);
682
+ }
497
683
  edgeOf(node, axis, statement) {
498
684
  return this.guard(`edge\n${node.name}\n${axis}\n${statement.placement.line}`, node, axis, statement.placement, () => statement.kind === 'align' ? 0 : statement.edge());
499
685
  }
@@ -991,7 +1177,7 @@ export class Placer {
991
1177
  text = { start: frame.textStart, size: frame.textSize };
992
1178
  }
993
1179
  else if (named.parent === parent) {
994
- if (this.framed(named) && !this.framed(node)) {
1180
+ if (this.framedOn(named, axis) && !this.framedOn(node, axis)) {
995
1181
  throw new SourceError(`"${node.name}" is placed against "${named.name}", which is placed against "${parent.name}" itself. ` +
996
1182
  `Nothing may be placed against a thing placed against its container's side or corner — place ` +
997
1183
  `"${node.name}" against another child`, placement.line);
@@ -1049,7 +1235,27 @@ export class Placer {
1049
1235
  const parent = node.parent;
1050
1236
  if (!parent || this.facts.get(parent).layout || this.facts.get(parent).venn)
1051
1237
  return false;
1052
- return node.placements.some((placement) => placement.targets.some((target) => target.name === parent.name && target.part !== 'text'));
1238
+ return node.placements.some((placement) => this.againstBox(placement, parent));
1239
+ }
1240
+ /**
1241
+ * Framed on one axis: what says where it goes on that axis is against its
1242
+ * container's box. `right of c.k inside c top` is framed down, so its height
1243
+ * is measured from the finished box, and is content across, so the box holds
1244
+ * it there (design.org 5.6).
1245
+ */
1246
+ framedOn(node, axis) {
1247
+ if (!this.framed(node))
1248
+ return false;
1249
+ const parent = node.parent;
1250
+ const statements = this.statements(node, axis);
1251
+ if (statements.length > 0)
1252
+ return statements.some((statement) => this.againstBox(statement.placement, parent));
1253
+ // Nothing states this axis, so a lone direction on the other one centers it on its target.
1254
+ return node.placements.some((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined && this.againstBox(placement, parent));
1255
+ }
1256
+ /** Whether a placement names its container's box, any part but the text. */
1257
+ againstBox(placement, parent) {
1258
+ return placement.targets.some((target) => target.name === parent.name && target.part !== 'text');
1053
1259
  }
1054
1260
  /**
1055
1261
  * A child placed against its container's text, or against its heading,
@@ -1111,10 +1317,13 @@ export class Placer {
1111
1317
  /**
1112
1318
  * The children a container's box is the outline of, besides its title: not
1113
1319
  * its heading, not what is joined to its text, and not those placed against
1114
- * the box.
1320
+ * the box. Given an axis, a child placed against the box only on the other
1321
+ * axis is content on this one.
1115
1322
  */
1116
- contentOf(node) {
1117
- return node.children.filter((child) => child !== node.heading && !joinedToText(child) && !this.framed(child));
1323
+ contentOf(node, axis) {
1324
+ return node.children.filter((child) => child !== node.heading &&
1325
+ !joinedToText(child) &&
1326
+ !(axis === undefined ? this.framed(child) : this.framedOn(child, axis)));
1118
1327
  }
1119
1328
  /** Whether a node has a title: its text, what is joined to it, or a heading. */
1120
1329
  hasTitle(node) {
@@ -1307,12 +1516,12 @@ export class Placer {
1307
1516
  * not placed against the box itself, and the title when it sits inside the
1308
1517
  * border or on it.
1309
1518
  */
1310
- workOutFrame(node, axis) {
1519
+ workOutFrame(node, axis, room = NO_ROOM) {
1311
1520
  const facts = this.facts.get(node);
1312
1521
  const title = this.titleOf(node);
1313
1522
  const hasTitle = this.hasTitle(node);
1314
1523
  const titleSize = this.titleSize(node, axis);
1315
- const content = this.contentOf(node);
1524
+ const content = this.contentOf(node, axis);
1316
1525
  const anchoredOnTitle = this.anchoredOnTitle(node);
1317
1526
  let low = Infinity;
1318
1527
  let high = -Infinity;
@@ -1343,6 +1552,12 @@ export class Placer {
1343
1552
  high = Math.max(high, band);
1344
1553
  }
1345
1554
  }
1555
+ // Room a child's limit asks for (design.org 5.6), between what the box
1556
+ // holds and its sides, so a title against a side moves out with it.
1557
+ if (!empty) {
1558
+ low -= room.low;
1559
+ high += room.high;
1560
+ }
1346
1561
  // Where the title sits in the frame. When something is placed against it,
1347
1562
  // it is the frame's anchor and sits at the origin; otherwise it is placed
1348
1563
  // against the children by its side.
@@ -1358,6 +1573,10 @@ export class Placer {
1358
1573
  low = titleStart;
1359
1574
  high = titleStart + titleSize;
1360
1575
  }
1576
+ if (empty) {
1577
+ low -= room.low;
1578
+ high += room.high;
1579
+ }
1361
1580
  }
1362
1581
  else if (title.how === 'center' || title.side === 'center') {
1363
1582
  titleStart = (low + high) / 2 - titleSize / 2;
@@ -1590,7 +1809,7 @@ export class Placer {
1590
1809
  innerOther(node, axis) {
1591
1810
  const key = `inner\n${node.name}\n${axis}`;
1592
1811
  return this.guard(key, node, axis, undefined, () => {
1593
- const content = this.contentOf(node);
1812
+ const content = this.contentOf(node, axis);
1594
1813
  let low = Infinity;
1595
1814
  let high = -Infinity;
1596
1815
  for (const child of content) {
@@ -2173,6 +2392,13 @@ export class Placer {
2173
2392
  return { low, high };
2174
2393
  }
2175
2394
  }
2395
+ const NO_ROOM = { low: 0, high: 0 };
2396
+ /** Less than this is no distance at all, for a limit worked out in floating point. */
2397
+ const LIMIT_EPSILON = 0.01;
2398
+ /** The axis a limit keeps to: up and down for above and below. */
2399
+ function limitAxis(limit) {
2400
+ return limit.way === 'above' || limit.way === 'below' ? 'y' : 'x';
2401
+ }
2176
2402
  /**
2177
2403
  * `top even with 2 below design top` on a venn's set: the other set's same
2178
2404
  * side, moved up or down. Anything else, such as its bottom or a sideways
package/dist/resolve.js CHANGED
@@ -771,6 +771,7 @@ function buildTree(statements, styles, defaults, icons) {
771
771
  attrs: stmt.attrs,
772
772
  appearance,
773
773
  placements: stmt.placements,
774
+ ...(stmt.limits ? { limits: stmt.limits } : {}),
774
775
  ...(stmt.guide ? { guide: true } : {}),
775
776
  line: stmt.line,
776
777
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {