grailwind 0.4.0 → 0.5.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,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-11-04
10
+
11
+ ### Added
12
+
13
+ - `in-chief` and `in-base`: one to three charges along the chief or base. On a
14
+ chief, they sit on it. The ESLint plugin reports an arrangement put on a
15
+ charge rather than its container.
16
+ - `-of-the-first`: the field's first tincture, as in a blazon, e.g.
17
+ `pale-of-the-first`. The ESLint plugin checks it.
18
+ - Striped fields: `barry-*` and `paly-*` (of six), `barry-of-eight-*`,
19
+ `barry-of-ten-*` and `paly-of-eight-*`. The first tincture is in chief or
20
+ on the dexter. They take furs and counterchanged, like the other divisions.
21
+ - `per-saltire-*`: a field divided on both diagonals, the first tincture in
22
+ chief and base.
23
+ - Bars, named by count: `bar-*`, `two-bars-*` and `three-bars-*`. They take
24
+ lines, furs, fimbriation and counterchanged, like the fess.
25
+ - `pile-*`: a wedge from the chief to a point. It takes lines, furs,
26
+ fimbriation and counterchanged, one or three charges `on` it, and two
27
+ `between`.
28
+ - `canton`: a ninth of the shield in dexter chief, over every ordinary and
29
+ charge, with its own field, divisions, ordinaries and charges, like the
30
+ inescutcheon.
31
+ - Six charges `between` a fess, bend, bend sinister, chevron or pale.
32
+ - `inescutcheon`: a small shield at the centre of the arms, over everything
33
+ else, with its own field, ordinaries and charges. A bordure on it is a third
34
+ as wide.
35
+ - The ESLint plugin checks a plain canton or inescutcheon against the field it
36
+ lies on. One with anything on it counts as arms of its own.
37
+
38
+ ### Changed
39
+
40
+ - The `in-chief` and `in-base` points are now `in-chief-point` and
41
+ `in-base-point`, like the honour, fess and nombril points.
42
+ - The canton and the chief surmount a bordure, as in heraldry. The canton is
43
+ still a third of the whole shield, and the chief's lower edge doesn't move.
44
+ - `@grailwind/eslint-plugin` 0.1.0-beta.1 needs `grailwind` 0.5.x, for the
45
+ renamed points.
46
+
47
+ ### Fixed
48
+
49
+ - Two charges `between` a fess, saltire or pall are placed one each side.
50
+
9
51
  ## [0.4.0] - 2026-11-03
10
52
 
11
53
  ### Added
@@ -71,7 +113,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
71
113
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
72
114
  custom SVG charges.
73
115
 
74
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.4.0...HEAD
116
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.5.0...HEAD
117
+ [0.5.0]: https://github.com/pafirmin/grailwind/compare/v0.4.0...v0.5.0
75
118
  [0.4.0]: https://github.com/pafirmin/grailwind/compare/v0.3.0...v0.4.0
76
119
  [0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
77
120
  [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,14 +54,16 @@ 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.
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
+ `chief-ermine` and the like in a fur, or `chevron-counterchanged` and the like
66
+ on a divided field.
60
67
  - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
61
68
  number, `in-pale`, `in-fess`, `in-bend`, `in-bend-sinister`, `on` or `between`
62
69
  the ordinary, or placed at a named point such as `in-dexter-chief`, sized
package/charges.js CHANGED
@@ -46,7 +46,22 @@ const DEFAULTS = {
46
46
  const POINT_SIZE = 22;
47
47
 
48
48
  // 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"]);
49
+ const CHIEF_POINTS = new Set(["dexter-chief", "chief-point", "sinister-chief"]);
50
+
51
+ // Arrangements along the chief or base, by the points each count of charges takes.
52
+ // prettier-ignore
53
+ const ALONG = {
54
+ "in-chief": {
55
+ 1: ["chief-point"],
56
+ 2: ["dexter-chief", "sinister-chief"],
57
+ 3: ["dexter-chief", "chief-point", "sinister-chief"],
58
+ },
59
+ "in-base": {
60
+ 1: ["base-point"],
61
+ 2: ["dexter-base", "sinister-base"],
62
+ 3: ["dexter-base", "base-point", "sinister-base"],
63
+ },
64
+ };
50
65
 
51
66
  // prettier-ignore
52
67
  const ARRANGEMENTS = {
@@ -107,6 +122,18 @@ const counterchanged = {
107
122
  },
108
123
  };
109
124
 
125
+ // A charge at one of the shield's points. The chief's points sit on a chief when there is one.
126
+ const atPoint = (name) => {
127
+ const [left, top] = POINTS[name];
128
+ return CHIEF_POINTS.has(name)
129
+ ? {
130
+ left: `${left}%`,
131
+ top: `var(--chief-points-y, ${top}%)`,
132
+ "--charge-width": `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
133
+ }
134
+ : place([left, top], POINT_SIZE);
135
+ };
136
+
110
137
  const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
111
138
 
112
139
  export default plugin(({ addUtilities, matchUtilities }) => {
@@ -160,6 +187,16 @@ export default plugin(({ addUtilities, matchUtilities }) => {
160
187
  utilities[`.${name}`] = arrangement;
161
188
  }
162
189
 
190
+ for (const [name, counts] of Object.entries(ALONG)) {
191
+ const arrangement = {};
192
+ for (const [count, points] of Object.entries(counts)) {
193
+ points.forEach((point, i) => {
194
+ arrangement[`&${hasCount(count)} > :nth-child(${i + 1})`] = atPoint(point);
195
+ });
196
+ }
197
+ utilities[`.${name}`] = arrangement;
198
+ }
199
+
163
200
  // Positions come from the ordinary on the shield (see ordinaries.js).
164
201
  const between = {};
165
202
  for (let i = 1; i <= 4; i++) {
@@ -169,6 +206,16 @@ export default plugin(({ addUtilities, matchUtilities }) => {
169
206
  "--charge-width": "var(--charge-size, var(--between-size))",
170
207
  };
171
208
  }
209
+ // The counts some ordinary overrides in BETWEEN's `counts` (see data.js).
210
+ for (const count of [2, 6]) {
211
+ for (let i = 1; i <= count; i++) {
212
+ between[`&${hasCount(count)} > :nth-child(${i})`] = {
213
+ left: `var(--between-${count}-${i}-x, var(--between-${i}-x))`,
214
+ top: `var(--between-${count}-${i}-y, var(--between-${i}-y))`,
215
+ "--charge-width": `var(--charge-size, var(--between-${count}-size, var(--between-size)))`,
216
+ };
217
+ }
218
+ }
172
219
  utilities[".between"] = between;
173
220
 
174
221
  // Falls back to the plain-field default when the ordinary has no points for this count.
@@ -191,16 +238,8 @@ export default plugin(({ addUtilities, matchUtilities }) => {
191
238
  }
192
239
  utilities[".on"] = on;
193
240
 
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
- };
241
+ for (const name of Object.keys(POINTS)) {
242
+ utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
204
243
  }
205
244
 
206
245
  // On a charge, or on `charges` for all of them.
package/data.js CHANGED
@@ -5,19 +5,30 @@
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
 
18
26
  export const ORDINARIES = [
19
27
  "pale",
20
28
  "fess",
29
+ "bar",
30
+ "two-bars",
31
+ "three-bars",
21
32
  "bend",
22
33
  "bend-sinister",
23
34
  "chevron",
@@ -25,6 +36,7 @@ export const ORDINARIES = [
25
36
  "saltire",
26
37
  "pall",
27
38
  "chief",
39
+ "pile",
28
40
  ];
29
41
 
30
42
  export const CHARGES = [
@@ -60,30 +72,47 @@ export const FURS = {
60
72
  // The points of the shield, as [left%, top%], for placing a single charge, e.g. `in-dexter-chief`.
61
73
  export const POINTS = {
62
74
  "dexter-chief": [25, 17],
63
- chief: [50, 15],
75
+ "chief-point": [50, 15],
64
76
  "sinister-chief": [75, 17],
65
77
  "honour-point": [50, 30],
66
78
  "fess-point": [50, 45],
67
79
  "nombril-point": [50, 60],
68
80
  "dexter-base": [30, 72],
69
- base: [50, 84],
81
+ "base-point": [50, 84],
70
82
  "sinister-base": [70, 72],
71
83
  };
72
84
 
73
85
  // Plain-field arrangements of a `charges` container, besides `between` and `on`.
74
- export const ARRANGEMENTS = ["in-pale", "in-fess", "in-bend", "in-bend-sinister"];
86
+ export const ARRANGEMENTS = [
87
+ "in-pale",
88
+ "in-fess",
89
+ "in-bend",
90
+ "in-bend-sinister",
91
+ "in-chief",
92
+ "in-base",
93
+ ];
75
94
 
76
95
  // Where charges go for "between", as [left%, top%], plus their size as % of width.
96
+ // `counts` overrides the points (and optionally the size) for one number of charges.
77
97
  // prettier-ignore
78
98
  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]] },
99
+ pale: { size: 18, points: [[18, 42], [82, 42]], counts: {
100
+ 6: { size: 14, points: [[18, 18], [18, 45], [18, 70], [82, 18], [82, 45], [82, 70]] } } },
101
+ fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]], counts: {
102
+ 2: { points: [[50, 15], [50, 78]] },
103
+ 6: { size: 14, points: [[20, 14], [50, 14], [80, 14], [32, 70], [68, 70], [50, 86]] } } },
104
+ bend: { size: 24, points: [[70, 26], [30, 68]], counts: {
105
+ 6: { size: 14, points: [[48, 9], [76, 12], [84, 38], [9, 48], [18, 72], [38, 84]] } } },
106
+ "bend-sinister": { size: 24, points: [[30, 26], [70, 68]], counts: {
107
+ 6: { size: 14, points: [[52, 9], [24, 12], [16, 38], [91, 48], [82, 72], [62, 84]] } } },
108
+ chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]], counts: {
109
+ 6: { size: 12, points: [[18, 14], [50, 11], [82, 14], [38, 66], [62, 66], [50, 84]] } } },
84
110
  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]] },
111
+ saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]], counts: {
112
+ 2: { points: [[14, 50], [86, 50]] } } },
113
+ pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]], counts: {
114
+ 2: { points: [[18, 56], [82, 56]] } } },
115
+ pile: { size: 20, points: [[18, 55], [82, 55]] },
87
116
  };
88
117
 
89
118
  // Where charges go for "on", along the ordinary's centre line, by number of charges.
@@ -97,4 +126,5 @@ export const ON = {
97
126
  cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
98
127
  saltire: { size: 14, counts: { 1: [[50, 50]], 5: [[22, 22], [78, 22], [50, 50], [22, 78], [78, 78]] } },
99
128
  pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
129
+ pile: { size: 14, counts: { 1: [[50, 25]], 3: [[50, 14], [50, 34], [50, 54]] } },
100
130
  };
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/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
  /* ---------------------------------------------------------------------------
@@ -111,6 +135,12 @@
111
135
  display: grid;
112
136
  grid-template-columns: 1fr 1fr;
113
137
  grid-template-rows: 1fr 1fr;
138
+
139
+ /* Each quarter is a coat of its own, so it starts its own count. */
140
+ & > * {
141
+ --the-first: var(--field-first);
142
+ --outer-inset: 0px;
143
+ }
114
144
  }
115
145
 
116
146
  /* ---------------------------------------------------------------------------
@@ -120,8 +150,51 @@
120
150
  /* Bordure: a border in a given tincture, e.g. `bordure-or`. */
121
151
  @utility bordure-* {
122
152
  border-style: solid;
123
- border-width: 0.75rem;
153
+ border-width: var(--bordure-width, 0.75rem);
124
154
  border-color: --value(--color-*);
155
+ --bordure-inset: var(--bordure-width, 0.75rem);
156
+ }
157
+ @utility bordure-of-the-first {
158
+ border-style: solid;
159
+ border-width: var(--bordure-width, 0.75rem);
160
+ border-color: var(--the-first, transparent);
161
+ --bordure-inset: var(--bordure-width, 0.75rem);
162
+ }
163
+
164
+ /* Inescutcheon: a shield a third as wide, centred on the fess point (50%, 45%),
165
+ * over everything else on the shield. As both are 5:6, it's a third as tall too. */
166
+ @utility inescutcheon {
167
+ position: absolute;
168
+ z-index: 2;
169
+ width: 33%;
170
+ left: 33.5%;
171
+ top: 28.5%;
172
+ aspect-ratio: 5 / 6;
173
+ overflow: clip;
174
+ overflow-clip-margin: border-box;
175
+ border-radius: 0 0 50% 50% / 0 0 45% 45%;
176
+ box-shadow: 0 0 0 2px var(--color-sable);
177
+ contain: layout;
178
+ /* In proportion with the shield's own bordure. */
179
+ --bordure-width: 0.25rem;
180
+ --outer-inset: var(--bordure-inset);
181
+ }
182
+
183
+ /* Canton: a third of the shield each way in dexter chief, so a ninth of it, and
184
+ * 5:6 like the shield, so everything on it places as on a shield. Over every
185
+ * ordinary and charge, as a later addition to the arms, and over the bordure. */
186
+ @utility canton {
187
+ position: absolute;
188
+ z-index: 2;
189
+ width: calc((100% + 2 * var(--outer-inset, 0px)) / 3);
190
+ left: calc(-1 * var(--outer-inset, 0px));
191
+ top: calc(-1 * var(--outer-inset, 0px));
192
+ aspect-ratio: 5 / 6;
193
+ /* Like the shield, so a chief on it surmounts its own bordure. */
194
+ overflow: clip;
195
+ overflow-clip-margin: border-box;
196
+ contain: layout;
197
+ --bordure-width: 0.25rem;
125
198
  }
126
199
 
127
200
  /* ---------------------------------------------------------------------------
@@ -132,11 +205,15 @@
132
205
  @utility escutcheon {
133
206
  position: relative;
134
207
  aspect-ratio: 5 / 6;
135
- overflow: hidden;
208
+ /* At the border's edge, so a canton or chief can surmount the bordure. */
209
+ overflow: clip;
210
+ overflow-clip-margin: border-box;
136
211
  border-radius: 0 0 50% 50% / 0 0 45% 45%;
137
212
  box-shadow: 0 0 0 2px var(--color-sable);
138
213
  /* The containing block for counterchanged charges' fixed layers. */
139
214
  contain: layout;
215
+ --the-first: var(--field-first);
216
+ --outer-inset: var(--bordure-inset);
140
217
  }
141
218
 
142
219
  /* ---------------------------------------------------------------------------
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, TINCTURES } 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,6 +32,7 @@ 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
  };
29
37
 
30
38
  const num = (v) => +v.toFixed(3);
@@ -34,6 +42,20 @@ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
34
42
 
35
43
  const PSEUDO = { chief: "&::after" };
36
44
 
45
+ // The chief surmounts a bordure.
46
+ const INSET = "var(--bordure-inset)";
47
+ const OVER_BORDURE = {
48
+ "--bordure-inset": "inherit",
49
+ inset: `calc(-1 * ${INSET}) calc(-1 * ${INSET}) 0`,
50
+ padding: `${INSET} ${INSET} 0`,
51
+ "background-origin": "content-box",
52
+ };
53
+ // A vertex on the shield's top or side edges, moved out to the bordure's outer edge.
54
+ const outer = ([x, y]) => {
55
+ const ox = x === 0 ? `calc(-1 * ${INSET})` : x === 100 ? `calc(100% + ${INSET})` : pct(x);
56
+ return `${ox} ${y === 0 ? `calc(-1 * ${INSET})` : pct(y)}`;
57
+ };
58
+
37
59
  // A y in % of the field, as % of the whole shield when a chief takes the top of it.
38
60
  const belowChief = (y) =>
39
61
  y === 100 ? "100%" : `calc(${num(y)}% + var(--field-top, 0%) * ${num(1 - y / 100)})`;
@@ -104,10 +126,19 @@ const widenedPolygon = (vertices) => {
104
126
  const S = 12;
105
127
  const band = (kind, from, to, tile) => ({ kind, from, to, tile });
106
128
  const stripe = (kind, from, to, tile) => ({ kind, from, to, tile, stripe: true });
129
+ // Half a tile, split on its diagonal, as gradient stops: 0–50% or 50–100%. The one diagonal band
130
+ // a tile that isn't square can take, as its diagonal isn't a line of constant x ± y.
131
+ const half = (kind, end, tile) => ({ kind, tile, stops: end === "start" ? [0, 50] : [50, 100] });
132
+ // A 2px strip down the centre, from the top to `bottom`, where two tiles meet mid-pixel and
133
+ // would let the fimbriation show between them. Unsoftened, as it lies on its own tincture.
134
+ const seam = (bottom) => ({ kind: "x", tile: [0, 0, 100, bottom], seam: true });
107
135
  // prettier-ignore
108
136
  const BANDS = {
109
137
  pale: [band("x", 35, 65)],
110
138
  fess: [band("y", 30, 60)],
139
+ bar: [band("y", 38, 52)],
140
+ "two-bars": [band("y", 27, 39), band("y", 51, 63)],
141
+ "three-bars": [band("y", 20, 30), band("y", 40, 50), band("y", 60, 70)],
111
142
  bend: [band("u-v", -20, 20)],
112
143
  "bend-sinister": [band("u+v", 80, 120)],
113
144
  chevron: [
@@ -127,6 +158,8 @@ const BANDS = {
127
158
  band("u+v", 85, 115),
128
159
  band("x", 40, 60, [0, 55, 100, 100]),
129
160
  ],
161
+ // Each slanted side is its tile's diagonal.
162
+ pile: [half("u-v", "end", [20, 0, 50, 85]), half("u+v", "start", [50, 0, 80, 85]), seam(83)],
130
163
  };
131
164
 
132
165
  const DIRECTION = {
@@ -155,23 +188,24 @@ const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
155
188
  }
156
189
  };
157
190
 
158
- // Soften each edge by a pixel, as hard gradient stops aren't antialiased.
191
+ // Soften each edge by a pixel, as hard gradient stops aren't antialiased. A stop at 0 or 100% is
192
+ // the tile's own edge, so it isn't softened.
159
193
  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
- );
194
+ if (layer.seam) {
195
+ return `linear-gradient(to right, transparent calc(50% - 1px), ${colour} 0 calc(50% + 1px), transparent 0)`;
196
+ }
197
+ const [a, b] = (layer.stops ?? [stop(layer, layer.from), stop(layer, layer.to)]).map(num);
198
+ const from =
199
+ a === 0 ? `${colour} 0` : `transparent calc(${a}% - 0.5px), ${colour} calc(${a}% + 0.5px)`;
200
+ const to = b === 100 ? "100%" : `calc(${b}% - 0.5px), transparent calc(${b}% + 0.5px)`;
201
+ return `linear-gradient(${DIRECTION[layer.kind]}, ${from} ${to})`;
166
202
  };
167
203
 
168
204
  const tilePosition = (start, size) => (size === 100 ? "0" : pct((start * 100) / (100 - size)));
169
205
 
170
- const bands = (layers, tincture) => ({
206
+ const bands = (layers, colour) => ({
171
207
  "background-image": layers
172
- .map((l) =>
173
- gradient(l, l.stripe ? "var(--fimbriation, transparent)" : `var(--color-${tincture})`),
174
- )
208
+ .map((l) => gradient(l, l.stripe ? "var(--fimbriation, transparent)" : colour))
175
209
  .join(", "),
176
210
  "background-size": layers
177
211
  .map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) => `${pct(x1 - x0)} ${pct(y1 - y0)}`)
@@ -236,14 +270,14 @@ const crossing = (a, v, b, first, second) => {
236
270
  : null;
237
271
  };
238
272
 
239
- const shape = (vertices, line, scallop) => {
273
+ // `extend` moves the sides on the shield's edge out, as `outer` does; the lined sides stay put.
274
+ const shape = (vertices, line, scallop, extend) => {
240
275
  const inward = signedArea(vertices) > 0 ? "ccw" : "cw";
241
276
  const pts = vertices.map(real);
277
+ const straight = vertices.map((v, i) => onShieldEdge(v, vertices[(i + 1) % vertices.length]));
242
278
  const edges = pts.map((a, i) => {
243
279
  const j = (i + 1) % pts.length;
244
- return onShieldEdge(vertices[i], vertices[j])
245
- ? [{ to: pts[j] }]
246
- : line(a, pts[j], scallop, inward);
280
+ return straight[i] ? [{ to: pts[j] }] : line(a, pts[j], scallop, inward);
247
281
  });
248
282
 
249
283
  // Where arcs cross at a corner, e.g. a chevron engrailed's peak, end both at the
@@ -266,22 +300,41 @@ const shape = (vertices, line, scallop) => {
266
300
  }
267
301
  });
268
302
 
269
- const commands = edges
270
- .flat()
271
- .map(({ to, r, sweep }) =>
303
+ const commands = edges.flatMap((edge, i) => {
304
+ const segments = edge.map(({ to, r, sweep }) =>
272
305
  r
273
306
  ? `arc to ${at(to)} of ${pct((r / W) * 100)} ${pct((r / H) * 100)} ${sweep}`
274
307
  : `line to ${at(to)}`,
275
308
  );
276
- return `shape(from ${at(start)}, ${commands.join(", ")}, close)`;
309
+ if (!extend) {
310
+ return segments;
311
+ }
312
+ const outside = straight.at(i - 1);
313
+ if (straight[i]) {
314
+ const out = `line to ${extend(vertices[(i + 1) % vertices.length])}`;
315
+ return outside ? [out] : [`line to ${extend(vertices[i])}`, out];
316
+ }
317
+ return outside ? [`line to ${at(pts[i])}`, ...segments] : segments;
318
+ });
319
+ const from = extend ? extend(vertices[0]) : at(start);
320
+ return `shape(from ${from}, ${commands.join(", ")}, close)`;
277
321
  };
278
322
 
279
- const betweenVars = ({ size, points }) => {
323
+ const betweenVars = ({ size, points, counts = {} }) => {
280
324
  const vars = { "--between-size": `${size}%` };
281
325
  points.forEach(([left, top], i) => {
282
326
  vars[`--between-${i + 1}-x`] = `${left}%`;
283
327
  vars[`--between-${i + 1}-y`] = `${top}%`;
284
328
  });
329
+ for (const [count, override] of Object.entries(counts)) {
330
+ if (override.size !== undefined) {
331
+ vars[`--between-${count}-size`] = `${override.size}%`;
332
+ }
333
+ override.points.forEach(([left, top], i) => {
334
+ vars[`--between-${count}-${i + 1}-x`] = `${left}%`;
335
+ vars[`--between-${count}-${i + 1}-y`] = `${top}%`;
336
+ });
337
+ }
285
338
  return vars;
286
339
  };
287
340
 
@@ -296,13 +349,45 @@ const onVars = ({ size, counts }) => {
296
349
  return vars;
297
350
  };
298
351
 
299
- export default plugin(({ addUtilities, theme }) => {
352
+ // Every layout var an ordinary sets on its shield, for an inescutcheon or canton to reset.
353
+ const layoutVars = () => {
354
+ const allVars = [];
355
+
356
+ for (const division of Object.values(BETWEEN)) {
357
+ const vars = betweenVars(division);
358
+ allVars.push(...Object.keys(vars));
359
+ }
360
+
361
+ for (const division of Object.values(ON)) {
362
+ const vars = onVars(division);
363
+ allVars.push(...Object.keys(vars));
364
+ }
365
+
366
+ const chiefVars = ["--field-top", "--chief-points-y", "--chief-points-size"];
367
+ allVars.push(...chiefVars);
368
+
369
+ return [...new Set(allVars)];
370
+ };
371
+
372
+ // Each tincture's class suffix and colour, and "of the first" (see --the-first in grailwind.css).
373
+ const PAINTS = [
374
+ ...TINCTURES.map((tincture) => [tincture, `var(--color-${tincture})`]),
375
+ [OF_THE_FIRST, "var(--the-first, transparent)"],
376
+ ];
377
+
378
+ export default plugin(({ addBase, addUtilities, theme }) => {
379
+ // Unset on an inescutcheon or canton, so it can't inherit its shield's layout. In the base
380
+ // layer, so an ordinary on it still sets its own.
381
+ addBase({
382
+ ".inescutcheon, .canton": Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
383
+ });
384
+
300
385
  const utilities = {};
301
386
 
302
- for (const tincture of TINCTURES) {
387
+ for (const [tincture, colour] of PAINTS) {
303
388
  utilities[`.fimbriated-${tincture}`] = {
304
389
  "--fimbriated": "1",
305
- "--fimbriation": `var(--color-${tincture})`,
390
+ "--fimbriation": colour,
306
391
  };
307
392
  }
308
393
 
@@ -329,10 +414,23 @@ export default plugin(({ addUtilities, theme }) => {
329
414
  });
330
415
 
331
416
  for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
417
+ const chief = ordinary === "chief";
418
+ const placed = chief ? OVER_BORDURE : { inset: "var(--field-top, 0%) 0 0" };
419
+ const plain = chief
420
+ ? `polygon(${vertices.map(outer).join(", ")}) content-box`
421
+ : polygon(vertices);
422
+ // Browsers without shape() keep the straight polygon.
423
+ const lined = (line) => [
424
+ plain,
425
+ chief
426
+ ? `${shape(vertices, line, SCALLOP, outer)} content-box`
427
+ : shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP),
428
+ ];
429
+
332
430
  // Covers the whole shield, so the swapped field lines up with the real one,
333
431
  // and the clip moves the ordinary below any chief instead.
334
432
  utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
335
- inset: "0",
433
+ ...(chief ? OVER_BORDURE : { inset: "0" }),
336
434
  // Registered not to inherit, so a shield inside another can't pick up its swap.
337
435
  ...Object.fromEntries(
338
436
  ["color", "image", "size", "position", "repeat"].map((n) => [
@@ -345,52 +443,50 @@ export default plugin(({ addUtilities, theme }) => {
345
443
  "background-size": "var(--counterchange-size, auto)",
346
444
  "background-position": "var(--counterchange-position, 0 0)",
347
445
  "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(", ")})`,
446
+ // The swapped field's tiles would wrap into the bordure's strips, so the bordure shows there.
447
+ ...(chief && { "background-clip": "content-box" }),
448
+ "clip-path": chief
449
+ ? plain
450
+ : `polygon(${vertices.map(([x, y]) => `${pct(x)} ${belowChief(y)}`).join(", ")})`,
352
451
  });
353
452
 
354
- for (const tincture of TINCTURES) {
453
+ for (const [tincture, colour] of PAINTS) {
355
454
  utilities[`.${ordinary}-${tincture}`] = ordinaryRules(ordinary, {
356
- inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
455
+ ...placed,
357
456
  ...(BANDS[ordinary]
358
457
  ? {
359
458
  "--fimbriated": "inherit",
360
459
  "--fimbriation": "inherit",
361
- "background-color": `var(--fimbriation, var(--color-${tincture}))`,
362
- ...bands(BANDS[ordinary], tincture),
460
+ "background-color": `var(--fimbriation, ${colour})`,
461
+ ...bands(BANDS[ordinary], colour),
363
462
  "clip-path": widenedPolygon(vertices),
364
463
  }
365
464
  : {
366
- "background-color": `var(--color-${tincture})`,
367
- "clip-path": polygon(vertices),
465
+ "background-color": colour,
466
+ "clip-path": plain,
368
467
  }),
369
468
  });
370
- // Not fimbriated. Browsers without shape() keep the straight polygon.
469
+ // Not fimbriated.
371
470
  for (const [name, line] of Object.entries(LINES)) {
372
471
  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)],
472
+ ...placed,
473
+ "background-color": colour,
474
+ "clip-path": lined(line),
376
475
  });
377
476
  }
378
477
  }
379
478
 
380
479
  // A plain polygon, without the fimbriation vars, so fimbriated-* can't reach a fur ordinary.
381
480
  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
- };
481
+ const pseudo = { ...placed, ...furBackground(fur, theme) };
386
482
  utilities[`.${ordinary}-${fur}`] = ordinaryRules(ordinary, {
387
483
  ...pseudo,
388
- "clip-path": polygon(vertices),
484
+ "clip-path": plain,
389
485
  });
390
486
  for (const [name, line] of Object.entries(LINES)) {
391
487
  utilities[`.${ordinary}-${name}-${fur}`] = ordinaryRules(ordinary, {
392
488
  ...pseudo,
393
- "clip-path": [polygon(vertices), shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP)],
489
+ "clip-path": lined(line),
394
490
  });
395
491
  }
396
492
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.1",
26
30
  "@html-eslint/parser": "^0.67.0",
27
31
  "@tailwindcss/cli": "^4.3.3",
28
32
  "eslint": "^10.14.0",