grailwind 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-11-15
10
+
11
+ ### Added
12
+
13
+ - Engrailed and invected bordures, e.g. `bordure-engrailed-or`. On a frame the field grows into the
14
+ bites of an engrailed one; off a frame they are plain.
15
+ - Bordures in a fur, e.g. `bordure-ermine` and `bordure-engrailed-vair`. Off a shield's field they
16
+ show the fur's ground.
17
+ - A bordure on a per-pale part follows the shield's outline and stops at the line of
18
+ impalement. A chief on that part goes under the bordure.
19
+ - Vair and potent, e.g. `vair`, `counter-vair`, `potent` and `vairy-or-gules`.
20
+ - `fur-<n>` sets how many tiles of a fur go across, e.g. `fur-3` for beffroi.
21
+ - More charge counts: 7, 9 and 10 on a plain field; 4 on a cross or saltire, 2 and 5 on a chevron
22
+ and 4 and 5 on a pale, fess or bend with `on`; 3 round a bend and 4, 9 and 10 round a chevron
23
+ with `between`.
24
+
25
+ ### Changed
26
+
27
+ - The bordure is an eighth of the shield's width, so the field inside it is smaller.
28
+ - **Breaking:** the shield is a frame round its field. `escutcheon` is now
29
+ `heater`, with the blazon on its one child:
30
+
31
+ ```html
32
+ <!-- before -->
33
+ <div class="escutcheon azure fess-or"></div>
34
+ <!-- after -->
35
+ <div class="heater"><div class="azure fess-or"></div></div>
36
+ ```
37
+
38
+ The heater is pointed, after Fox-Davies, and its outline is drawn just
39
+ inside its edge. An inescutcheon is a frame too, round its own field, and
40
+ pointed. A bordure on a shield's field follows its outline and scales with
41
+ it; on a part of a divided field or a canton, it's still a border.
42
+
43
+ - `@grailwind/eslint-plugin` 0.1.0-beta.5 needs `grailwind` 0.9.x. The vair
44
+ family isn't checked by the rule of tincture unless the `furs` option names
45
+ it. The new `field-in-shield` rule flags a blazon on the frame, and
46
+ `escutcheon`.
47
+
48
+ ### Fixed
49
+
50
+ - A canton, inescutcheon or `charges` over all of a divided field with parts no longer takes a
51
+ part's place.
52
+
9
53
  ## [0.8.1] - 2026-11-13
10
54
 
11
55
  ### Fixed
@@ -193,7 +237,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
193
237
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
194
238
  custom SVG charges.
195
239
 
196
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.8.1...HEAD
240
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.9.0...HEAD
241
+ [0.9.0]: https://github.com/pafirmin/grailwind/compare/v0.8.1...v0.9.0
197
242
  [0.8.1]: https://github.com/pafirmin/grailwind/compare/v0.8.0...v0.8.1
198
243
  [0.8.0]: https://github.com/pafirmin/grailwind/compare/v0.7.0...v0.8.0
199
244
  [0.7.0]: https://github.com/pafirmin/grailwind/compare/v0.6.0...v0.7.0
package/README.md CHANGED
@@ -11,11 +11,13 @@ Heraldic blazon for [Tailwind CSS](https://tailwindcss.com). Draw coats of
11
11
  arms, or style anything else, in the language heralds have used for 800 years:
12
12
 
13
13
  ```html
14
- <div class="escutcheon azure chevron-or">
15
- <div class="charges between">
16
- <i class="mullet argent"></i>
17
- <i class="mullet argent"></i>
18
- <i class="mullet argent"></i>
14
+ <div class="heater">
15
+ <div class="azure chevron-or">
16
+ <div class="charges between">
17
+ <i class="mullet argent"></i>
18
+ <i class="mullet argent"></i>
19
+ <i class="mullet argent"></i>
20
+ </div>
19
21
  </div>
20
22
  </div>
21
23
  ```
@@ -53,9 +55,12 @@ tinctures replace Tailwind's colours entirely.
53
55
 
54
56
  - **Tinctures:** a tincture on its own sets the background (`azure`), and
55
57
  Tailwind's prefixes work as usual (`text-or`). There are also the furs `ermine`,
56
- `ermines`, `erminois` and `pean`, which work in divisions too.
57
- - **The shield:** `escutcheon`, plus `bordure-*`, `inescutcheon` (a small shield at the centre)
58
- and `canton` (a ninth of the shield in dexter chief), each with its own field and charges.
58
+ `ermines`, `erminois`, `pean`, `vair` and `potent` (with `vairy-or-gules` and the like), which
59
+ work in divisions too, and `fur-<n>` sets their density.
60
+ - **The shield:** `heater`, a pointed shield round its one child, the field, plus `bordure-*` (a
61
+ eighth of the shield wide, also `bordure-engrailed-*` and `bordure-invected-*`), `inescutcheon` (a
62
+ small shield at the centre) and `canton` (a ninth of the shield in dexter chief), each with its
63
+ own field and charges.
59
64
  - **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
60
65
  `gyronny-or-sable`, `barry-argent-azure`, `bendy-argent-gules`, `chequy-or-azure` and more, or
61
66
  `per-pale`, `per-fess` and `quarterly` for layout.
package/charges.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Grailwind CSS: charges and their arrangement.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
- import { POINTS, ROUNDELS } from "./data.js";
5
+ import { BETWEEN, BETWEEN_LINED, ON, POINTS, ROUNDELS } from "./data.js";
6
6
  import { AT } from "./furs.js";
7
7
 
8
8
  const star = (cx, cy, r, points = 5) => {
@@ -67,9 +67,12 @@ const DEFAULTS = {
67
67
  1: { size: 50, points: [[50, 45]] },
68
68
  2: { size: 30, points: [[50, 24], [50, 66]] },
69
69
  3: { size: 28, points: [[28, 25], [72, 25], [50, 64]] },
70
- 4: { size: 24, points: [[30, 24], [70, 24], [32, 60], [68, 60]] },
71
- 5: { size: 22, points: [[27, 20], [73, 20], [50, 42], [32, 64], [68, 64]] },
70
+ 4: { size: 24, points: [[30, 24], [70, 24], [30, 60], [70, 60]] },
71
+ 5: { size: 22, points: [[27, 20], [73, 20], [50, 42], [27, 64], [73, 64]] },
72
72
  6: { size: 20, points: [[20, 18], [50, 18], [80, 18], [35, 42], [65, 42], [50, 66]] },
73
+ 7: { size: 20, points: [[24, 18], [50, 18], [76, 18], [24, 42], [50, 42], [76, 42], [50, 66]] },
74
+ 9: { size: 17, points: [[22, 16], [50, 16], [78, 16], [24, 38], [50, 38], [76, 38], [32, 60], [50, 60], [68, 60]] },
75
+ 10: { size: 15, points: [[17, 15], [39, 15], [61, 15], [83, 15], [28, 35], [50, 35], [72, 35], [39, 55], [61, 55], [50, 75]] },
73
76
  };
74
77
 
75
78
  const POINT_SIZE = 22;
@@ -155,15 +158,16 @@ const inheritOnCounterchange = Object.fromEntries(
155
158
  COUNTERCHANGE.map((n) => [`--on-counterchange-${n}`, "inherit"]),
156
159
  );
157
160
 
158
- // A layer the size of the shield, as the escutcheon is the containing block
159
- // for fixed positioning. The charge's mask cuts it to shape.
161
+ // A layer the size of the field, as the heater (or a part of a divided shield, or a canton) is
162
+ // the containing block for fixed positioning, and the field lies inside its bordure by
163
+ // --layer-inset. The charge's mask cuts it to shape.
160
164
  const counterchanged = {
161
165
  ...inheritCounterchange,
162
166
  ...inheritOnCounterchange,
163
167
  "&::before": {
164
168
  content: '""',
165
169
  position: "fixed",
166
- inset: "0",
170
+ inset: "var(--layer-inset, 0px)",
167
171
  ...inheritCounterchange,
168
172
  ...inheritOnCounterchange,
169
173
  ...Object.fromEntries(
@@ -187,6 +191,15 @@ const atPoint = (name) => {
187
191
  : place([left, top], POINT_SIZE);
188
192
  };
189
193
 
194
+ /**
195
+ * @param {object[]} layouts Entries of `ON`, `BETWEEN` or `BETWEEN_LINED`, with optional `counts`.
196
+ * @returns {number[]} Every number of charges some layout places, ascending.
197
+ */
198
+ const countKeys = (layouts) =>
199
+ [...new Set(layouts.flatMap((layout) => Object.keys(layout.counts ?? {}).map(Number)))].sort(
200
+ (a, b) => a - b,
201
+ );
202
+
190
203
  const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
191
204
 
192
205
  export default plugin(({ addUtilities, matchUtilities }) => {
@@ -211,7 +224,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
211
224
  const charges = {
212
225
  position: "absolute",
213
226
  inset: "var(--field-top, 0%) 0 0",
214
- // Above the chief, which is painted last, so charges can sit on it.
227
+ // Above the field's ordinaries. Charges on the chief go higher, with it (see below).
215
228
  "z-index": "1",
216
229
  ...inheritCounterchange,
217
230
  "& > *": {
@@ -241,8 +254,11 @@ export default plugin(({ addUtilities, matchUtilities }) => {
241
254
  utilities[`.${name}`] = arrangement;
242
255
  }
243
256
 
257
+ // Charges on the chief go over it (z 3), which goes over a frame's bordure ring (z 2).
258
+ const onChief = { "z-index": "4" };
259
+
244
260
  for (const [name, counts] of Object.entries(ALONG)) {
245
- const arrangement = {};
261
+ const arrangement = name === "in-chief" ? { "&.charges": onChief } : {};
246
262
  for (const [count, points] of Object.entries(counts)) {
247
263
  points.forEach((point, i) => {
248
264
  arrangement[`&${hasCount(count)} > :nth-child(${i + 1})`] = atPoint(point);
@@ -260,8 +276,12 @@ export default plugin(({ addUtilities, matchUtilities }) => {
260
276
  "--charge-width": "var(--charge-size, var(--between-size))",
261
277
  };
262
278
  }
263
- // The counts some ordinary overrides in BETWEEN's `counts` (see data.js).
264
- for (const count of [2, 6]) {
279
+ // The counts some ordinary overrides in `counts` (see data.js).
280
+ const betweenCounts = countKeys([
281
+ ...Object.values(BETWEEN),
282
+ ...Object.values(BETWEEN_LINED).flatMap((lines) => Object.values(lines)),
283
+ ]);
284
+ for (const count of betweenCounts) {
265
285
  for (let i = 1; i <= count; i++) {
266
286
  between[`&${hasCount(count)} > :nth-child(${i})`] = {
267
287
  left: `var(--between-${count}-${i}-x, var(--between-${i}-x))`,
@@ -275,7 +295,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
275
295
  // Falls back to the plain-field default when the ordinary has no points for this count.
276
296
  // An undefined var() would otherwise unset `left`/`top` instead of deferring to the :where() default.
277
297
  const on = {};
278
- for (const count of [1, 2, 3, 5]) {
298
+ for (const count of countKeys(Object.values(ON))) {
279
299
  const fallback = DEFAULTS[count];
280
300
  for (let i = 1; i <= count; i++) {
281
301
  const [left, top] = fallback?.points[i - 1] ?? [];
@@ -286,7 +306,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
286
306
  on[`&${hasCount(count)} > :nth-child(${i})`] = {
287
307
  left: at("x", left),
288
308
  top: at("y", top),
289
- "--charge-width": `var(--charge-size, var(--on-size, ${fallback?.size ?? 16}%))`,
309
+ "--charge-width": `var(--charge-size, var(--on-${count}-size, var(--on-size, ${fallback?.size ?? 16}%)))`,
290
310
  };
291
311
  }
292
312
  }
@@ -294,7 +314,10 @@ export default plugin(({ addUtilities, matchUtilities }) => {
294
314
  utilities[".on"] = { "&": inheritOnCounterchange, ...on };
295
315
 
296
316
  for (const name of Object.keys(POINTS)) {
297
- utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
317
+ utilities[`.in-${name}`] = {
318
+ ".charges > &": atPoint(name),
319
+ ...(CHIEF_POINTS.has(name) && { ".charges:has(> &)": onChief }),
320
+ };
298
321
  }
299
322
 
300
323
  // The whole field, under the ordinaries and the chief. A custom charge has no tile, so it
package/data.js CHANGED
@@ -31,6 +31,12 @@ export const DIVISIONS = [
31
31
  "chequy",
32
32
  ];
33
33
 
34
+ // The classes that frame a field: a shape round one child, which carries the blazon.
35
+ export const FRAMES = ["heater", "inescutcheon"];
36
+
37
+ // The lines a bordure can have besides plain, e.g. `bordure-engrailed-or`.
38
+ export const BORDURE_LINES = ["engrailed", "invected"];
39
+
34
40
  // Ordinaries that can be reversed, e.g. `pile-reversed-or`: the same shape upside down.
35
41
  export const REVERSIBLE = ["chevron", "pall", "pile"];
36
42
  const reversed = (ordinary) => `${ordinary}-reversed`;
@@ -100,12 +106,47 @@ export const VOIDABLE = [
100
106
  ...REVERSIBLE.map(reversed),
101
107
  ];
102
108
 
103
- // Each fur's [ground, spot] tinctures.
109
+ // The vair family: plain vair and potent are argent and azure, and the -y forms take a metal and a
110
+ // colour, metal first as blazon writes them.
111
+ const VAIRS = ["vair", "counter-vair", "potent", "counter-potent"];
112
+ const VAIRY = {
113
+ vair: "vairy",
114
+ "counter-vair": "counter-vairy",
115
+ potent: "potenty",
116
+ "counter-potent": "counter-potenty",
117
+ };
118
+
119
+ // Each fur's pattern: ermine's spots, or the vair family's panes.
120
+ export const FUR_PATTERNS = {
121
+ ermine: "ermine",
122
+ ermines: "ermine",
123
+ erminois: "ermine",
124
+ pean: "ermine",
125
+ ...Object.fromEntries(VAIRS.map((pattern) => [pattern, pattern])),
126
+ ...Object.fromEntries(
127
+ VAIRS.flatMap((pattern) =>
128
+ METALS.flatMap((metal) =>
129
+ COLOURS.map((colour) => [`${VAIRY[pattern]}-${metal}-${colour}`, pattern]),
130
+ ),
131
+ ),
132
+ ),
133
+ };
134
+
135
+ // Each fur's [ground, drawn] tinctures: ermine's spots, or the vair family's panes that point up in
136
+ // the top row, which is the first tincture blazoned.
104
137
  export const FURS = {
105
138
  ermine: ["argent", "sable"],
106
139
  ermines: ["sable", "argent"],
107
140
  erminois: ["or", "sable"],
108
141
  pean: ["sable", "or"],
142
+ ...Object.fromEntries(VAIRS.map((pattern) => [pattern, ["azure", "argent"]])),
143
+ ...Object.fromEntries(
144
+ VAIRS.flatMap((pattern) =>
145
+ METALS.flatMap((metal) =>
146
+ COLOURS.map((colour) => [`${VAIRY[pattern]}-${metal}-${colour}`, [colour, metal]]),
147
+ ),
148
+ ),
149
+ ),
109
150
  };
110
151
 
111
152
  // The points of the shield, as [left%, top%], for placing a single charge, e.g. `in-dexter-chief`.
@@ -142,11 +183,16 @@ export const BETWEEN = {
142
183
  2: { points: [[50, 15], [50, 78]] },
143
184
  6: { size: 14, points: [[20, 14], [50, 14], [80, 14], [32, 70], [68, 70], [50, 86]] } } },
144
185
  bend: { size: 24, points: [[70, 26], [30, 68]], counts: {
186
+ 3: { size: 18, points: [[60, 13], [86, 36], [30, 68]] },
145
187
  6: { size: 14, points: [[48, 9], [76, 12], [84, 38], [9, 48], [18, 72], [38, 84]] } } },
146
188
  "bend-sinister": { size: 24, points: [[30, 26], [70, 68]], counts: {
189
+ 3: { size: 18, points: [[40, 13], [14, 36], [70, 68]] },
147
190
  6: { size: 14, points: [[52, 9], [24, 12], [16, 38], [91, 48], [82, 72], [62, 84]] } } },
148
191
  chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]], counts: {
149
- 6: { size: 12, points: [[18, 14], [50, 11], [82, 14], [40, 72], [60, 72], [50, 84]] } } },
192
+ 4: { size: 15, points: [[20, 16], [50, 9], [80, 16], [50, 74]] },
193
+ 6: { size: 12, points: [[18, 14], [50, 11], [82, 14], [40, 77], [60, 77], [50, 89]] },
194
+ 9: { size: 11, points: [[14, 12], [30, 12], [22, 26], [70, 12], [86, 12], [78, 26], [43, 73], [57, 73], [50, 86]] },
195
+ 10: { size: 11, points: [[14, 12], [30, 12], [22, 26], [70, 12], [86, 12], [78, 26], [43, 71], [57, 71], [43, 85], [57, 85]] } } },
150
196
  cross: { size: 18, points: [[20, 17], [80, 17], [24, 72], [76, 72]] },
151
197
  saltire: { size: 16, points: [[50, 13], [14, 45], [86, 45], [50, 84]], counts: {
152
198
  2: { points: [[14, 45], [86, 45]] } } },
@@ -158,18 +204,53 @@ export const BETWEEN = {
158
204
  // `on` charges shrink on lines that cut deep into the ordinary, by line.
159
205
  export const ON_SCALE = { engrailed: 0.8, indented: 0.9 };
160
206
 
161
- // Where charges go for "on", along the ordinary's centre line, by number of charges.
207
+ // Where charges go for "on", along the ordinary's centre line, by number of charges. A count
208
+ // can set its own size, else the ordinary's applies.
162
209
  // prettier-ignore
163
210
  export const ON = {
164
- pale: { size: 20, counts: { 1: [[50, 45]], 2: [[50, 28], [50, 62]], 3: [[50, 18], [50, 45], [50, 72]] } },
165
- fess: { size: 22, counts: { 1: [[50, 45]], 2: [[30, 45], [70, 45]], 3: [[20, 45], [50, 45], [80, 45]] } },
166
- bend: { size: 18, counts: { 1: [[50, 50]], 2: [[33, 33], [67, 67]], 3: [[24, 24], [50, 50], [76, 76]] } },
167
- "bend-sinister": { size: 18, counts: { 1: [[50, 50]], 2: [[67, 33], [33, 67]], 3: [[76, 24], [50, 50], [24, 76]] } },
168
- chevron: { size: 15, counts: { 1: [[50, 36]], 3: [[20, 65], [50, 36], [80, 65]] } },
169
- cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
170
- saltire: { size: 16, counts: { 1: [[50, 45]], 5: [[22, 19.8], [78, 19.8], [50, 45], [22, 70.2], [78, 70.2]] } },
171
- pall: { size: 16, counts: { 1: [[50, 40]], 3: [[26, 20.8], [74, 20.8], [50, 75]] } },
172
- pile: { size: 14, counts: { 1: [[50, 25]], 3: [[50, 14], [50, 34], [50, 54]] } },
211
+ pale: { size: 20, counts: {
212
+ 1: { points: [[50, 45]] },
213
+ 2: { points: [[50, 28], [50, 62]] },
214
+ 3: { points: [[50, 18], [50, 45], [50, 72]] },
215
+ 4: { size: 18, points: [[50, 13], [50, 35], [50, 57], [50, 79]] },
216
+ 5: { size: 15, points: [[50, 12], [50, 29], [50, 46], [50, 63], [50, 80]] } } },
217
+ fess: { size: 22, counts: {
218
+ 1: { points: [[50, 45]] },
219
+ 2: { points: [[30, 45], [70, 45]] },
220
+ 3: { points: [[20, 45], [50, 45], [80, 45]] },
221
+ 4: { size: 18, points: [[17, 45], [39, 45], [61, 45], [83, 45]] },
222
+ 5: { size: 15, points: [[14, 45], [32, 45], [50, 45], [68, 45], [86, 45]] } } },
223
+ bend: { size: 18, counts: {
224
+ 1: { points: [[50, 50]] },
225
+ 2: { points: [[33, 33], [67, 67]] },
226
+ 3: { points: [[24, 24], [50, 50], [76, 76]] },
227
+ 4: { size: 16, points: [[22, 22], [40.7, 40.7], [59.3, 59.3], [78, 78]] },
228
+ 5: { size: 12, points: [[17, 17], [32, 32], [47, 47], [62, 62], [77, 77]] } } },
229
+ "bend-sinister": { size: 18, counts: {
230
+ 1: { points: [[50, 50]] },
231
+ 2: { points: [[67, 33], [33, 67]] },
232
+ 3: { points: [[76, 24], [50, 50], [24, 76]] },
233
+ 4: { size: 16, points: [[78, 22], [59.3, 40.7], [40.7, 59.3], [22, 78]] },
234
+ 5: { size: 12, points: [[83, 17], [68, 32], [53, 47], [38, 62], [23, 77]] } } },
235
+ chevron: { size: 15, counts: {
236
+ 1: { points: [[50, 36]] },
237
+ 2: { points: [[28, 57], [72, 57]] },
238
+ 3: { points: [[20, 65], [50, 36], [80, 65]] },
239
+ 5: { size: 13, points: [[50, 36], [35, 51], [65, 51], [20, 66], [80, 66]] } } },
240
+ cross: { size: 15, counts: {
241
+ 1: { points: [[50, 45]] },
242
+ 4: { points: [[50, 15], [18, 45], [82, 45], [50, 80]] },
243
+ 5: { points: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } } },
244
+ saltire: { size: 16, counts: {
245
+ 1: { points: [[50, 45]] },
246
+ 4: { points: [[22, 19.8], [78, 19.8], [22, 70.2], [78, 70.2]] },
247
+ 5: { points: [[22, 19.8], [78, 19.8], [50, 45], [22, 70.2], [78, 70.2]] } } },
248
+ pall: { size: 16, counts: {
249
+ 1: { points: [[50, 40]] },
250
+ 3: { points: [[26, 20.8], [74, 20.8], [50, 75]] } } },
251
+ pile: { size: 14, counts: {
252
+ 1: { points: [[50, 25]] },
253
+ 3: { points: [[50, 14], [50, 34], [50, 54]] } } },
173
254
  };
174
255
 
175
256
  const mirror = (points) => points.map(([x, y]) => [x, 100 - y]);
@@ -185,7 +266,7 @@ for (const ordinary of REVERSIBLE) {
185
266
  };
186
267
  ON[reversed(ordinary)] = {
187
268
  size: ON[ordinary].size,
188
- counts: mapValues(ON[ordinary].counts, mirror),
269
+ counts: mapValues(ON[ordinary].counts, (count) => ({ ...count, points: mirror(count.points) })),
189
270
  };
190
271
  }
191
272
 
@@ -208,7 +289,7 @@ export const BETWEEN_LINED = {
208
289
  indented: { points: [[70, 26], [24, 72]] },
209
290
  },
210
291
  chevron: {
211
- embattled: { counts: { 6: { size: 10, points: [[16, 13], [50, 8], [84, 13], [40, 72], [60, 72], [50, 84]] } } },
292
+ embattled: { counts: { 6: { size: 10, points: [[16, 13], [50, 8], [84, 13], [40, 77], [60, 77], [50, 89]] } } },
212
293
  },
213
294
  "chevron-reversed": {
214
295
  embattled: { points: [[26, 76], [74, 76], [50, 24]] },
package/divisions.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import plugin from "tailwindcss/plugin";
5
5
  import { FURS, TINCTURES } from "./data.js";
6
6
  import { furLayers } from "./furs.js";
7
+ import { PATHS } from "./paths.js";
7
8
 
8
9
  // The fess point, in % down the shield: horizontal lines of partition pass through it, as the
9
10
  // cross and fess do.
@@ -26,16 +27,40 @@ const stripes = (to, pieces) => (a, b) => {
26
27
  };
27
28
  };
28
29
 
29
- // How much of the corner-to-corner extent the shield covers: the base corner is cut away by the
30
- // border-radius of 0 0 50% 50% / 0 0 45% 45% and the 5:6 aspect ratio in grailwind.css. It runs
31
- // from the chief corner the bands start in to the tangent on the base curve.
32
- const BENDY_VISIBLE = (() => {
33
- const [width, height] = [5, 6];
34
- const [radiusX, radiusY] = [width / 2, 0.45 * height];
35
- const [centreX, centreY] = [width / 2, height - radiusY];
36
- const tangent = Math.sqrt((height * radiusX) ** 2 + (width * radiusY) ** 2);
37
- return (width * height - height * centreX + width * centreY + tangent) / (2 * width * height);
38
- })();
30
+ /**
31
+ * Points along a drawn outline's curves and lines, densely, in fractions of its box.
32
+ * @param {Array[]} segments The drawing's segments, as in paths.js, in real units (100 × 120).
33
+ * @returns {number[][]} [x, y] points, each 0 to 1.
34
+ */
35
+ const outlineFractions = (segments) => {
36
+ const points = [];
37
+ let from = [0, 0];
38
+ for (const [command, ...n] of segments) {
39
+ if (command === "M" || command === "L") {
40
+ from = [n[0], n[1]];
41
+ points.push(from);
42
+ } else if (command === "C") {
43
+ for (let i = 1; i <= 1000; i++) {
44
+ const t = i / 1000;
45
+ const [a, b, c, d] = [(1 - t) ** 3, 3 * (1 - t) ** 2 * t, 3 * (1 - t) * t ** 2, t ** 3];
46
+ points.push([
47
+ a * from[0] + b * n[0] + c * n[2] + d * n[4],
48
+ a * from[1] + b * n[1] + c * n[3] + d * n[5],
49
+ ]);
50
+ }
51
+ from = [n[4], n[5]];
52
+ } else if (command !== "Z") {
53
+ throw new Error(`bendy can't read ${command} in the heater's outline`);
54
+ }
55
+ }
56
+ return points.map(([x, y]) => [x / 100, y / 120]);
57
+ };
58
+
59
+ // How much of the corner-to-corner extent the shield covers, as the heater's base cuts the far
60
+ // corner away. On the 5:6 shield, a point's distance along the extent is (x + y) / 2 in fractions
61
+ // of the width and height, so it runs from the chief corner the bands start in to where a band's
62
+ // line is tangent to the base (drawings/heater.svg).
63
+ const BENDY_VISIBLE = Math.max(...outlineFractions(PATHS.heater).map(([x, y]) => (x + y) / 2));
39
64
 
40
65
  // Diagonal bands parallel to per bend, `pieces` equal ones across the visible part of the shield,
41
66
  // the last running to the edge. Explicit stops, not a repeating gradient, so a canton's square
@@ -295,6 +320,17 @@ const field = (layers, color) =>
295
320
  "--field",
296
321
  );
297
322
 
323
+ /**
324
+ * A field's first tincture, and the shield's, for a bordure ring of the first (see grailwind.css).
325
+ * Only the heater sets it, as an inescutcheon's "of the first" is the shield's.
326
+ * @param {string} colour The first tincture's CSS colour.
327
+ * @returns {Record<string, unknown>} The declarations, with the heater's as a nested rule.
328
+ */
329
+ const firstTincture = (colour) => ({
330
+ "--field-first": colour,
331
+ ".heater:has(> &)": { "--frame-first": colour },
332
+ });
333
+
298
334
  // The field with its tinctures swapped, for counterchanged ordinaries to paint.
299
335
  const counterchange = (rules) =>
300
336
  Object.fromEntries(
@@ -321,7 +357,7 @@ export default plugin(({ addUtilities, theme }) => {
321
357
  ...unswapped,
322
358
  ...field(toLayers(unswapped), unswapped["background-color"]),
323
359
  ...counterchange(background(b, a)),
324
- "--field-first": a,
360
+ ...firstTincture(a),
325
361
  };
326
362
  }
327
363
  }
@@ -341,7 +377,7 @@ export default plugin(({ addUtilities, theme }) => {
341
377
  ...joinLayers(layers, "background"),
342
378
  ...field(layers),
343
379
  ...counterchange(withFur(division, second, first, theme)),
344
- ...(!FURS[first] && { "--field-first": `var(--color-${first})` }),
380
+ ...(!FURS[first] && firstTincture(`var(--color-${first})`)),
345
381
  };
346
382
  }
347
383
  }