grailwind 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/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@ 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
+
53
+ ## [0.8.1] - 2026-11-13
54
+
55
+ ### Fixed
56
+
57
+ - Counterchanged charges `on` a counterchanged ordinary showed the ordinary's
58
+ swapped field and vanished. They now show the field's own tinctures.
59
+ - `chevronny-*` in a part of a split shield (a `per-pale` half, a `per-fess`
60
+ chief) broke at the centre line. It now follows the part, as `per-chevron`
61
+ does, and the arms meet.
62
+
9
63
  ## [0.8.0] - 2026-11-12
10
64
 
11
65
  ### Added
@@ -183,7 +237,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
183
237
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
184
238
  custom SVG charges.
185
239
 
186
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.8.0...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
242
+ [0.8.1]: https://github.com/pafirmin/grailwind/compare/v0.8.0...v0.8.1
187
243
  [0.8.0]: https://github.com/pafirmin/grailwind/compare/v0.7.0...v0.8.0
188
244
  [0.7.0]: https://github.com/pafirmin/grailwind/compare/v0.6.0...v0.7.0
189
245
  [0.6.0]: https://github.com/pafirmin/grailwind/compare/v0.5.0...v0.6.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;
@@ -149,17 +152,29 @@ const inheritCounterchange = Object.fromEntries(
149
152
  COUNTERCHANGE.map((n) => [`--counterchange-${n}`, "inherit"]),
150
153
  );
151
154
 
152
- // A layer the size of the shield, as the escutcheon is the containing block
153
- // for fixed positioning. The charge's mask cuts it to shape.
155
+ // What a charge on a counterchanged ordinary is counterchanged against: the field's own
156
+ // tinctures, handed down by the ordinary to `on` containers only.
157
+ const inheritOnCounterchange = Object.fromEntries(
158
+ COUNTERCHANGE.map((n) => [`--on-counterchange-${n}`, "inherit"]),
159
+ );
160
+
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.
154
164
  const counterchanged = {
155
165
  ...inheritCounterchange,
166
+ ...inheritOnCounterchange,
156
167
  "&::before": {
157
168
  content: '""',
158
169
  position: "fixed",
159
- inset: "0",
170
+ inset: "var(--layer-inset, 0px)",
160
171
  ...inheritCounterchange,
172
+ ...inheritOnCounterchange,
161
173
  ...Object.fromEntries(
162
- COUNTERCHANGE.map((n) => [`background-${n}`, `var(--counterchange-${n})`]),
174
+ COUNTERCHANGE.map((n) => [
175
+ `background-${n}`,
176
+ `var(--on-counterchange-${n}, var(--counterchange-${n}))`,
177
+ ]),
163
178
  ),
164
179
  },
165
180
  };
@@ -176,6 +191,15 @@ const atPoint = (name) => {
176
191
  : place([left, top], POINT_SIZE);
177
192
  };
178
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
+
179
203
  const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
180
204
 
181
205
  export default plugin(({ addUtilities, matchUtilities }) => {
@@ -200,7 +224,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
200
224
  const charges = {
201
225
  position: "absolute",
202
226
  inset: "var(--field-top, 0%) 0 0",
203
- // 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).
204
228
  "z-index": "1",
205
229
  ...inheritCounterchange,
206
230
  "& > *": {
@@ -230,8 +254,11 @@ export default plugin(({ addUtilities, matchUtilities }) => {
230
254
  utilities[`.${name}`] = arrangement;
231
255
  }
232
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
+
233
260
  for (const [name, counts] of Object.entries(ALONG)) {
234
- const arrangement = {};
261
+ const arrangement = name === "in-chief" ? { "&.charges": onChief } : {};
235
262
  for (const [count, points] of Object.entries(counts)) {
236
263
  points.forEach((point, i) => {
237
264
  arrangement[`&${hasCount(count)} > :nth-child(${i + 1})`] = atPoint(point);
@@ -249,8 +276,12 @@ export default plugin(({ addUtilities, matchUtilities }) => {
249
276
  "--charge-width": "var(--charge-size, var(--between-size))",
250
277
  };
251
278
  }
252
- // The counts some ordinary overrides in BETWEEN's `counts` (see data.js).
253
- 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) {
254
285
  for (let i = 1; i <= count; i++) {
255
286
  between[`&${hasCount(count)} > :nth-child(${i})`] = {
256
287
  left: `var(--between-${count}-${i}-x, var(--between-${i}-x))`,
@@ -264,7 +295,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
264
295
  // Falls back to the plain-field default when the ordinary has no points for this count.
265
296
  // An undefined var() would otherwise unset `left`/`top` instead of deferring to the :where() default.
266
297
  const on = {};
267
- for (const count of [1, 2, 3, 5]) {
298
+ for (const count of countKeys(Object.values(ON))) {
268
299
  const fallback = DEFAULTS[count];
269
300
  for (let i = 1; i <= count; i++) {
270
301
  const [left, top] = fallback?.points[i - 1] ?? [];
@@ -275,14 +306,18 @@ export default plugin(({ addUtilities, matchUtilities }) => {
275
306
  on[`&${hasCount(count)} > :nth-child(${i})`] = {
276
307
  left: at("x", left),
277
308
  top: at("y", top),
278
- "--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}%)))`,
279
310
  };
280
311
  }
281
312
  }
282
- utilities[".on"] = on;
313
+ // Nested, so Tailwind keeps these apart from the per-count rules above.
314
+ utilities[".on"] = { "&": inheritOnCounterchange, ...on };
283
315
 
284
316
  for (const name of Object.keys(POINTS)) {
285
- utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
317
+ utilities[`.in-${name}`] = {
318
+ ".charges > &": atPoint(name),
319
+ ...(CHIEF_POINTS.has(name) && { ".charges:has(> &)": onChief }),
320
+ };
286
321
  }
287
322
 
288
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
@@ -50,31 +75,36 @@ const bendy = (to, pieces) => (a, b) => {
50
75
  return layer(`linear-gradient(to ${to}, ${stops.join(", ")})`);
51
76
  };
52
77
 
53
- // The direction of `to bottom right` on the shield (aspect-ratio 5 / 6 in grailwind.css), so
54
- // chevronny's lines run parallel to per chevron's, the field's diagonals.
55
- const CHEVRON_ANGLE = 180 - (Math.atan(6 / 5) * 180) / Math.PI;
56
-
57
- // A half box is 75% as long as the whole along this angle (45/sqrt(61) over 60/sqrt(61)).
58
- // Plain, the sinister half sits over a full-width dexter layer, which hides the centre seam.
59
- // With a fur the layers can't overlap, as the transparent stripes would show the layer below.
78
+ // Four tiles, each half the box, so the lines follow the box's own proportions: a part of the
79
+ // shield is the whole one squeezed to fit, as per chevron's are. A tile's lines run parallel to its
80
+ // diagonal, dexter tiles `to bottom right` and sinister ones `to bottom left`.
81
+ // The period is 300/n of a tile's gradient line, which keeps a stripe's width on the whole shield.
82
+ // A lower tile starts half a gradient line further on, so its lines continue the upper tile's.
83
+ // Plain, the dexter and lower tiles overlap the others by 1px, hiding the seams. With a fur the
84
+ // transparent stripes would show the tile beneath, so they butt.
60
85
  const chevronny = (pieces) => (a, b, fur) => {
61
- const stripe = (angle, length) =>
62
- `repeating-linear-gradient(${angle}deg, ${a} 0 calc(${length}% / ${pieces}), ${b} 0 calc(${2 * length}% / ${pieces}))`;
63
- const sinister = stripe(-CHEVRON_ANGLE, 100);
64
-
65
- return fur
66
- ? {
67
- "background-image": [stripe(CHEVRON_ANGLE, 100), sinister].join(", "),
68
- "background-size": "50% 100%",
69
- "background-position": "left, right",
70
- "background-repeat": "no-repeat",
71
- }
72
- : {
73
- "background-image": [sinister, stripe(CHEVRON_ANGLE, 75)].join(", "),
74
- "background-size": "50% 100%, 100% 100%",
75
- "background-position": "right, left",
76
- "background-repeat": "no-repeat",
77
- };
86
+ const period = 300 / pieces;
87
+ const lowerStart = (period - (50 % period)) % period;
88
+ const stripe = (to, start) =>
89
+ `repeating-linear-gradient(to bottom ${to}, ${a} ${start}% ${start + period / 2}%, ${b} 0 ${start + period}%)`;
90
+ const overlap = fur ? "50%" : "calc(50% + 1px)";
91
+
92
+ return {
93
+ "background-image": [
94
+ stripe("left", 0),
95
+ stripe("left", lowerStart),
96
+ stripe("right", 0),
97
+ stripe("right", lowerStart),
98
+ ].join(", "),
99
+ "background-size": [
100
+ `50% 50%`,
101
+ `50% ${overlap}`,
102
+ `${overlap} 50%`,
103
+ `${overlap} ${overlap}`,
104
+ ].join(", "),
105
+ "background-position": "right top, right bottom, left top, left bottom",
106
+ "background-repeat": "no-repeat",
107
+ };
78
108
  };
79
109
 
80
110
  const DIVISIONS = {
@@ -225,21 +255,81 @@ const toLayers = (rules) => {
225
255
  });
226
256
  };
227
257
 
228
- // A division with one fur: the fur's part is left transparent, over the fur's own layers.
229
- const withFur = (division, first, second, theme) => {
258
+ /**
259
+ * A division with one fur: the fur's part is left transparent, over the fur's own layers.
260
+ * @param {string} division The division's name.
261
+ * @param {string} first The first tincture or fur.
262
+ * @param {string} second The second tincture or fur; one of the two is a fur.
263
+ * @param {Function} theme Tailwind's theme function, for the spots' colour.
264
+ * @returns {{image: string, size: string, position: string, repeat: string}[]} The layers, top first.
265
+ */
266
+ const furredLayers = (division, first, second, theme) => {
230
267
  const fur = FURS[first] ? first : second;
231
268
  const paint = (t) => (t === fur ? "transparent" : `var(--color-${t})`);
232
- const all = [
269
+ return [
233
270
  ...toLayers(DIVISIONS[division](paint(first), paint(second), true)),
234
271
  ...furLayers(fur, theme),
235
272
  ];
236
- return Object.fromEntries(
273
+ };
274
+
275
+ /**
276
+ * Layers joined into one comma-separated value per property.
277
+ * @param {{image: string, size: string, position: string, repeat: string}[]} layers
278
+ * @param {string} prefix The property prefix, such as "background" or "--field".
279
+ * @returns {Object<string, string>} `<prefix>-image`, `-size`, `-position` and `-repeat`.
280
+ */
281
+ const joinLayers = (layers, prefix) =>
282
+ Object.fromEntries(
237
283
  ["image", "size", "position", "repeat"].map((p) => [
238
- `background-${p}`,
239
- all.map((layer) => layer[p]).join(", "),
284
+ `${prefix}-${p}`,
285
+ layers.map((layer) => layer[p]).join(", "),
240
286
  ]),
241
287
  );
242
- };
288
+
289
+ /**
290
+ * A division with one fur, as background rules.
291
+ * @param {string} division The division's name.
292
+ * @param {string} first The first tincture or fur.
293
+ * @param {string} second The second tincture or fur; one of the two is a fur.
294
+ * @param {Function} theme Tailwind's theme function, for the spots' colour.
295
+ * @returns {Object<string, string>} The four background-* properties.
296
+ */
297
+ const withFur = (division, first, second, theme) =>
298
+ joinLayers(furredLayers(division, first, second, theme), "background");
299
+
300
+ /**
301
+ * The field's own layers as --field-* variables, for charges on a counterchanged ordinary.
302
+ * A background colour becomes a last opaque layer, as the variables carry no colour.
303
+ * @param {{image: string, size: string, position: string, repeat: string}[]} layers
304
+ * @param {string} [color] The division's background-color, if it has one.
305
+ * @returns {Object<string, string>} The four --field-* variables.
306
+ */
307
+ const field = (layers, color) =>
308
+ joinLayers(
309
+ color
310
+ ? [
311
+ ...layers,
312
+ {
313
+ image: `linear-gradient(${color}, ${color})`,
314
+ size: "100% 100%",
315
+ position: "0 0",
316
+ repeat: "no-repeat",
317
+ },
318
+ ]
319
+ : layers,
320
+ "--field",
321
+ );
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
+ });
243
333
 
244
334
  // The field with its tinctures swapped, for counterchanged ordinaries to paint.
245
335
  const counterchange = (rules) =>
@@ -262,10 +352,12 @@ export default plugin(({ addUtilities, theme }) => {
262
352
 
263
353
  const a = `var(--color-${first})`;
264
354
  const b = `var(--color-${second})`;
355
+ const unswapped = background(a, b);
265
356
  utilities[`.${division}-${first}-${second}`] = {
266
- ...background(a, b),
357
+ ...unswapped,
358
+ ...field(toLayers(unswapped), unswapped["background-color"]),
267
359
  ...counterchange(background(b, a)),
268
- "--field-first": a,
360
+ ...firstTincture(a),
269
361
  };
270
362
  }
271
363
  }
@@ -279,11 +371,13 @@ export default plugin(({ addUtilities, theme }) => {
279
371
  [fur, tincture],
280
372
  [tincture, fur],
281
373
  ]) {
374
+ const layers = furredLayers(division, first, second, theme);
282
375
  // Only a tincture can be "of the first"; a fur first leaves it unset.
283
376
  utilities[`.${division}-${first}-${second}`] = {
284
- ...withFur(division, first, second, theme),
377
+ ...joinLayers(layers, "background"),
378
+ ...field(layers),
285
379
  ...counterchange(withFur(division, second, first, theme)),
286
- ...(!FURS[first] && { "--field-first": `var(--color-${first})` }),
380
+ ...(!FURS[first] && firstTincture(`var(--color-${first})`)),
287
381
  };
288
382
  }
289
383
  }