grailwind 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-11-09
10
+
11
+ ### Added
12
+
13
+ - `per-chevron-*`: a field divided along a chevron's centre line, the first
14
+ tincture in chief.
15
+ - `bendy-*`, `bendy-sinister-*`, `chevronny-*` (each with `-of-eight-*`) and
16
+ `chequy-*`: six or eight pieces, or five squares by six.
17
+
18
+ ### Changed
19
+
20
+ - `@grailwind/eslint-plugin` 0.1.0-beta.3 needs `grailwind` 0.7.x.
21
+
22
+ ### Fixed
23
+
24
+ - On a shield, `quarterly` and two-part `per-fess` split at the fess point, as
25
+ the divisions do, not halfway down.
26
+ - No seam down the centre of `per-saltire-*` at odd widths.
27
+
28
+ ## [0.6.0] - 2026-11-06
29
+
30
+ ### Added
31
+
32
+ - Crosses and the shakefork as charges: `cross-couped`, `cross-potent`,
33
+ `cross-pattee`, `maltese-cross`, `cross-crosslet`, `cross-moline`,
34
+ `cross-fleury` and `shakefork`.
35
+ - `semy`: a semé, one built-in charge strewn across the field, e.g. bezanty or
36
+ crusilly.
37
+ - Voided ordinaries, e.g. `cross-voided-argent`: the field shows through.
38
+ `cross-voided-counterchanged` and the like counterchange the outline.
39
+ - Reversed ordinaries: `pile-reversed-*`, `chevron-reversed-*` and
40
+ `pall-reversed-*`, upside down.
41
+
42
+ ### Changed
43
+
44
+ - `@grailwind/eslint-plugin` 0.1.0-beta.2 needs `grailwind` 0.6.x, for voided
45
+ ordinaries.
46
+
47
+ ### Fixed
48
+
49
+ - Charges, a canton or an inescutcheon in a quarter (or a half of `per-pale` or
50
+ `per-fess`) are placed and counterchanged within it, under the shield's own
51
+ ordinary, rather than against the whole shield.
52
+
9
53
  ## [0.5.0] - 2026-11-04
10
54
 
11
55
  ### Added
@@ -113,7 +157,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
113
157
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
114
158
  custom SVG charges.
115
159
 
116
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.5.0...HEAD
160
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.7.0...HEAD
161
+ [0.7.0]: https://github.com/pafirmin/grailwind/compare/v0.6.0...v0.7.0
162
+ [0.6.0]: https://github.com/pafirmin/grailwind/compare/v0.5.0...v0.6.0
117
163
  [0.5.0]: https://github.com/pafirmin/grailwind/compare/v0.4.0...v0.5.0
118
164
  [0.4.0]: https://github.com/pafirmin/grailwind/compare/v0.3.0...v0.4.0
119
165
  [0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
package/README.md CHANGED
@@ -57,17 +57,20 @@ tinctures replace Tailwind's colours entirely.
57
57
  - **The shield:** `escutcheon`, plus `bordure-*`, `inescutcheon` (a small shield at the centre)
58
58
  and `canton` (a ninth of the shield in dexter chief), each with its own field and charges.
59
59
  - **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
60
- `gyronny-or-sable`, `barry-argent-azure` and more, or `per-pale`, `per-fess` and `quarterly` for
61
- layout.
60
+ `gyronny-or-sable`, `barry-argent-azure`, `bendy-argent-gules`, `chequy-or-azure` and more, or
61
+ `per-pale`, `per-fess` and `quarterly` for layout.
62
62
  - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`,
63
63
  `two-bars-or` and more, with `fimbriated-*` for a narrow border,
64
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,
65
67
  `chief-ermine` and the like in a fur, or `chevron-counterchanged` and the like
66
68
  on a divided field.
67
- - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
69
+ - **Charges:** `mullet`, `fleur-de-lis`, `bezant`, crosses and more, arranged by
68
70
  number, `in-pale`, `in-fess`, `in-bend`, `in-bend-sinister`, `on` or `between`
69
- the ordinary, or placed at a named point such as `in-dexter-chief`, sized
70
- 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.
71
74
  - **Your own charges:** any SVG, via `charge-[url(…)]`.
72
75
 
73
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.
@@ -88,6 +117,19 @@ const svgUrl = (shape) =>
88
117
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shape}</svg>`,
89
118
  )}")`;
90
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
+
91
133
  const maskRules = (image) => ({
92
134
  "mask-image": image,
93
135
  "mask-size": "contain",
@@ -143,12 +185,13 @@ export default plugin(({ addUtilities, matchUtilities }) => {
143
185
  const utilities = {};
144
186
 
145
187
  for (const [name, shape] of Object.entries(SHAPES)) {
146
- utilities[`.${name}`] = maskRules(svgUrl(shape));
188
+ utilities[`.${name}`] = { ...maskRules(svgUrl(shape)), "--semy-image": semyTile(shape) };
147
189
  }
148
190
 
149
191
  for (const [name, tincture] of Object.entries(ROUNDELS)) {
150
192
  utilities[`.${name}`] = {
151
193
  ...maskRules(svgUrl(SHAPES.roundel)),
194
+ "--semy-image": semyTile(SHAPES.roundel),
152
195
  "background-color": `var(--color-${tincture})`,
153
196
  };
154
197
  }
@@ -242,6 +285,22 @@ export default plugin(({ addUtilities, matchUtilities }) => {
242
285
  utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
243
286
  }
244
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
+
245
304
  // On a charge, or on `charges` for all of them.
246
305
  utilities[".counterchanged"] = {
247
306
  ".charges > &": counterchanged,
package/data.js CHANGED
@@ -13,6 +13,7 @@ export const DIVISIONS = [
13
13
  "per-fess",
14
14
  "per-bend",
15
15
  "per-bend-sinister",
16
+ "per-chevron",
16
17
  "per-saltire",
17
18
  "quarterly",
18
19
  "gyronny",
@@ -21,8 +22,19 @@ export const DIVISIONS = [
21
22
  "barry-of-ten",
22
23
  "paly",
23
24
  "paly-of-eight",
25
+ "bendy",
26
+ "bendy-of-eight",
27
+ "bendy-sinister",
28
+ "bendy-sinister-of-eight",
29
+ "chevronny",
30
+ "chevronny-of-eight",
31
+ "chequy",
24
32
  ];
25
33
 
34
+ // Ordinaries that can be reversed, e.g. `pile-reversed-or`: the same shape upside down.
35
+ export const REVERSIBLE = ["chevron", "pall", "pile"];
36
+ const reversed = (ordinary) => `${ordinary}-reversed`;
37
+
26
38
  export const ORDINARIES = [
27
39
  "pale",
28
40
  "fess",
@@ -37,6 +49,7 @@ export const ORDINARIES = [
37
49
  "pall",
38
50
  "chief",
39
51
  "pile",
52
+ ...REVERSIBLE.map(reversed),
40
53
  ];
41
54
 
42
55
  export const CHARGES = [
@@ -47,6 +60,14 @@ export const CHARGES = [
47
60
  "billet",
48
61
  "crescent",
49
62
  "fleur-de-lis",
63
+ "cross-couped",
64
+ "cross-potent",
65
+ "cross-pattee",
66
+ "maltese-cross",
67
+ "cross-crosslet",
68
+ "cross-moline",
69
+ "cross-fleury",
70
+ "shakefork",
50
71
  ];
51
72
 
52
73
  // Roundels have their own names by tincture.
@@ -61,6 +82,21 @@ export const ROUNDELS = {
61
82
  // Lines of partition for an ordinary's edge, e.g. `fess-engrailed-or`.
62
83
  export const LINES = ["engrailed", "invected"];
63
84
 
85
+ // Ordinaries that can be voided, e.g. `cross-voided-argent`.
86
+ export const VOIDABLE = [
87
+ "pale",
88
+ "fess",
89
+ "bar",
90
+ "bend",
91
+ "bend-sinister",
92
+ "chevron",
93
+ "cross",
94
+ "saltire",
95
+ "pall",
96
+ "pile",
97
+ ...REVERSIBLE.map(reversed),
98
+ ];
99
+
64
100
  // Each fur's [ground, spot] tinctures.
65
101
  export const FURS = {
66
102
  ermine: ["argent", "sable"],
@@ -90,6 +126,7 @@ export const ARRANGEMENTS = [
90
126
  "in-bend-sinister",
91
127
  "in-chief",
92
128
  "in-base",
129
+ "semy",
93
130
  ];
94
131
 
95
132
  // Where charges go for "between", as [left%, top%], plus their size as % of width.
@@ -128,3 +165,28 @@ export const ON = {
128
165
  pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
129
166
  pile: { size: 14, counts: { 1: [[50, 25]], 3: [[50, 14], [50, 34], [50, 54]] } },
130
167
  };
168
+
169
+ const mirror = (points) => points.map(([x, y]) => [x, 100 - y]);
170
+ const mapValues = (object, fn) =>
171
+ Object.fromEntries(Object.entries(object).map(([key, value]) => [key, fn(value)]));
172
+
173
+ for (const ordinary of REVERSIBLE) {
174
+ const { size, points, counts = {} } = BETWEEN[ordinary];
175
+ BETWEEN[reversed(ordinary)] = {
176
+ size,
177
+ points: mirror(points),
178
+ counts: mapValues(counts, (count) => ({ ...count, points: mirror(count.points) })),
179
+ };
180
+ ON[reversed(ordinary)] = {
181
+ size: ON[ordinary].size,
182
+ counts: mapValues(ON[ordinary].counts, mirror),
183
+ };
184
+ }
185
+
186
+ // Mirrored, the lower charges would clip on the heater's curved flanks.
187
+ // prettier-ignore
188
+ BETWEEN["chevron-reversed"] = {
189
+ ...BETWEEN["chevron-reversed"],
190
+ points: [[28, 74], [72, 74], [50, 30]],
191
+ counts: { 6: { size: 12, points: [[26, 78], [50, 89], [74, 78], [38, 34], [62, 34], [50, 16]] } },
192
+ };
package/divisions.js CHANGED
@@ -22,6 +22,57 @@ const stripes = (to, pieces) => (a, b) => {
22
22
  };
23
23
  };
24
24
 
25
+ // How much of the corner-to-corner extent the shield covers: the base corner is cut away by the
26
+ // border-radius of 0 0 50% 50% / 0 0 45% 45% and the 5:6 aspect ratio in grailwind.css. It runs
27
+ // from the chief corner the bands start in to the tangent on the base curve.
28
+ const BENDY_VISIBLE = (() => {
29
+ const [width, height] = [5, 6];
30
+ const [radiusX, radiusY] = [width / 2, 0.45 * height];
31
+ const [centreX, centreY] = [width / 2, height - radiusY];
32
+ const tangent = Math.sqrt((height * radiusX) ** 2 + (width * radiusY) ** 2);
33
+ return (width * height - height * centreX + width * centreY + tangent) / (2 * width * height);
34
+ })();
35
+
36
+ // Diagonal bands parallel to per bend, `pieces` equal ones across the visible part of the shield,
37
+ // the last running to the edge. Explicit stops, not a repeating gradient, so a canton's square
38
+ // corners don't show the first tincture again.
39
+ const bendy = (to, pieces) => (a, b) => {
40
+ const stops = [];
41
+ for (let i = 0; i < pieces; i++) {
42
+ const start = i === 0 ? 0 : ((i * BENDY_VISIBLE * 100) / pieces).toFixed(2);
43
+ const end = i === pieces - 1 ? 100 : (((i + 1) * BENDY_VISIBLE * 100) / pieces).toFixed(2);
44
+ stops.push(`${i % 2 === 0 ? a : b} ${start}% ${end}%`);
45
+ }
46
+ return layer(`linear-gradient(to ${to}, ${stops.join(", ")})`);
47
+ };
48
+
49
+ // The direction of `to bottom right` on the shield (aspect-ratio 5 / 6 in grailwind.css), so
50
+ // chevronny's lines run parallel to per chevron's, the field's diagonals.
51
+ const CHEVRON_ANGLE = 180 - (Math.atan(6 / 5) * 180) / Math.PI;
52
+
53
+ // A half box is 75% as long as the whole along this angle (45/sqrt(61) over 60/sqrt(61)).
54
+ // Plain, the sinister half sits over a full-width dexter layer, which hides the centre seam.
55
+ // With a fur the layers can't overlap, as the transparent stripes would show the layer below.
56
+ const chevronny = (pieces) => (a, b, fur) => {
57
+ const stripe = (angle, length) =>
58
+ `repeating-linear-gradient(${angle}deg, ${a} 0 calc(${length}% / ${pieces}), ${b} 0 calc(${2 * length}% / ${pieces}))`;
59
+ const sinister = stripe(-CHEVRON_ANGLE, 100);
60
+
61
+ return fur
62
+ ? {
63
+ "background-image": [stripe(CHEVRON_ANGLE, 100), sinister].join(", "),
64
+ "background-size": "50% 100%",
65
+ "background-position": "left, right",
66
+ "background-repeat": "no-repeat",
67
+ }
68
+ : {
69
+ "background-image": [sinister, stripe(CHEVRON_ANGLE, 75)].join(", "),
70
+ "background-size": "50% 100%, 100% 100%",
71
+ "background-position": "right, left",
72
+ "background-repeat": "no-repeat",
73
+ };
74
+ };
75
+
25
76
  const DIVISIONS = {
26
77
  // Vertical split: first on the dexter (viewer's left).
27
78
  "per-pale": (a, b) => layer(`linear-gradient(to right, ${a} 50%, ${b} 50%)`),
@@ -36,18 +87,39 @@ const DIVISIONS = {
36
87
  // Line from sinister chief to dexter base: first in the upper half.
37
88
  "per-bend-sinister": (a, b) => layer(`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`),
38
89
 
90
+ "per-chevron": (a, b) =>
91
+ a === "transparent"
92
+ ? {
93
+ "background-image": [
94
+ `linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
95
+ `linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
96
+ `linear-gradient(${b}, ${b})`,
97
+ ].join(", "),
98
+ "background-size": "50% 50%, 50% 50%, 100% 15%",
99
+ "background-position": "left 70%, right 70%, bottom",
100
+ "background-repeat": "no-repeat",
101
+ }
102
+ : {
103
+ "background-image": [
104
+ `linear-gradient(to bottom right, ${a} 42.5%, transparent 42.5%)`,
105
+ `linear-gradient(to bottom left, ${a} 42.5%, ${b} 42.5%)`,
106
+ ].join(", "),
107
+ },
108
+
39
109
  // 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.
110
+ // Gyronny's four tiles, with the top-right and bottom-left swapped. Two strips under them fill
111
+ // the sub-pixel seam where the tiles meet: the first tincture down, the second across.
41
112
  "per-saltire": (a, b) => ({
42
- "background-color": b,
43
113
  "background-image": [
44
114
  `linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
45
115
  `linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
46
116
  `linear-gradient(to bottom left, ${b} 50%, ${a} 50%)`,
47
117
  `linear-gradient(to bottom right, ${b} 50%, ${a} 50%)`,
118
+ `linear-gradient(${a}, ${a})`,
119
+ `linear-gradient(${b}, ${b})`,
48
120
  ].join(", "),
49
- "background-size": "50% 50%",
50
- "background-position": "left top, right top, right bottom, left bottom",
121
+ "background-size": "50% 50%, 50% 50%, 50% 50%, 50% 50%, 2px 100%, 100% 2px",
122
+ "background-position": "left top, right top, right bottom, left bottom, center, center",
51
123
  "background-repeat": "no-repeat",
52
124
  }),
53
125
 
@@ -83,22 +155,42 @@ const DIVISIONS = {
83
155
  "barry-of-ten": stripes("bottom", 10),
84
156
  paly: stripes("right", 6),
85
157
  "paly-of-eight": stripes("right", 8),
158
+
159
+ // Bendy has the first tincture in the sinister chief corner, bendy sinister in the dexter.
160
+ bendy: bendy("bottom left", 6),
161
+ "bendy-of-eight": bendy("bottom left", 8),
162
+ "bendy-sinister": bendy("bottom right", 6),
163
+ "bendy-sinister-of-eight": bendy("bottom right", 8),
164
+
165
+ // The first tincture in the chief corners.
166
+ chevronny: chevronny(6),
167
+ "chevronny-of-eight": chevronny(8),
168
+
169
+ // 5 across and 6 down, the first tincture in the dexter chief square. Conic starts at the top.
170
+ // The repeat must be explicit, as with stripes.
171
+ chequy: (a, b) => ({
172
+ "background-image": `conic-gradient(${b} 0 25%, ${a} 0 50%, ${b} 0 75%, ${a} 0)`,
173
+ "background-size": "40% calc(100% / 3)",
174
+ "background-repeat": "repeat",
175
+ }),
86
176
  };
87
177
 
88
178
  // A division's background rules as one { image, size, position, repeat } per layer.
89
179
  const toLayers = (rules) => {
90
- return rules["background-image"].split(/, (?=linear-gradient)/).map((gradient, i) => {
91
- const size = (rules["background-size"] ?? "100% 100%").split(", ");
92
- const position = (rules["background-position"] ?? "0 0").split(", ");
93
- const repeat = (rules["background-repeat"] ?? "no-repeat").split(", ");
94
-
95
- return {
96
- image: gradient,
97
- size: size[i % size.length],
98
- position: position[i % position.length],
99
- repeat: repeat[i % repeat.length],
100
- };
101
- });
180
+ return rules["background-image"]
181
+ .split(/, (?=(?:repeating-)?(?:linear|conic)-gradient)/)
182
+ .map((gradient, i) => {
183
+ const size = (rules["background-size"] ?? "100% 100%").split(", ");
184
+ const position = (rules["background-position"] ?? "0 0").split(", ");
185
+ const repeat = (rules["background-repeat"] ?? "no-repeat").split(", ");
186
+
187
+ return {
188
+ image: gradient,
189
+ size: size[i % size.length],
190
+ position: position[i % position.length],
191
+ repeat: repeat[i % repeat.length],
192
+ };
193
+ });
102
194
  };
103
195
 
104
196
  // A division with one fur: the fur's part is left transparent, over the fur's own layers.
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
@@ -120,6 +120,13 @@
120
120
  display: grid;
121
121
  grid-auto-flow: column;
122
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
+ }
123
130
  }
124
131
 
125
132
  /* Per fess: split horizontally into chief / base. */
@@ -127,6 +134,20 @@
127
134
  display: grid;
128
135
  grid-auto-flow: row;
129
136
  grid-auto-rows: 1fr;
137
+
138
+ /* On a shield, two parts split at the fess point, FESS_POINT in divisions.js.
139
+ * More parts stay equal. */
140
+ &:is(.escutcheon, .escutcheon *) {
141
+ &:has(> :last-child:nth-child(2)) {
142
+ grid-template-rows: 45fr 55fr;
143
+ }
144
+
145
+ /* See per-pale. */
146
+ & > * {
147
+ position: relative;
148
+ contain: layout;
149
+ }
150
+ }
130
151
  }
131
152
 
132
153
  /* Quarterly: four quarters, numbered 1–4 across then down.
@@ -141,6 +162,17 @@
141
162
  --the-first: var(--field-first);
142
163
  --outer-inset: 0px;
143
164
  }
165
+
166
+ /* On a shield, split at the fess point, as per-fess. */
167
+ &:is(.escutcheon, .escutcheon *) {
168
+ grid-template-rows: 45fr 55fr;
169
+
170
+ /* See per-pale. */
171
+ & > * {
172
+ position: relative;
173
+ contain: layout;
174
+ }
175
+ }
144
176
  }
145
177
 
146
178
  /* ---------------------------------------------------------------------------
package/ordinaries.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Grailwind CSS: ordinaries, e.g. `fess-or`.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
- import { BETWEEN, FURS, OF_THE_FIRST, 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
@@ -34,6 +34,10 @@ const ORDINARIES = {
34
34
  chief: [[0, 0], [100, 0], [100, 30], [0, 30]],
35
35
  pile: [[20, 0], [80, 0], [50, 85]],
36
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
+ }
37
41
 
38
42
  const num = (v) => +v.toFixed(3);
39
43
  const pct = (v) => (num(v) === 0 ? "0" : `${num(v)}%`);
@@ -59,6 +63,7 @@ const outer = ([x, y]) => {
59
63
  // A y in % of the field, as % of the whole shield when a chief takes the top of it.
60
64
  const belowChief = (y) =>
61
65
  y === 100 ? "100%" : `calc(${num(y)}% + var(--field-top, 0%) * ${num(1 - y / 100)})`;
66
+ const pointBelowChief = ([x, y]) => `${pct(x)} ${belowChief(y)}`;
62
67
 
63
68
  // Fimbriation: the ordinary's clip is widened and filled with the fimbriation
64
69
  // tincture, and the ordinary's own tincture is painted over it as gradient
@@ -86,15 +91,15 @@ const offsetLine = (a, b, outward, distance) => {
86
91
  return { n, c: n[0] * ax + n[1] * ay + distance };
87
92
  };
88
93
 
89
- // How far each vertex moves, in %, when fimbriated. Sides on the shield's edge
90
- // don't move, so their vertices slide along it.
91
- 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) => {
92
97
  const outward = Math.sign(signedArea(vertices));
93
98
  const count = vertices.length;
94
99
  const line = (i) => {
95
100
  const a = vertices[i];
96
101
  const b = vertices[(i + 1) % count];
97
- return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : FIMBRIATION);
102
+ return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : distance);
98
103
  };
99
104
  return vertices.map((v, i) => {
100
105
  const p = line((i - 1 + count) % count);
@@ -106,6 +111,23 @@ const widen = (vertices) => {
106
111
  });
107
112
  };
108
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
+
109
131
  const shift = (v, d) =>
110
132
  num(d) === 0
111
133
  ? pct(v)
@@ -162,6 +184,28 @@ const BANDS = {
162
184
  pile: [half("u-v", "end", [20, 0, 50, 85]), half("u+v", "start", [50, 0, 80, 85]), seam(83)],
163
185
  };
164
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
+ }
204
+ };
205
+ for (const ordinary of REVERSIBLE) {
206
+ BANDS[`${ordinary}-reversed`] = BANDS[ordinary].map(reverseBand);
207
+ }
208
+
165
209
  const DIRECTION = {
166
210
  x: "to right",
167
211
  y: "to bottom",
@@ -223,6 +267,19 @@ const bands = (layers, colour) => ({
223
267
  // box is shorter, so they are slightly squashed.
224
268
  const SCALLOP = 14;
225
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
+ }
226
283
  const DEPTH = 0.35;
227
284
 
228
285
  const at = ([x, y]) => point([(x * 100) / W, (y * 100) / H]);
@@ -376,10 +433,11 @@ const PAINTS = [
376
433
  ];
377
434
 
378
435
  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.
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.
381
438
  addBase({
382
- ".inescutcheon, .canton": Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
439
+ ".inescutcheon, .canton, :is(.escutcheon, .escutcheon *):is(.quarterly, .per-pale, .per-fess) > *":
440
+ Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
383
441
  });
384
442
 
385
443
  const utilities = {};
@@ -429,7 +487,7 @@ export default plugin(({ addBase, addUtilities, theme }) => {
429
487
 
430
488
  // Covers the whole shield, so the swapped field lines up with the real one,
431
489
  // and the clip moves the ordinary below any chief instead.
432
- utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
490
+ const counterchanged = {
433
491
  ...(chief ? OVER_BORDURE : { inset: "0" }),
434
492
  // Registered not to inherit, so a shield inside another can't pick up its swap.
435
493
  ...Object.fromEntries(
@@ -445,9 +503,10 @@ export default plugin(({ addBase, addUtilities, theme }) => {
445
503
  "background-repeat": "var(--counterchange-repeat, repeat)",
446
504
  // The swapped field's tiles would wrap into the bordure's strips, so the bordure shows there.
447
505
  ...(chief && { "background-clip": "content-box" }),
448
- "clip-path": chief
449
- ? plain
450
- : `polygon(${vertices.map(([x, y]) => `${pct(x)} ${belowChief(y)}`).join(", ")})`,
506
+ };
507
+ utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
508
+ ...counterchanged,
509
+ "clip-path": chief ? plain : `polygon(${vertices.map(pointBelowChief).join(", ")})`,
451
510
  });
452
511
 
453
512
  for (const [tincture, colour] of PAINTS) {
@@ -490,6 +549,29 @@ export default plugin(({ addBase, addUtilities, theme }) => {
490
549
  });
491
550
  }
492
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
+ }
493
575
  }
494
576
 
495
577
  addUtilities(utilities);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -9,7 +9,8 @@
9
9
  "dev:watch": "tailwindcss -i docs/app.css -o docs/site.css --watch=always & python3 -m http.server ${PORT:-8000} -d docs",
10
10
  "lint": "eslint .",
11
11
  "format": "prettier --write .",
12
- "format:check": "prettier --check ."
12
+ "format:check": "prettier --check .",
13
+ "prepare": "husky"
13
14
  },
14
15
  "keywords": [
15
16
  "tailwindcss",
@@ -26,10 +27,12 @@
26
27
  "license": "MIT",
27
28
  "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
28
29
  "devDependencies": {
29
- "@grailwind/eslint-plugin": "^0.1.0-beta.1",
30
+ "@grailwind/eslint-plugin": "^0.1.0-beta.3",
30
31
  "@html-eslint/parser": "^0.67.0",
31
32
  "@tailwindcss/cli": "^4.3.3",
32
33
  "eslint": "^10.14.0",
34
+ "husky": "^9.1.7",
35
+ "lint-staged": "^17.6.0",
33
36
  "playwright": "~1.64.1",
34
37
  "prettier": "^3.9.10",
35
38
  "tailwindcss": "^4.3.3"
@@ -71,5 +74,8 @@
71
74
  },
72
75
  "bugs": {
73
76
  "url": "https://github.com/pafirmin/grailwind/issues"
77
+ },
78
+ "lint-staged": {
79
+ "*": "prettier --write --ignore-unknown"
74
80
  }
75
81
  }