grailwind 0.4.0 → 0.6.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,73 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - Crosses and the shakefork as charges: `cross-couped`, `cross-potent`,
14
+ `cross-pattee`, `maltese-cross`, `cross-crosslet`, `cross-moline`,
15
+ `cross-fleury` and `shakefork`.
16
+ - `semy`: a semé, one built-in charge strewn across the field, e.g. bezanty or
17
+ crusilly.
18
+ - Voided ordinaries, e.g. `cross-voided-argent`: the field shows through.
19
+ `cross-voided-counterchanged` and the like counterchange the outline.
20
+ - Reversed ordinaries: `pile-reversed-*`, `chevron-reversed-*` and
21
+ `pall-reversed-*`, upside down.
22
+
23
+ ### Changed
24
+
25
+ - `@grailwind/eslint-plugin` 0.1.0-beta.2 needs `grailwind` 0.6.x, for voided
26
+ ordinaries.
27
+
28
+ ### Fixed
29
+
30
+ - Charges, a canton or an inescutcheon in a quarter (or a half of `per-pale` or
31
+ `per-fess`) are placed and counterchanged within it, under the shield's own
32
+ ordinary, rather than against the whole shield.
33
+
34
+ ## [0.5.0] - 2026-11-04
35
+
36
+ ### Added
37
+
38
+ - `in-chief` and `in-base`: one to three charges along the chief or base. On a
39
+ chief, they sit on it. The ESLint plugin reports an arrangement put on a
40
+ charge rather than its container.
41
+ - `-of-the-first`: the field's first tincture, as in a blazon, e.g.
42
+ `pale-of-the-first`. The ESLint plugin checks it.
43
+ - Striped fields: `barry-*` and `paly-*` (of six), `barry-of-eight-*`,
44
+ `barry-of-ten-*` and `paly-of-eight-*`. The first tincture is in chief or
45
+ on the dexter. They take furs and counterchanged, like the other divisions.
46
+ - `per-saltire-*`: a field divided on both diagonals, the first tincture in
47
+ chief and base.
48
+ - Bars, named by count: `bar-*`, `two-bars-*` and `three-bars-*`. They take
49
+ lines, furs, fimbriation and counterchanged, like the fess.
50
+ - `pile-*`: a wedge from the chief to a point. It takes lines, furs,
51
+ fimbriation and counterchanged, one or three charges `on` it, and two
52
+ `between`.
53
+ - `canton`: a ninth of the shield in dexter chief, over every ordinary and
54
+ charge, with its own field, divisions, ordinaries and charges, like the
55
+ inescutcheon.
56
+ - Six charges `between` a fess, bend, bend sinister, chevron or pale.
57
+ - `inescutcheon`: a small shield at the centre of the arms, over everything
58
+ else, with its own field, ordinaries and charges. A bordure on it is a third
59
+ as wide.
60
+ - The ESLint plugin checks a plain canton or inescutcheon against the field it
61
+ lies on. One with anything on it counts as arms of its own.
62
+
63
+ ### Changed
64
+
65
+ - The `in-chief` and `in-base` points are now `in-chief-point` and
66
+ `in-base-point`, like the honour, fess and nombril points.
67
+ - The canton and the chief surmount a bordure, as in heraldry. The canton is
68
+ still a third of the whole shield, and the chief's lower edge doesn't move.
69
+ - `@grailwind/eslint-plugin` 0.1.0-beta.1 needs `grailwind` 0.5.x, for the
70
+ renamed points.
71
+
72
+ ### Fixed
73
+
74
+ - Two charges `between` a fess, saltire or pall are placed one each side.
75
+
9
76
  ## [0.4.0] - 2026-11-03
10
77
 
11
78
  ### Added
@@ -71,7 +138,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
71
138
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
72
139
  custom SVG charges.
73
140
 
74
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.4.0...HEAD
141
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.6.0...HEAD
142
+ [0.6.0]: https://github.com/pafirmin/grailwind/compare/v0.5.0...v0.6.0
143
+ [0.5.0]: https://github.com/pafirmin/grailwind/compare/v0.4.0...v0.5.0
75
144
  [0.4.0]: https://github.com/pafirmin/grailwind/compare/v0.3.0...v0.4.0
76
145
  [0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
77
146
  [0.2.0]: https://github.com/pafirmin/grailwind/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Grailwind CSS
2
2
 
3
+ [![Test](https://github.com/pafirmin/grailwind/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/pafirmin/grailwind/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/grailwind)](https://www.npmjs.com/package/grailwind)
5
+ [![ESLint plugin](https://img.shields.io/npm/v/@grailwind/eslint-plugin/beta?label=eslint-plugin)](https://www.npmjs.com/package/@grailwind/eslint-plugin)
6
+ [![License: MIT](https://img.shields.io/npm/l/grailwind)](https://github.com/pafirmin/grailwind/blob/main/LICENSE)
7
+
3
8
  ![Eight coats of arms drawn with Grailwind: chevrons, quarters, fleurs-de-lis, a saltire, gyronny, a chief, a bend and a bordure](https://grailwind.dev/preview.png)
4
9
 
5
10
  Heraldic blazon for [Tailwind CSS](https://tailwindcss.com). Draw coats of
@@ -49,18 +54,23 @@ tinctures replace Tailwind's colours entirely.
49
54
  - **Tinctures:** a tincture on its own sets the background (`azure`), and
50
55
  Tailwind's prefixes work as usual (`text-or`). There are also the furs `ermine`,
51
56
  `ermines`, `erminois` and `pean`, which work in divisions too.
52
- - **The shield:** `escutcheon`, plus `bordure-*`.
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.
53
59
  - **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
54
- `gyronny-or-sable` and more, or `per-pale`, `per-fess` and `quarterly` for
60
+ `gyronny-or-sable`, `barry-argent-azure` and more, or `per-pale`, `per-fess` and `quarterly` for
55
61
  layout.
56
- - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`
57
- and more, with `fimbriated-*` for a narrow border, `fess-engrailed-or` and
58
- the like for an engrailed or invected edge, `chief-ermine` and the like in a
59
- fur, or `chevron-counterchanged` and the like on a divided field.
60
- - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
62
+ - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`,
63
+ `two-bars-or` and more, with `fimbriated-*` for a narrow border,
64
+ `fess-engrailed-or` and the like for an engrailed or invected edge,
65
+ `cross-voided-argent` and the like to show the field through it,
66
+ `pile-reversed-or` (also the chevron and pall) to turn one upside down,
67
+ `chief-ermine` and the like in a fur, or `chevron-counterchanged` and the like
68
+ on a divided field.
69
+ - **Charges:** `mullet`, `fleur-de-lis`, `bezant`, crosses and more, arranged by
61
70
  number, `in-pale`, `in-fess`, `in-bend`, `in-bend-sinister`, `on` or `between`
62
- the ordinary, or placed at a named point such as `in-dexter-chief`, sized
63
- with `charge-size-*`, and `counterchanged` on a divided field.
71
+ the ordinary, or placed at a named point such as `in-dexter-chief`, strewn
72
+ `semy` across the field, sized with `charge-size-*`, and `counterchanged` on
73
+ a divided field.
64
74
  - **Your own charges:** any SVG, via `charge-[url(…)]`.
65
75
 
66
76
  ## Linting
package/charges.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
5
  import { POINTS, ROUNDELS } from "./data.js";
6
+ import { AT } from "./furs.js";
6
7
 
7
8
  const star = (cx, cy, r, points = 5) => {
8
9
  const coords = [];
@@ -14,6 +15,20 @@ const star = (cx, cy, r, points = 5) => {
14
15
  return `<polygon points="${coords.join(" ")}"/>`;
15
16
  };
16
17
 
18
+ // One arm drawn upward from the centre, repeated x times evenly about (50, 50).
19
+ const rotateX = (d, x) => {
20
+ const path = (d, deg) => `<path d="${d}" transform="rotate(${deg} 50 50)" />`;
21
+
22
+ const paths = [];
23
+ const rotation = 360 / x;
24
+ let curr = 0;
25
+ for (let i = 0; i < x; i++) {
26
+ paths.push(path(d, curr));
27
+ curr += rotation;
28
+ }
29
+ return paths.join("");
30
+ };
31
+
17
32
  const SHAPES = {
18
33
  roundel: `<circle cx="50" cy="50" r="46"/>`,
19
34
  annulet: `<circle cx="50" cy="50" r="39" fill="none" stroke="#000" stroke-width="14"/>`,
@@ -29,6 +44,20 @@ const SHAPES = {
29
44
  <path d="M45 65H55C55 74 53 82 50 90C47 82 45 74 45 65Z"/>
30
45
  <path d="M58 65C62 74 72 80 84 78C80 88 64 90 56 74Z"/>
31
46
  <path d="M42 65C38 74 28 80 16 78C20 88 36 90 44 74Z"/>`,
47
+ "cross-couped": rotateX("M39 50V2H61V50Z", 4),
48
+ "cross-potent": rotateX("M43 50V16H31V2H69V16H57V50Z", 4),
49
+ "cross-pattee": rotateX("M40 50V40Q36 20 22 2H78Q64 20 60 40V50Z", 4),
50
+ "maltese-cross": rotateX("M50 50L30 2L50 18L70 2Z", 4),
51
+ "cross-crosslet": rotateX("M43 50V25H32V13H43V2H57V13H68V25H57V50Z", 4),
52
+ "cross-moline": rotateX(
53
+ "M43 50V18C42 12 38 11 33 16C31 6 40 -1 50 7C60 -1 69 6 67 16C62 11 58 12 57 18V50Z",
54
+ 4,
55
+ ),
56
+ "cross-fleury": rotateX(
57
+ "M43 50V21C41 17 37 16 33 20C30 12 37 8 45 13C45 8 47 5 50 2C53 5 55 8 55 13C63 8 70 12 67 20C63 16 59 17 57 21V50Z",
58
+ 4,
59
+ ),
60
+ shakefork: `<polygon points="50,96 39,85 39,43 12.6,20.9 11.3,5.4 26.8,4 50,23.5 73.2,4 88.7,5.4 87.4,20.9 61,43 61,85"/>`,
32
61
  };
33
62
 
34
63
  // Points as [left%, top%] of the shield, plus a charge size as % of its width.
@@ -46,7 +75,22 @@ const DEFAULTS = {
46
75
  const POINT_SIZE = 22;
47
76
 
48
77
  // With a chief on the shield, these points sit on it (see ordinaries.js).
49
- const CHIEF_POINTS = new Set(["dexter-chief", "chief", "sinister-chief"]);
78
+ const CHIEF_POINTS = new Set(["dexter-chief", "chief-point", "sinister-chief"]);
79
+
80
+ // Arrangements along the chief or base, by the points each count of charges takes.
81
+ // prettier-ignore
82
+ const ALONG = {
83
+ "in-chief": {
84
+ 1: ["chief-point"],
85
+ 2: ["dexter-chief", "sinister-chief"],
86
+ 3: ["dexter-chief", "chief-point", "sinister-chief"],
87
+ },
88
+ "in-base": {
89
+ 1: ["base-point"],
90
+ 2: ["dexter-base", "sinister-base"],
91
+ 3: ["dexter-base", "base-point", "sinister-base"],
92
+ },
93
+ };
50
94
 
51
95
  // prettier-ignore
52
96
  const ARRANGEMENTS = {
@@ -73,6 +117,19 @@ const svgUrl = (shape) =>
73
117
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shape}</svg>`,
74
118
  )}")`;
75
119
 
120
+ // Semé: whole charges across the field, staggered on the furs' tile (see furs.js), cut at the
121
+ // edges. The shape is defined once and reused, so a shape with its own ids stays valid.
122
+ const SEMY_ACROSS = 4;
123
+ const SEMY_SCALE = 0.25;
124
+ const SEMY_SHIFT = `${-100 / (4 * (SEMY_ACROSS - 1))}%`;
125
+ const semyTile = (shape) => {
126
+ const at = (x, y) => `translate(${x - 50 * SEMY_SCALE} ${y - 50 * SEMY_SCALE})`;
127
+ const uses = AT.map(([x, y]) => `<use href="#s" transform="${at(x, y)} scale(${SEMY_SCALE})"/>`);
128
+ return `url("data:image/svg+xml,${encodeURIComponent(
129
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 60"><defs><g id="s">${shape}</g></defs>${uses.join("")}</svg>`,
130
+ )}")`;
131
+ };
132
+
76
133
  const maskRules = (image) => ({
77
134
  "mask-image": image,
78
135
  "mask-size": "contain",
@@ -107,6 +164,18 @@ const counterchanged = {
107
164
  },
108
165
  };
109
166
 
167
+ // A charge at one of the shield's points. The chief's points sit on a chief when there is one.
168
+ const atPoint = (name) => {
169
+ const [left, top] = POINTS[name];
170
+ return CHIEF_POINTS.has(name)
171
+ ? {
172
+ left: `${left}%`,
173
+ top: `var(--chief-points-y, ${top}%)`,
174
+ "--charge-width": `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
175
+ }
176
+ : place([left, top], POINT_SIZE);
177
+ };
178
+
110
179
  const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
111
180
 
112
181
  export default plugin(({ addUtilities, matchUtilities }) => {
@@ -116,12 +185,13 @@ export default plugin(({ addUtilities, matchUtilities }) => {
116
185
  const utilities = {};
117
186
 
118
187
  for (const [name, shape] of Object.entries(SHAPES)) {
119
- utilities[`.${name}`] = maskRules(svgUrl(shape));
188
+ utilities[`.${name}`] = { ...maskRules(svgUrl(shape)), "--semy-image": semyTile(shape) };
120
189
  }
121
190
 
122
191
  for (const [name, tincture] of Object.entries(ROUNDELS)) {
123
192
  utilities[`.${name}`] = {
124
193
  ...maskRules(svgUrl(SHAPES.roundel)),
194
+ "--semy-image": semyTile(SHAPES.roundel),
125
195
  "background-color": `var(--color-${tincture})`,
126
196
  };
127
197
  }
@@ -160,6 +230,16 @@ export default plugin(({ addUtilities, matchUtilities }) => {
160
230
  utilities[`.${name}`] = arrangement;
161
231
  }
162
232
 
233
+ for (const [name, counts] of Object.entries(ALONG)) {
234
+ const arrangement = {};
235
+ for (const [count, points] of Object.entries(counts)) {
236
+ points.forEach((point, i) => {
237
+ arrangement[`&${hasCount(count)} > :nth-child(${i + 1})`] = atPoint(point);
238
+ });
239
+ }
240
+ utilities[`.${name}`] = arrangement;
241
+ }
242
+
163
243
  // Positions come from the ordinary on the shield (see ordinaries.js).
164
244
  const between = {};
165
245
  for (let i = 1; i <= 4; i++) {
@@ -169,6 +249,16 @@ export default plugin(({ addUtilities, matchUtilities }) => {
169
249
  "--charge-width": "var(--charge-size, var(--between-size))",
170
250
  };
171
251
  }
252
+ // The counts some ordinary overrides in BETWEEN's `counts` (see data.js).
253
+ for (const count of [2, 6]) {
254
+ for (let i = 1; i <= count; i++) {
255
+ between[`&${hasCount(count)} > :nth-child(${i})`] = {
256
+ left: `var(--between-${count}-${i}-x, var(--between-${i}-x))`,
257
+ top: `var(--between-${count}-${i}-y, var(--between-${i}-y))`,
258
+ "--charge-width": `var(--charge-size, var(--between-${count}-size, var(--between-size)))`,
259
+ };
260
+ }
261
+ }
172
262
  utilities[".between"] = between;
173
263
 
174
264
  // Falls back to the plain-field default when the ordinary has no points for this count.
@@ -191,18 +281,26 @@ export default plugin(({ addUtilities, matchUtilities }) => {
191
281
  }
192
282
  utilities[".on"] = on;
193
283
 
194
- for (const [name, [left, top]] of Object.entries(POINTS)) {
195
- utilities[`.in-${name}`] = {
196
- ".charges > &": CHIEF_POINTS.has(name)
197
- ? {
198
- left: `${left}%`,
199
- top: `var(--chief-points-y, ${top}%)`,
200
- "--charge-width": `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
201
- }
202
- : place([left, top], POINT_SIZE),
203
- };
284
+ for (const name of Object.keys(POINTS)) {
285
+ utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
204
286
  }
205
287
 
288
+ // The whole field, under the ordinaries and the chief. A custom charge has no tile, so it
289
+ // draws nothing: `none` would mean no mask, and flood the field.
290
+ utilities[".semy"] = {
291
+ "&.charges": { inset: "0", "z-index": "0" },
292
+ "&.charges > *": {
293
+ inset: "0",
294
+ width: "auto",
295
+ margin: "0",
296
+ "aspect-ratio": "auto",
297
+ "mask-image": "var(--semy-image, linear-gradient(transparent, transparent))",
298
+ "mask-size": `calc(100% / ${SEMY_ACROSS}) auto`,
299
+ "mask-repeat": "repeat",
300
+ "mask-position": `${SEMY_SHIFT} ${SEMY_SHIFT}`,
301
+ },
302
+ };
303
+
206
304
  // On a charge, or on `charges` for all of them.
207
305
  utilities[".counterchanged"] = {
208
306
  ".charges > &": counterchanged,
package/data.js CHANGED
@@ -5,19 +5,34 @@
5
5
  export const METALS = ["or", "argent"];
6
6
  export const COLOURS = ["gules", "azure", "vert", "purpure", "sable"];
7
7
  export const TINCTURES = [...METALS, ...COLOURS];
8
+ // Paints in the field's first tincture, as "of the first" in a blazon.
9
+ export const OF_THE_FIRST = "of-the-first";
8
10
 
9
11
  export const DIVISIONS = [
10
12
  "per-pale",
11
13
  "per-fess",
12
14
  "per-bend",
13
15
  "per-bend-sinister",
16
+ "per-saltire",
14
17
  "quarterly",
15
18
  "gyronny",
19
+ "barry",
20
+ "barry-of-eight",
21
+ "barry-of-ten",
22
+ "paly",
23
+ "paly-of-eight",
16
24
  ];
17
25
 
26
+ // Ordinaries that can be reversed, e.g. `pile-reversed-or`: the same shape upside down.
27
+ export const REVERSIBLE = ["chevron", "pall", "pile"];
28
+ const reversed = (ordinary) => `${ordinary}-reversed`;
29
+
18
30
  export const ORDINARIES = [
19
31
  "pale",
20
32
  "fess",
33
+ "bar",
34
+ "two-bars",
35
+ "three-bars",
21
36
  "bend",
22
37
  "bend-sinister",
23
38
  "chevron",
@@ -25,6 +40,8 @@ export const ORDINARIES = [
25
40
  "saltire",
26
41
  "pall",
27
42
  "chief",
43
+ "pile",
44
+ ...REVERSIBLE.map(reversed),
28
45
  ];
29
46
 
30
47
  export const CHARGES = [
@@ -35,6 +52,14 @@ export const CHARGES = [
35
52
  "billet",
36
53
  "crescent",
37
54
  "fleur-de-lis",
55
+ "cross-couped",
56
+ "cross-potent",
57
+ "cross-pattee",
58
+ "maltese-cross",
59
+ "cross-crosslet",
60
+ "cross-moline",
61
+ "cross-fleury",
62
+ "shakefork",
38
63
  ];
39
64
 
40
65
  // Roundels have their own names by tincture.
@@ -49,6 +74,21 @@ export const ROUNDELS = {
49
74
  // Lines of partition for an ordinary's edge, e.g. `fess-engrailed-or`.
50
75
  export const LINES = ["engrailed", "invected"];
51
76
 
77
+ // Ordinaries that can be voided, e.g. `cross-voided-argent`.
78
+ export const VOIDABLE = [
79
+ "pale",
80
+ "fess",
81
+ "bar",
82
+ "bend",
83
+ "bend-sinister",
84
+ "chevron",
85
+ "cross",
86
+ "saltire",
87
+ "pall",
88
+ "pile",
89
+ ...REVERSIBLE.map(reversed),
90
+ ];
91
+
52
92
  // Each fur's [ground, spot] tinctures.
53
93
  export const FURS = {
54
94
  ermine: ["argent", "sable"],
@@ -60,30 +100,48 @@ export const FURS = {
60
100
  // The points of the shield, as [left%, top%], for placing a single charge, e.g. `in-dexter-chief`.
61
101
  export const POINTS = {
62
102
  "dexter-chief": [25, 17],
63
- chief: [50, 15],
103
+ "chief-point": [50, 15],
64
104
  "sinister-chief": [75, 17],
65
105
  "honour-point": [50, 30],
66
106
  "fess-point": [50, 45],
67
107
  "nombril-point": [50, 60],
68
108
  "dexter-base": [30, 72],
69
- base: [50, 84],
109
+ "base-point": [50, 84],
70
110
  "sinister-base": [70, 72],
71
111
  };
72
112
 
73
113
  // Plain-field arrangements of a `charges` container, besides `between` and `on`.
74
- export const ARRANGEMENTS = ["in-pale", "in-fess", "in-bend", "in-bend-sinister"];
114
+ export const ARRANGEMENTS = [
115
+ "in-pale",
116
+ "in-fess",
117
+ "in-bend",
118
+ "in-bend-sinister",
119
+ "in-chief",
120
+ "in-base",
121
+ "semy",
122
+ ];
75
123
 
76
124
  // Where charges go for "between", as [left%, top%], plus their size as % of width.
125
+ // `counts` overrides the points (and optionally the size) for one number of charges.
77
126
  // prettier-ignore
78
127
  export const BETWEEN = {
79
- pale: { size: 18, points: [[18, 42], [82, 42]] },
80
- fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]] },
81
- bend: { size: 24, points: [[70, 26], [30, 68]] },
82
- "bend-sinister": { size: 24, points: [[30, 26], [70, 68]] },
83
- chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]] },
128
+ pale: { size: 18, points: [[18, 42], [82, 42]], counts: {
129
+ 6: { size: 14, points: [[18, 18], [18, 45], [18, 70], [82, 18], [82, 45], [82, 70]] } } },
130
+ fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]], counts: {
131
+ 2: { points: [[50, 15], [50, 78]] },
132
+ 6: { size: 14, points: [[20, 14], [50, 14], [80, 14], [32, 70], [68, 70], [50, 86]] } } },
133
+ bend: { size: 24, points: [[70, 26], [30, 68]], counts: {
134
+ 6: { size: 14, points: [[48, 9], [76, 12], [84, 38], [9, 48], [18, 72], [38, 84]] } } },
135
+ "bend-sinister": { size: 24, points: [[30, 26], [70, 68]], counts: {
136
+ 6: { size: 14, points: [[52, 9], [24, 12], [16, 38], [91, 48], [82, 72], [62, 84]] } } },
137
+ chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]], counts: {
138
+ 6: { size: 12, points: [[18, 14], [50, 11], [82, 14], [38, 66], [62, 66], [50, 84]] } } },
84
139
  cross: { size: 18, points: [[20, 17], [80, 17], [24, 72], [76, 72]] },
85
- saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]] },
86
- pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
140
+ saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]], counts: {
141
+ 2: { points: [[14, 50], [86, 50]] } } },
142
+ pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]], counts: {
143
+ 2: { points: [[18, 56], [82, 56]] } } },
144
+ pile: { size: 20, points: [[18, 55], [82, 55]] },
87
145
  };
88
146
 
89
147
  // Where charges go for "on", along the ordinary's centre line, by number of charges.
@@ -97,4 +155,30 @@ export const ON = {
97
155
  cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
98
156
  saltire: { size: 14, counts: { 1: [[50, 50]], 5: [[22, 22], [78, 22], [50, 50], [22, 78], [78, 78]] } },
99
157
  pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
158
+ pile: { size: 14, counts: { 1: [[50, 25]], 3: [[50, 14], [50, 34], [50, 54]] } },
159
+ };
160
+
161
+ const mirror = (points) => points.map(([x, y]) => [x, 100 - y]);
162
+ const mapValues = (object, fn) =>
163
+ Object.fromEntries(Object.entries(object).map(([key, value]) => [key, fn(value)]));
164
+
165
+ for (const ordinary of REVERSIBLE) {
166
+ const { size, points, counts = {} } = BETWEEN[ordinary];
167
+ BETWEEN[reversed(ordinary)] = {
168
+ size,
169
+ points: mirror(points),
170
+ counts: mapValues(counts, (count) => ({ ...count, points: mirror(count.points) })),
171
+ };
172
+ ON[reversed(ordinary)] = {
173
+ size: ON[ordinary].size,
174
+ counts: mapValues(ON[ordinary].counts, mirror),
175
+ };
176
+ }
177
+
178
+ // Mirrored, the lower charges would clip on the heater's curved flanks.
179
+ // prettier-ignore
180
+ BETWEEN["chevron-reversed"] = {
181
+ ...BETWEEN["chevron-reversed"],
182
+ points: [[28, 74], [72, 74], [50, 30]],
183
+ counts: { 6: { size: 12, points: [[26, 78], [50, 89], [74, 78], [38, 34], [62, 34], [50, 16]] } },
100
184
  };
package/divisions.js CHANGED
@@ -11,6 +11,17 @@ const FESS_POINT = "45%";
11
11
  // Each division maps (first, second) tincture colours to background rules.
12
12
  const layer = (image) => ({ "background-image": image });
13
13
 
14
+ // Equal stripes, the first tincture at the top (barry) or on the dexter (paly): one pair, tiled.
15
+ // The repeat must be explicit, as toLayers defaults to no-repeat for the fur variants.
16
+ const stripes = (to, pieces) => (a, b) => {
17
+ const pair = `calc(200% / ${pieces})`;
18
+ return {
19
+ "background-image": `linear-gradient(to ${to}, ${a} 50%, ${b} 50%)`,
20
+ "background-size": to === "bottom" ? `100% ${pair}` : `${pair} 100%`,
21
+ "background-repeat": to === "bottom" ? "repeat-y" : "repeat-x",
22
+ };
23
+ };
24
+
14
25
  const DIVISIONS = {
15
26
  // Vertical split: first on the dexter (viewer's left).
16
27
  "per-pale": (a, b) => layer(`linear-gradient(to right, ${a} 50%, ${b} 50%)`),
@@ -25,6 +36,21 @@ const DIVISIONS = {
25
36
  // Line from sinister chief to dexter base: first in the upper half.
26
37
  "per-bend-sinister": (a, b) => layer(`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`),
27
38
 
39
+ // Both diagonals, crossing at the centre like the saltire: first in chief and base.
40
+ // Gyronny's four tiles, with the top-right and bottom-left swapped.
41
+ "per-saltire": (a, b) => ({
42
+ "background-color": b,
43
+ "background-image": [
44
+ `linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
45
+ `linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
46
+ `linear-gradient(to bottom left, ${b} 50%, ${a} 50%)`,
47
+ `linear-gradient(to bottom right, ${b} 50%, ${a} 50%)`,
48
+ ].join(", "),
49
+ "background-size": "50% 50%",
50
+ "background-position": "left top, right top, right bottom, left bottom",
51
+ "background-repeat": "no-repeat",
52
+ }),
53
+
28
54
  // Quarters 1 and 4 (dexter chief, sinister base) are the first tincture.
29
55
  // Linear gradients rather than conic, which render jagged edges in some browsers.
30
56
  // With a fur, the bands can't overlap, or the fur's transparent half shows the band below.
@@ -50,6 +76,13 @@ const DIVISIONS = {
50
76
  "background-position": "left top, right top, right bottom, left bottom",
51
77
  "background-repeat": "no-repeat",
52
78
  }),
79
+
80
+ // Across the whole height, not through the fess point, so the shield's point clips the last bar.
81
+ barry: stripes("bottom", 6),
82
+ "barry-of-eight": stripes("bottom", 8),
83
+ "barry-of-ten": stripes("bottom", 10),
84
+ paly: stripes("right", 6),
85
+ "paly-of-eight": stripes("right", 8),
53
86
  };
54
87
 
55
88
  // A division's background rules as one { image, size, position, repeat } per layer.
@@ -108,6 +141,7 @@ export default plugin(({ addUtilities, theme }) => {
108
141
  utilities[`.${division}-${first}-${second}`] = {
109
142
  ...background(a, b),
110
143
  ...counterchange(background(b, a)),
144
+ "--field-first": a,
111
145
  };
112
146
  }
113
147
  }
@@ -121,9 +155,11 @@ export default plugin(({ addUtilities, theme }) => {
121
155
  [fur, tincture],
122
156
  [tincture, fur],
123
157
  ]) {
158
+ // Only a tincture can be "of the first"; a fur first leaves it unset.
124
159
  utilities[`.${division}-${first}-${second}`] = {
125
160
  ...withFur(division, first, second, theme),
126
161
  ...counterchange(withFur(division, second, first, theme)),
162
+ ...(!FURS[first] && { "--field-first": `var(--color-${first})` }),
127
163
  };
128
164
  }
129
165
  }
package/furs.js CHANGED
@@ -11,7 +11,7 @@ const SPOT = [
11
11
 
12
12
  // A 5:6 tile, the shield's own shape, so whole tiles fill it: a spot at its centre, and quarters of one at each
13
13
  // corner that join up with the neighbouring tiles.
14
- const AT = [
14
+ export const AT = [
15
15
  [25, 30],
16
16
  [0, 0],
17
17
  [50, 0],
package/grailwind.css CHANGED
@@ -42,6 +42,19 @@
42
42
  syntax: "*";
43
43
  inherits: false;
44
44
  }
45
+ /* A field's first tincture. The escutcheon copies it to --the-first, which inherits,
46
+ * so a canton's own field can't change it. */
47
+ @property --field-first {
48
+ syntax: "*";
49
+ inherits: false;
50
+ }
51
+ /* A field's own bordure width, 0 without one. The shield copies it to --outer-inset, which
52
+ * inherits, so a canton can step out over the shield's bordure and not its own. */
53
+ @property --bordure-inset {
54
+ syntax: "<length>";
55
+ inherits: false;
56
+ initial-value: 0px;
57
+ }
45
58
 
46
59
  /* ---------------------------------------------------------------------------
47
60
  * Tinctures
@@ -67,24 +80,35 @@
67
80
  * ------------------------------------------------------------------------- */
68
81
  @utility or {
69
82
  background-color: var(--color-or);
83
+ --field-first: var(--color-or);
70
84
  }
71
85
  @utility argent {
72
86
  background-color: var(--color-argent);
87
+ --field-first: var(--color-argent);
73
88
  }
74
89
  @utility gules {
75
90
  background-color: var(--color-gules);
91
+ --field-first: var(--color-gules);
76
92
  }
77
93
  @utility azure {
78
94
  background-color: var(--color-azure);
95
+ --field-first: var(--color-azure);
79
96
  }
80
97
  @utility vert {
81
98
  background-color: var(--color-vert);
99
+ --field-first: var(--color-vert);
82
100
  }
83
101
  @utility purpure {
84
102
  background-color: var(--color-purpure);
103
+ --field-first: var(--color-purpure);
85
104
  }
86
105
  @utility sable {
87
106
  background-color: var(--color-sable);
107
+ --field-first: var(--color-sable);
108
+ }
109
+ /* The field's first tincture, for charges and for a canton or inescutcheon field. */
110
+ @utility of-the-first {
111
+ background-color: var(--the-first, transparent);
88
112
  }
89
113
 
90
114
  /* ---------------------------------------------------------------------------
@@ -96,6 +120,13 @@
96
120
  display: grid;
97
121
  grid-auto-flow: column;
98
122
  grid-auto-columns: 1fr;
123
+
124
+ /* On a shield, each part is a coat of its own: what's in it is placed, stacked and
125
+ * counterchanged within it. */
126
+ &:is(.escutcheon, .escutcheon *) > * {
127
+ position: relative;
128
+ contain: layout;
129
+ }
99
130
  }
100
131
 
101
132
  /* Per fess: split horizontally into chief / base. */
@@ -103,6 +134,12 @@
103
134
  display: grid;
104
135
  grid-auto-flow: row;
105
136
  grid-auto-rows: 1fr;
137
+
138
+ /* See per-pale. */
139
+ &:is(.escutcheon, .escutcheon *) > * {
140
+ position: relative;
141
+ contain: layout;
142
+ }
106
143
  }
107
144
 
108
145
  /* Quarterly: four quarters, numbered 1–4 across then down.
@@ -111,6 +148,18 @@
111
148
  display: grid;
112
149
  grid-template-columns: 1fr 1fr;
113
150
  grid-template-rows: 1fr 1fr;
151
+
152
+ /* Each quarter is a coat of its own, so it starts its own count. */
153
+ & > * {
154
+ --the-first: var(--field-first);
155
+ --outer-inset: 0px;
156
+ }
157
+
158
+ /* See per-pale. */
159
+ &:is(.escutcheon, .escutcheon *) > * {
160
+ position: relative;
161
+ contain: layout;
162
+ }
114
163
  }
115
164
 
116
165
  /* ---------------------------------------------------------------------------
@@ -120,8 +169,51 @@
120
169
  /* Bordure: a border in a given tincture, e.g. `bordure-or`. */
121
170
  @utility bordure-* {
122
171
  border-style: solid;
123
- border-width: 0.75rem;
172
+ border-width: var(--bordure-width, 0.75rem);
124
173
  border-color: --value(--color-*);
174
+ --bordure-inset: var(--bordure-width, 0.75rem);
175
+ }
176
+ @utility bordure-of-the-first {
177
+ border-style: solid;
178
+ border-width: var(--bordure-width, 0.75rem);
179
+ border-color: var(--the-first, transparent);
180
+ --bordure-inset: var(--bordure-width, 0.75rem);
181
+ }
182
+
183
+ /* Inescutcheon: a shield a third as wide, centred on the fess point (50%, 45%),
184
+ * over everything else on the shield. As both are 5:6, it's a third as tall too. */
185
+ @utility inescutcheon {
186
+ position: absolute;
187
+ z-index: 2;
188
+ width: 33%;
189
+ left: 33.5%;
190
+ top: 28.5%;
191
+ aspect-ratio: 5 / 6;
192
+ overflow: clip;
193
+ overflow-clip-margin: border-box;
194
+ border-radius: 0 0 50% 50% / 0 0 45% 45%;
195
+ box-shadow: 0 0 0 2px var(--color-sable);
196
+ contain: layout;
197
+ /* In proportion with the shield's own bordure. */
198
+ --bordure-width: 0.25rem;
199
+ --outer-inset: var(--bordure-inset);
200
+ }
201
+
202
+ /* Canton: a third of the shield each way in dexter chief, so a ninth of it, and
203
+ * 5:6 like the shield, so everything on it places as on a shield. Over every
204
+ * ordinary and charge, as a later addition to the arms, and over the bordure. */
205
+ @utility canton {
206
+ position: absolute;
207
+ z-index: 2;
208
+ width: calc((100% + 2 * var(--outer-inset, 0px)) / 3);
209
+ left: calc(-1 * var(--outer-inset, 0px));
210
+ top: calc(-1 * var(--outer-inset, 0px));
211
+ aspect-ratio: 5 / 6;
212
+ /* Like the shield, so a chief on it surmounts its own bordure. */
213
+ overflow: clip;
214
+ overflow-clip-margin: border-box;
215
+ contain: layout;
216
+ --bordure-width: 0.25rem;
125
217
  }
126
218
 
127
219
  /* ---------------------------------------------------------------------------
@@ -132,11 +224,15 @@
132
224
  @utility escutcheon {
133
225
  position: relative;
134
226
  aspect-ratio: 5 / 6;
135
- overflow: hidden;
227
+ /* At the border's edge, so a canton or chief can surmount the bordure. */
228
+ overflow: clip;
229
+ overflow-clip-margin: border-box;
136
230
  border-radius: 0 0 50% 50% / 0 0 45% 45%;
137
231
  box-shadow: 0 0 0 2px var(--color-sable);
138
232
  /* The containing block for counterchanged charges' fixed layers. */
139
233
  contain: layout;
234
+ --the-first: var(--field-first);
235
+ --outer-inset: var(--bordure-inset);
140
236
  }
141
237
 
142
238
  /* ---------------------------------------------------------------------------
package/ordinaries.js CHANGED
@@ -2,13 +2,20 @@
2
2
  * Grailwind CSS: ordinaries, e.g. `fess-or`.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
- import { BETWEEN, FURS, ON, TINCTURES } from "./data.js";
5
+ import { BETWEEN, FURS, OF_THE_FIRST, ON, REVERSIBLE, TINCTURES, VOIDABLE } from "./data.js";
6
6
  import { furBackground } from "./furs.js";
7
7
 
8
8
  // prettier-ignore
9
9
  const ORDINARIES = {
10
10
  pale: [[35, 0], [65, 0], [65, 100], [35, 100]],
11
11
  fess: [[0, 30], [100, 30], [100, 60], [0, 60]],
12
+ // Bars that don't touch are one polygon, joined along the shield's dexter edge.
13
+ bar: [[0, 38], [100, 38], [100, 52], [0, 52]],
14
+ "two-bars": [[0, 27], [100, 27], [100, 39], [0, 39], [0, 51], [100, 51], [100, 63], [0, 63]],
15
+ "three-bars": [
16
+ [0, 20], [100, 20], [100, 30], [0, 30], [0, 40], [100, 40],
17
+ [100, 50], [0, 50], [0, 60], [100, 60], [100, 70], [0, 70],
18
+ ],
12
19
  bend: [[0, 0], [20, 0], [100, 80], [100, 100], [80, 100], [0, 20]],
13
20
  "bend-sinister": [[100, 0], [80, 0], [0, 80], [0, 100], [20, 100], [100, 20]],
14
21
  chevron: [[50, 25], [100, 75], [100, 95], [50, 45], [0, 95], [0, 75]],
@@ -25,7 +32,12 @@ const ORDINARIES = {
25
32
  [60, 55], [60, 100], [40, 100], [40, 55], [0, 15],
26
33
  ],
27
34
  chief: [[0, 0], [100, 0], [100, 30], [0, 30]],
35
+ pile: [[20, 0], [80, 0], [50, 85]],
28
36
  };
37
+ // signedArea() follows the flip's winding, so the order stays.
38
+ for (const ordinary of REVERSIBLE) {
39
+ ORDINARIES[`${ordinary}-reversed`] = ORDINARIES[ordinary].map(([x, y]) => [x, 100 - y]);
40
+ }
29
41
 
30
42
  const num = (v) => +v.toFixed(3);
31
43
  const pct = (v) => (num(v) === 0 ? "0" : `${num(v)}%`);
@@ -34,9 +46,24 @@ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
34
46
 
35
47
  const PSEUDO = { chief: "&::after" };
36
48
 
49
+ // The chief surmounts a bordure.
50
+ const INSET = "var(--bordure-inset)";
51
+ const OVER_BORDURE = {
52
+ "--bordure-inset": "inherit",
53
+ inset: `calc(-1 * ${INSET}) calc(-1 * ${INSET}) 0`,
54
+ padding: `${INSET} ${INSET} 0`,
55
+ "background-origin": "content-box",
56
+ };
57
+ // A vertex on the shield's top or side edges, moved out to the bordure's outer edge.
58
+ const outer = ([x, y]) => {
59
+ const ox = x === 0 ? `calc(-1 * ${INSET})` : x === 100 ? `calc(100% + ${INSET})` : pct(x);
60
+ return `${ox} ${y === 0 ? `calc(-1 * ${INSET})` : pct(y)}`;
61
+ };
62
+
37
63
  // A y in % of the field, as % of the whole shield when a chief takes the top of it.
38
64
  const belowChief = (y) =>
39
65
  y === 100 ? "100%" : `calc(${num(y)}% + var(--field-top, 0%) * ${num(1 - y / 100)})`;
66
+ const pointBelowChief = ([x, y]) => `${pct(x)} ${belowChief(y)}`;
40
67
 
41
68
  // Fimbriation: the ordinary's clip is widened and filled with the fimbriation
42
69
  // tincture, and the ordinary's own tincture is painted over it as gradient
@@ -64,15 +91,15 @@ const offsetLine = (a, b, outward, distance) => {
64
91
  return { n, c: n[0] * ax + n[1] * ay + distance };
65
92
  };
66
93
 
67
- // How far each vertex moves, in %, when fimbriated. Sides on the shield's edge
68
- // don't move, so their vertices slide along it.
69
- const widen = (vertices) => {
94
+ // How far each vertex moves, in %, when the sides move out by `distance` (in by a negative one).
95
+ // Sides on the shield's edge don't move, so their vertices slide along it.
96
+ const widen = (vertices, distance = FIMBRIATION) => {
70
97
  const outward = Math.sign(signedArea(vertices));
71
98
  const count = vertices.length;
72
99
  const line = (i) => {
73
100
  const a = vertices[i];
74
101
  const b = vertices[(i + 1) % count];
75
- return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : FIMBRIATION);
102
+ return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : distance);
76
103
  };
77
104
  return vertices.map((v, i) => {
78
105
  const p = line((i - 1 + count) % count);
@@ -84,6 +111,23 @@ const widen = (vertices) => {
84
111
  });
85
112
  };
86
113
 
114
+ const rotate = (list, start) => [...list.slice(start), ...list.slice(0, start)];
115
+
116
+ // The ordinary with a hole the shape of it shrunk by `distance`, cut with evenodd.
117
+ const voidedPolygon = (vertices, distance, format = point) => {
118
+ const offsets = widen(vertices, -distance);
119
+ const inner = vertices.map(([x, y], i) => [x + offsets[i][0], y + offsets[i][1]]);
120
+ // Start and end at the shield's edge so we don't draw through it.
121
+ const edge = vertices.findIndex((v, i) => onShieldEdge(v, vertices[(i + 1) % vertices.length]));
122
+ if (edge === -1) {
123
+ throw new Error("a voided ordinary needs a side on the shield's edge");
124
+ }
125
+ const outerStr = [...rotate(vertices, edge), vertices[edge]].map(format).join(", ");
126
+ const innerStr = [...rotate(inner, edge), inner[edge]].map(format).join(", ");
127
+
128
+ return `polygon(evenodd, ${outerStr}, ${innerStr})`;
129
+ };
130
+
87
131
  const shift = (v, d) =>
88
132
  num(d) === 0
89
133
  ? pct(v)
@@ -104,10 +148,19 @@ const widenedPolygon = (vertices) => {
104
148
  const S = 12;
105
149
  const band = (kind, from, to, tile) => ({ kind, from, to, tile });
106
150
  const stripe = (kind, from, to, tile) => ({ kind, from, to, tile, stripe: true });
151
+ // Half a tile, split on its diagonal, as gradient stops: 0–50% or 50–100%. The one diagonal band
152
+ // a tile that isn't square can take, as its diagonal isn't a line of constant x ± y.
153
+ const half = (kind, end, tile) => ({ kind, tile, stops: end === "start" ? [0, 50] : [50, 100] });
154
+ // A 2px strip down the centre, from the top to `bottom`, where two tiles meet mid-pixel and
155
+ // would let the fimbriation show between them. Unsoftened, as it lies on its own tincture.
156
+ const seam = (bottom) => ({ kind: "x", tile: [0, 0, 100, bottom], seam: true });
107
157
  // prettier-ignore
108
158
  const BANDS = {
109
159
  pale: [band("x", 35, 65)],
110
160
  fess: [band("y", 30, 60)],
161
+ bar: [band("y", 38, 52)],
162
+ "two-bars": [band("y", 27, 39), band("y", 51, 63)],
163
+ "three-bars": [band("y", 20, 30), band("y", 40, 50), band("y", 60, 70)],
111
164
  bend: [band("u-v", -20, 20)],
112
165
  "bend-sinister": [band("u+v", 80, 120)],
113
166
  chevron: [
@@ -127,7 +180,31 @@ const BANDS = {
127
180
  band("u+v", 85, 115),
128
181
  band("x", 40, 60, [0, 55, 100, 100]),
129
182
  ],
183
+ // Each slanted side is its tile's diagonal.
184
+ pile: [half("u-v", "end", [20, 0, 50, 85]), half("u+v", "start", [50, 0, 80, 85]), seam(83)],
185
+ };
186
+
187
+ // The bands upside down. A diagonal swaps kind, as x − y becomes x + (100 − y) − 100. A half's
188
+ // stops stay: its 50% line is the flipped tile's other diagonal, which is the flipped side.
189
+ const reverseBand = ({ kind, from, to, tile, ...rest }) => {
190
+ const layer = { ...rest, tile: tile && [tile[0], 100 - tile[3], tile[2], 100 - tile[1]] };
191
+ if (from === undefined) {
192
+ return { ...layer, kind: { "u-v": "u+v", "u+v": "u-v" }[kind] ?? kind };
193
+ }
194
+ switch (kind) {
195
+ case "x":
196
+ return { ...layer, kind, from, to };
197
+ case "y":
198
+ return { ...layer, kind, from: 100 - to, to: 100 - from };
199
+ case "u-v":
200
+ return { ...layer, kind: "u+v", from: from + 100, to: to + 100 };
201
+ case "u+v":
202
+ return { ...layer, kind: "u-v", from: from - 100, to: to - 100 };
203
+ }
130
204
  };
205
+ for (const ordinary of REVERSIBLE) {
206
+ BANDS[`${ordinary}-reversed`] = BANDS[ordinary].map(reverseBand);
207
+ }
131
208
 
132
209
  const DIRECTION = {
133
210
  x: "to right",
@@ -155,23 +232,24 @@ const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
155
232
  }
156
233
  };
157
234
 
158
- // Soften each edge by a pixel, as hard gradient stops aren't antialiased.
235
+ // Soften each edge by a pixel, as hard gradient stops aren't antialiased. A stop at 0 or 100% is
236
+ // the tile's own edge, so it isn't softened.
159
237
  const gradient = (layer, colour) => {
160
- const a = num(stop(layer, layer.from));
161
- const b = num(stop(layer, layer.to));
162
- return (
163
- `linear-gradient(${DIRECTION[layer.kind]}, transparent calc(${a}% - 0.5px), ` +
164
- `${colour} calc(${a}% + 0.5px) calc(${b}% - 0.5px), transparent calc(${b}% + 0.5px))`
165
- );
238
+ if (layer.seam) {
239
+ return `linear-gradient(to right, transparent calc(50% - 1px), ${colour} 0 calc(50% + 1px), transparent 0)`;
240
+ }
241
+ const [a, b] = (layer.stops ?? [stop(layer, layer.from), stop(layer, layer.to)]).map(num);
242
+ const from =
243
+ a === 0 ? `${colour} 0` : `transparent calc(${a}% - 0.5px), ${colour} calc(${a}% + 0.5px)`;
244
+ const to = b === 100 ? "100%" : `calc(${b}% - 0.5px), transparent calc(${b}% + 0.5px)`;
245
+ return `linear-gradient(${DIRECTION[layer.kind]}, ${from} ${to})`;
166
246
  };
167
247
 
168
248
  const tilePosition = (start, size) => (size === 100 ? "0" : pct((start * 100) / (100 - size)));
169
249
 
170
- const bands = (layers, tincture) => ({
250
+ const bands = (layers, colour) => ({
171
251
  "background-image": layers
172
- .map((l) =>
173
- gradient(l, l.stripe ? "var(--fimbriation, transparent)" : `var(--color-${tincture})`),
174
- )
252
+ .map((l) => gradient(l, l.stripe ? "var(--fimbriation, transparent)" : colour))
175
253
  .join(", "),
176
254
  "background-size": layers
177
255
  .map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) => `${pct(x1 - x0)} ${pct(y1 - y0)}`)
@@ -189,6 +267,19 @@ const bands = (layers, tincture) => ({
189
267
  // box is shorter, so they are slightly squashed.
190
268
  const SCALLOP = 14;
191
269
  const SCALLOPS = { chevron: 10 };
270
+
271
+ // A quarter of the ordinary's width, in real units. The pile tapers, so its is set by eye.
272
+ // prettier-ignore
273
+ const VOIDS = {
274
+ pale: 7.5, fess: 9, bar: 4.2, bend: 7.7, "bend-sinister": 7.7,
275
+ chevron: 3.8, cross: 5, saltire: 5.8, pall: 5, pile: 8,
276
+ };
277
+ for (const ordinary of REVERSIBLE) {
278
+ if (SCALLOPS[ordinary]) {
279
+ SCALLOPS[`${ordinary}-reversed`] = SCALLOPS[ordinary];
280
+ }
281
+ VOIDS[`${ordinary}-reversed`] = VOIDS[ordinary];
282
+ }
192
283
  const DEPTH = 0.35;
193
284
 
194
285
  const at = ([x, y]) => point([(x * 100) / W, (y * 100) / H]);
@@ -236,14 +327,14 @@ const crossing = (a, v, b, first, second) => {
236
327
  : null;
237
328
  };
238
329
 
239
- const shape = (vertices, line, scallop) => {
330
+ // `extend` moves the sides on the shield's edge out, as `outer` does; the lined sides stay put.
331
+ const shape = (vertices, line, scallop, extend) => {
240
332
  const inward = signedArea(vertices) > 0 ? "ccw" : "cw";
241
333
  const pts = vertices.map(real);
334
+ const straight = vertices.map((v, i) => onShieldEdge(v, vertices[(i + 1) % vertices.length]));
242
335
  const edges = pts.map((a, i) => {
243
336
  const j = (i + 1) % pts.length;
244
- return onShieldEdge(vertices[i], vertices[j])
245
- ? [{ to: pts[j] }]
246
- : line(a, pts[j], scallop, inward);
337
+ return straight[i] ? [{ to: pts[j] }] : line(a, pts[j], scallop, inward);
247
338
  });
248
339
 
249
340
  // Where arcs cross at a corner, e.g. a chevron engrailed's peak, end both at the
@@ -266,22 +357,41 @@ const shape = (vertices, line, scallop) => {
266
357
  }
267
358
  });
268
359
 
269
- const commands = edges
270
- .flat()
271
- .map(({ to, r, sweep }) =>
360
+ const commands = edges.flatMap((edge, i) => {
361
+ const segments = edge.map(({ to, r, sweep }) =>
272
362
  r
273
363
  ? `arc to ${at(to)} of ${pct((r / W) * 100)} ${pct((r / H) * 100)} ${sweep}`
274
364
  : `line to ${at(to)}`,
275
365
  );
276
- return `shape(from ${at(start)}, ${commands.join(", ")}, close)`;
366
+ if (!extend) {
367
+ return segments;
368
+ }
369
+ const outside = straight.at(i - 1);
370
+ if (straight[i]) {
371
+ const out = `line to ${extend(vertices[(i + 1) % vertices.length])}`;
372
+ return outside ? [out] : [`line to ${extend(vertices[i])}`, out];
373
+ }
374
+ return outside ? [`line to ${at(pts[i])}`, ...segments] : segments;
375
+ });
376
+ const from = extend ? extend(vertices[0]) : at(start);
377
+ return `shape(from ${from}, ${commands.join(", ")}, close)`;
277
378
  };
278
379
 
279
- const betweenVars = ({ size, points }) => {
380
+ const betweenVars = ({ size, points, counts = {} }) => {
280
381
  const vars = { "--between-size": `${size}%` };
281
382
  points.forEach(([left, top], i) => {
282
383
  vars[`--between-${i + 1}-x`] = `${left}%`;
283
384
  vars[`--between-${i + 1}-y`] = `${top}%`;
284
385
  });
386
+ for (const [count, override] of Object.entries(counts)) {
387
+ if (override.size !== undefined) {
388
+ vars[`--between-${count}-size`] = `${override.size}%`;
389
+ }
390
+ override.points.forEach(([left, top], i) => {
391
+ vars[`--between-${count}-${i + 1}-x`] = `${left}%`;
392
+ vars[`--between-${count}-${i + 1}-y`] = `${top}%`;
393
+ });
394
+ }
285
395
  return vars;
286
396
  };
287
397
 
@@ -296,13 +406,46 @@ const onVars = ({ size, counts }) => {
296
406
  return vars;
297
407
  };
298
408
 
299
- export default plugin(({ addUtilities, theme }) => {
409
+ // Every layout var an ordinary sets on its shield, for an inescutcheon or canton to reset.
410
+ const layoutVars = () => {
411
+ const allVars = [];
412
+
413
+ for (const division of Object.values(BETWEEN)) {
414
+ const vars = betweenVars(division);
415
+ allVars.push(...Object.keys(vars));
416
+ }
417
+
418
+ for (const division of Object.values(ON)) {
419
+ const vars = onVars(division);
420
+ allVars.push(...Object.keys(vars));
421
+ }
422
+
423
+ const chiefVars = ["--field-top", "--chief-points-y", "--chief-points-size"];
424
+ allVars.push(...chiefVars);
425
+
426
+ return [...new Set(allVars)];
427
+ };
428
+
429
+ // Each tincture's class suffix and colour, and "of the first" (see --the-first in grailwind.css).
430
+ const PAINTS = [
431
+ ...TINCTURES.map((tincture) => [tincture, `var(--color-${tincture})`]),
432
+ [OF_THE_FIRST, "var(--the-first, transparent)"],
433
+ ];
434
+
435
+ export default plugin(({ addBase, addUtilities, theme }) => {
436
+ // Unset on an inescutcheon, a canton or a part of a divided shield, so it can't inherit its
437
+ // shield's layout. In the base layer, so an ordinary on it still sets its own.
438
+ addBase({
439
+ ".inescutcheon, .canton, :is(.escutcheon, .escutcheon *):is(.quarterly, .per-pale, .per-fess) > *":
440
+ Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
441
+ });
442
+
300
443
  const utilities = {};
301
444
 
302
- for (const tincture of TINCTURES) {
445
+ for (const [tincture, colour] of PAINTS) {
303
446
  utilities[`.fimbriated-${tincture}`] = {
304
447
  "--fimbriated": "1",
305
- "--fimbriation": `var(--color-${tincture})`,
448
+ "--fimbriation": colour,
306
449
  };
307
450
  }
308
451
 
@@ -329,10 +472,23 @@ export default plugin(({ addUtilities, theme }) => {
329
472
  });
330
473
 
331
474
  for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
475
+ const chief = ordinary === "chief";
476
+ const placed = chief ? OVER_BORDURE : { inset: "var(--field-top, 0%) 0 0" };
477
+ const plain = chief
478
+ ? `polygon(${vertices.map(outer).join(", ")}) content-box`
479
+ : polygon(vertices);
480
+ // Browsers without shape() keep the straight polygon.
481
+ const lined = (line) => [
482
+ plain,
483
+ chief
484
+ ? `${shape(vertices, line, SCALLOP, outer)} content-box`
485
+ : shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP),
486
+ ];
487
+
332
488
  // Covers the whole shield, so the swapped field lines up with the real one,
333
489
  // and the clip moves the ordinary below any chief instead.
334
- utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
335
- inset: "0",
490
+ const counterchanged = {
491
+ ...(chief ? OVER_BORDURE : { inset: "0" }),
336
492
  // Registered not to inherit, so a shield inside another can't pick up its swap.
337
493
  ...Object.fromEntries(
338
494
  ["color", "image", "size", "position", "repeat"].map((n) => [
@@ -345,55 +501,77 @@ export default plugin(({ addUtilities, theme }) => {
345
501
  "background-size": "var(--counterchange-size, auto)",
346
502
  "background-position": "var(--counterchange-position, 0 0)",
347
503
  "background-repeat": "var(--counterchange-repeat, repeat)",
348
- "clip-path":
349
- ordinary === "chief"
350
- ? polygon(vertices)
351
- : `polygon(${vertices.map(([x, y]) => `${pct(x)} ${belowChief(y)}`).join(", ")})`,
504
+ // The swapped field's tiles would wrap into the bordure's strips, so the bordure shows there.
505
+ ...(chief && { "background-clip": "content-box" }),
506
+ };
507
+ utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
508
+ ...counterchanged,
509
+ "clip-path": chief ? plain : `polygon(${vertices.map(pointBelowChief).join(", ")})`,
352
510
  });
353
511
 
354
- for (const tincture of TINCTURES) {
512
+ for (const [tincture, colour] of PAINTS) {
355
513
  utilities[`.${ordinary}-${tincture}`] = ordinaryRules(ordinary, {
356
- inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
514
+ ...placed,
357
515
  ...(BANDS[ordinary]
358
516
  ? {
359
517
  "--fimbriated": "inherit",
360
518
  "--fimbriation": "inherit",
361
- "background-color": `var(--fimbriation, var(--color-${tincture}))`,
362
- ...bands(BANDS[ordinary], tincture),
519
+ "background-color": `var(--fimbriation, ${colour})`,
520
+ ...bands(BANDS[ordinary], colour),
363
521
  "clip-path": widenedPolygon(vertices),
364
522
  }
365
523
  : {
366
- "background-color": `var(--color-${tincture})`,
367
- "clip-path": polygon(vertices),
524
+ "background-color": colour,
525
+ "clip-path": plain,
368
526
  }),
369
527
  });
370
- // Not fimbriated. Browsers without shape() keep the straight polygon.
528
+ // Not fimbriated.
371
529
  for (const [name, line] of Object.entries(LINES)) {
372
530
  utilities[`.${ordinary}-${name}-${tincture}`] = ordinaryRules(ordinary, {
373
- inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
374
- "background-color": `var(--color-${tincture})`,
375
- "clip-path": [polygon(vertices), shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP)],
531
+ ...placed,
532
+ "background-color": colour,
533
+ "clip-path": lined(line),
376
534
  });
377
535
  }
378
536
  }
379
537
 
380
538
  // A plain polygon, without the fimbriation vars, so fimbriated-* can't reach a fur ordinary.
381
539
  for (const fur of Object.keys(FURS)) {
382
- const pseudo = {
383
- inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
384
- ...furBackground(fur, theme),
385
- };
540
+ const pseudo = { ...placed, ...furBackground(fur, theme) };
386
541
  utilities[`.${ordinary}-${fur}`] = ordinaryRules(ordinary, {
387
542
  ...pseudo,
388
- "clip-path": polygon(vertices),
543
+ "clip-path": plain,
389
544
  });
390
545
  for (const [name, line] of Object.entries(LINES)) {
391
546
  utilities[`.${ordinary}-${name}-${fur}`] = ordinaryRules(ordinary, {
392
547
  ...pseudo,
393
- "clip-path": [polygon(vertices), shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP)],
548
+ "clip-path": lined(line),
394
549
  });
395
550
  }
396
551
  }
552
+
553
+ // Not fimbriated.
554
+ if (VOIDABLE.includes(ordinary)) {
555
+ const clip = voidedPolygon(vertices, VOIDS[ordinary]);
556
+ for (const [tincture, colour] of PAINTS) {
557
+ utilities[`.${ordinary}-voided-${tincture}`] = ordinaryRules(ordinary, {
558
+ ...placed,
559
+ "background-color": colour,
560
+ "clip-path": clip,
561
+ });
562
+ }
563
+ for (const fur of Object.keys(FURS)) {
564
+ utilities[`.${ordinary}-voided-${fur}`] = ordinaryRules(ordinary, {
565
+ ...placed,
566
+ ...furBackground(fur, theme),
567
+ "clip-path": clip,
568
+ });
569
+ }
570
+ utilities[`.${ordinary}-voided-counterchanged`] = ordinaryRules(ordinary, {
571
+ ...counterchanged,
572
+ "clip-path": voidedPolygon(vertices, VOIDS[ordinary], pointBelowChief),
573
+ });
574
+ }
397
575
  }
398
576
 
399
577
  addUtilities(utilities);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "scripts": {
5
5
  "build": "tailwindcss -i docs/app.css -o docs/site.css",
6
6
  "dev": "tailwindcss -i docs/app.css -o docs/site.css --watch",
@@ -16,13 +16,17 @@
16
16
  "tailwind",
17
17
  "heraldry",
18
18
  "blazon",
19
- "css"
19
+ "css",
20
+ "coat-of-arms",
21
+ "coats-of-arms",
22
+ "tailwindcss-plugin",
23
+ "eslint"
20
24
  ],
21
25
  "author": "Paul Firmin",
22
26
  "license": "MIT",
23
27
  "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
24
28
  "devDependencies": {
25
- "@grailwind/eslint-plugin": "^0.1.0-beta.0",
29
+ "@grailwind/eslint-plugin": "^0.1.0-beta.2",
26
30
  "@html-eslint/parser": "^0.67.0",
27
31
  "@tailwindcss/cli": "^4.3.3",
28
32
  "eslint": "^10.14.0",