grailwind 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-11-01
10
+
11
+ ### Added
12
+
13
+ - Counterchanged ordinaries: `<ordinary>-counterchanged` swaps the tinctures
14
+ of a divided field, e.g. `per-pale-argent-gules chevron-counterchanged`.
15
+ - Counterchanged charges: `counterchanged` on a charge, or on the `charges`
16
+ container for all of them.
17
+
18
+ ### Changed
19
+
20
+ - The horizontal line of `per-fess-*` and `quarterly-*` fields now passes
21
+ through the fess point, 45% down the shield, where the fess and cross are
22
+ centred. It was at 50%.
23
+ - Charges are centred with negative margins instead of `translate`, so
24
+ Tailwind's `translate-*` on a charge now moves it from its place.
25
+ - `escutcheon` sets `contain: layout`.
26
+
27
+ ## [0.2.0] - 2026-11-01
28
+
29
+ ### Added
30
+
31
+ - Charges `on` an ordinary: `charges on` places one, two, three or five charges
32
+ along the pale, fess, bend, bend sinister, chevron, cross, saltire or pall.
33
+ - Fimbriation: `fimbriated-<tincture>` edges an ordinary with a narrow border,
34
+ e.g. `cross-azure fimbriated-argent`.
35
+
36
+ ### Fixed
37
+
38
+ - Charges at `in-dexter-chief`, `in-chief` and `in-sinister-chief` now sit on
39
+ the chief when one is present, and charges are drawn above it.
40
+
41
+ ## [0.1.0] - 2026-10-31
42
+
43
+ Initial release: tinctures, divisions, ordinaries, charges, strict mode and
44
+ custom SVG charges.
45
+
46
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.3.0...HEAD
47
+ [0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
48
+ [0.2.0]: https://github.com/pafirmin/grailwind/compare/v0.1.0...v0.2.0
49
+ [0.1.0]: https://github.com/pafirmin/grailwind/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Grailwind CSS
2
2
 
3
+ ![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
+
3
5
  Heraldic blazon for [Tailwind CSS](https://tailwindcss.com). Draw coats of
4
6
  arms, or style anything else, in the language heralds have used for 800 years:
5
7
 
@@ -13,10 +15,10 @@ arms, or style anything else, in the language heralds have used for 800 years:
13
15
  </div>
14
16
  ```
15
17
 
16
- That's *Azure, a chevron Or between three mullets Argent*: a blue shield, a
18
+ That's _Azure, a chevron Or between three mullets Argent_: a blue shield, a
17
19
  gold chevron, and three silver stars around it.
18
20
 
19
- **[Read the documentation](https://pafirmin.github.io/grailwind/)** for every class, with live examples.
21
+ **[Read the documentation](https://grailwind.dev/)** for every class, with live examples.
20
22
 
21
23
  > Grailwind is a light-hearted project at version 0.x, so class names may
22
24
  > still change.
@@ -51,20 +53,21 @@ tinctures replace Tailwind's colours entirely.
51
53
  `gyronny-or-sable` and more, or `per-pale`, `per-fess` and `quarterly` for
52
54
  layout.
53
55
  - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`
54
- and more.
56
+ and more, with `fimbriated-*` for a narrow border, or `chevron-counterchanged`
57
+ and the like on a divided field.
55
58
  - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
56
- number, `in-pale`, `in-fess`, `in-bend` or `between` the ordinary, or
57
- placed at a named point such as `in-dexter-chief`, and sized with
58
- `charge-size-*`.
59
+ number, `in-pale`, `in-fess`, `in-bend`, `on` or `between` the ordinary, or
60
+ placed at a named point such as `in-dexter-chief`, sized with
61
+ `charge-size-*`, and `counterchanged` on a divided field.
59
62
  - **Your own charges:** any SVG, via `charge-[url(…)]`.
60
63
 
61
64
  ## Development
62
65
 
63
66
  ```sh
64
67
  npm install
65
- npm test # checks both the default and strict builds
66
- npm run build # builds the docs site; `npm run dev` rebuilds on change
67
- python3 -m http.server -d docs
68
+ npm test # checks both the default and strict builds
69
+ npm run format # formats with Prettier; CI runs format:check
70
+ npm run dev:watch # rebuilds the docs site on change and serves it at http://localhost:8000
68
71
  ```
69
72
 
70
73
  ## Licence
package/charges.js CHANGED
@@ -41,6 +41,7 @@ const ROUNDELS = {
41
41
 
42
42
  // Points as [left%, top%] of the shield, plus a charge size as % of its width.
43
43
  // The default for each number of charges, e.g. three are placed 2 and 1.
44
+ // prettier-ignore
44
45
  const DEFAULTS = {
45
46
  1: { size: 50, points: [[50, 45]] },
46
47
  2: { size: 30, points: [[50, 24], [50, 66]] },
@@ -63,6 +64,10 @@ const POINTS = {
63
64
  };
64
65
  const POINT_SIZE = 22;
65
66
 
67
+ // With a chief on the shield, these points sit on it (see ordinaries.js).
68
+ const CHIEF_POINTS = new Set(["dexter-chief", "chief", "sinister-chief"]);
69
+
70
+ // prettier-ignore
66
71
  const ARRANGEMENTS = {
67
72
  "in-pale": {
68
73
  2: { size: 30, points: [[50, 24], [50, 66]] },
@@ -93,9 +98,30 @@ const maskRules = (image) => ({
93
98
  const place = ([left, top], size) => ({
94
99
  left: `${left}%`,
95
100
  top: `${top}%`,
96
- width: `var(--charge-size, ${size}%)`,
101
+ "--charge-width": `var(--charge-size, ${size}%)`,
97
102
  });
98
103
 
104
+ // The field with its tinctures swapped, from the shield's division (see divisions.js).
105
+ const COUNTERCHANGE = ["color", "image", "size", "position", "repeat"];
106
+ const inheritCounterchange = Object.fromEntries(
107
+ COUNTERCHANGE.map((n) => [`--counterchange-${n}`, "inherit"]),
108
+ );
109
+
110
+ // A layer the size of the shield, as the escutcheon is the containing block
111
+ // for fixed positioning. The charge's mask cuts it to shape.
112
+ const counterchanged = {
113
+ ...inheritCounterchange,
114
+ "&::before": {
115
+ content: '""',
116
+ position: "fixed",
117
+ inset: "0",
118
+ ...inheritCounterchange,
119
+ ...Object.fromEntries(
120
+ COUNTERCHANGE.map((n) => [`background-${n}`, `var(--counterchange-${n})`]),
121
+ ),
122
+ },
123
+ };
124
+
99
125
  const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
100
126
 
101
127
  export default plugin(({ addUtilities, matchUtilities }) => {
@@ -119,17 +145,22 @@ export default plugin(({ addUtilities, matchUtilities }) => {
119
145
  const charges = {
120
146
  position: "absolute",
121
147
  inset: "var(--field-top, 0%) 0 0",
148
+ // Above the chief, which is painted last, so charges can sit on it.
149
+ "z-index": "1",
150
+ ...inheritCounterchange,
122
151
  "& > *": {
123
152
  position: "absolute",
124
153
  "aspect-ratio": "1",
125
- translate: "-50% -50%",
154
+ // Centred with margins, not translate: a transform would stop a
155
+ // counterchanged charge's fixed layer from lining up with the shield.
156
+ width: "var(--charge-width)",
157
+ margin: "calc(var(--charge-width) / -2) 0 0 calc(var(--charge-width) / -2)",
126
158
  "object-fit": "contain",
127
159
  },
128
160
  };
129
161
  for (const [count, { size, points }] of Object.entries(DEFAULTS)) {
130
162
  points.forEach((point, i) => {
131
- charges[`&:where(${hasCount(count)}) > :where(:nth-child(${i + 1}))`] =
132
- place(point, size);
163
+ charges[`&:where(${hasCount(count)}) > :where(:nth-child(${i + 1}))`] = place(point, size);
133
164
  });
134
165
  }
135
166
  utilities[".charges"] = charges;
@@ -150,14 +181,48 @@ export default plugin(({ addUtilities, matchUtilities }) => {
150
181
  between[`& > :nth-child(${i})`] = {
151
182
  left: `var(--between-${i}-x)`,
152
183
  top: `var(--between-${i}-y)`,
153
- width: "var(--charge-size, var(--between-size))",
184
+ "--charge-width": "var(--charge-size, var(--between-size))",
154
185
  };
155
186
  }
156
187
  utilities[".between"] = between;
157
188
 
158
- for (const [name, point] of Object.entries(POINTS)) {
159
- utilities[`.in-${name}`] = { ".charges > &": place(point, POINT_SIZE) };
189
+ // Falls back to the plain-field default when the ordinary has no points for this count.
190
+ // An undefined var() would otherwise unset `left`/`top` instead of deferring to the :where() default.
191
+ const on = {};
192
+ for (const count of [1, 2, 3, 5]) {
193
+ const fallback = DEFAULTS[count];
194
+ for (let i = 1; i <= count; i++) {
195
+ const [left, top] = fallback?.points[i - 1] ?? [];
196
+ const at = (axis, value) =>
197
+ value === undefined
198
+ ? `var(--on-${count}-${i}-${axis})`
199
+ : `var(--on-${count}-${i}-${axis}, ${value}%)`;
200
+ on[`&${hasCount(count)} > :nth-child(${i})`] = {
201
+ left: at("x", left),
202
+ top: at("y", top),
203
+ "--charge-width": `var(--charge-size, var(--on-size, ${fallback?.size ?? 16}%))`,
204
+ };
205
+ }
160
206
  }
207
+ utilities[".on"] = on;
208
+
209
+ for (const [name, [left, top]] of Object.entries(POINTS)) {
210
+ utilities[`.in-${name}`] = {
211
+ ".charges > &": CHIEF_POINTS.has(name)
212
+ ? {
213
+ left: `${left}%`,
214
+ top: `var(--chief-points-y, ${top}%)`,
215
+ "--charge-width": `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
216
+ }
217
+ : place([left, top], POINT_SIZE),
218
+ };
219
+ }
220
+
221
+ // On a charge, or on `charges` for all of them.
222
+ utilities[".counterchanged"] = {
223
+ ".charges > &": counterchanged,
224
+ "&.charges > *": counterchanged,
225
+ };
161
226
 
162
227
  addUtilities(utilities);
163
228
  });
package/divisions.js CHANGED
@@ -5,6 +5,9 @@ import plugin from "tailwindcss/plugin";
5
5
 
6
6
  const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
7
7
 
8
+ // Horizontal lines of partition pass through the fess point, as the cross and fess do.
9
+ const FESS_POINT = "45%";
10
+
8
11
  // Each division maps (first, second) tincture colours to background rules.
9
12
  const layer = (image) => ({ "background-image": image });
10
13
 
@@ -12,22 +15,21 @@ const DIVISIONS = {
12
15
  // Vertical split: first on the dexter (viewer's left).
13
16
  "per-pale": (a, b) => layer(`linear-gradient(to right, ${a} 50%, ${b} 50%)`),
14
17
 
15
- // Horizontal split: first in chief.
16
- "per-fess": (a, b) => layer(`linear-gradient(to bottom, ${a} 50%, ${b} 50%)`),
18
+ // Horizontal split through the fess point: first in chief.
19
+ "per-fess": (a, b) => layer(`linear-gradient(to bottom, ${a} ${FESS_POINT}, ${b} ${FESS_POINT})`),
17
20
 
18
21
  // Line from dexter chief to sinister base: first in the upper half.
19
22
  // With a corner-to-corner gradient, the 50% line joins the other two corners.
20
23
  "per-bend": (a, b) => layer(`linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`),
21
24
 
22
25
  // Line from sinister chief to dexter base: first in the upper half.
23
- "per-bend-sinister": (a, b) =>
24
- layer(`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`),
26
+ "per-bend-sinister": (a, b) => layer(`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`),
25
27
 
26
28
  // Quarters 1 and 4 (dexter chief, sinister base) are the first tincture.
27
29
  // Linear gradients rather than conic, which render jagged edges in some browsers.
28
30
  quarterly: (a, b) => ({
29
31
  "background-image": `linear-gradient(to right, ${a} 50%, ${b} 50%), linear-gradient(to right, ${b} 50%, ${a} 50%)`,
30
- "background-size": "100% 50%, 100% 100%",
32
+ "background-size": `100% ${FESS_POINT}, 100% 100%`,
31
33
  "background-position": "top, top",
32
34
  "background-repeat": "no-repeat",
33
35
  }),
@@ -49,6 +51,15 @@ const DIVISIONS = {
49
51
  }),
50
52
  };
51
53
 
54
+ // The field with its tinctures swapped, for counterchanged ordinaries to paint.
55
+ const counterchange = (rules) =>
56
+ Object.fromEntries(
57
+ Object.entries(rules).map(([property, value]) => [
58
+ property.replace("background-", "--counterchange-"),
59
+ value,
60
+ ]),
61
+ );
62
+
52
63
  export default plugin(({ addUtilities }) => {
53
64
  const utilities = {};
54
65
 
@@ -57,10 +68,12 @@ export default plugin(({ addUtilities }) => {
57
68
  for (const second of TINCTURES) {
58
69
  if (first === second) continue;
59
70
 
60
- utilities[`.${division}-${first}-${second}`] = background(
61
- `var(--color-${first})`,
62
- `var(--color-${second})`,
63
- );
71
+ const a = `var(--color-${first})`;
72
+ const b = `var(--color-${second})`;
73
+ utilities[`.${division}-${first}-${second}`] = {
74
+ ...background(a, b),
75
+ ...counterchange(background(b, a)),
76
+ };
64
77
  }
65
78
  }
66
79
  }
package/grailwind.css CHANGED
@@ -13,6 +13,35 @@
13
13
  @plugin "./ordinaries.js";
14
14
  @plugin "./charges.js";
15
15
 
16
+ @property --fimbriated {
17
+ syntax: "*";
18
+ inherits: false;
19
+ }
20
+ @property --fimbriation {
21
+ syntax: "*";
22
+ inherits: false;
23
+ }
24
+ @property --counterchange-color {
25
+ syntax: "*";
26
+ inherits: false;
27
+ }
28
+ @property --counterchange-image {
29
+ syntax: "*";
30
+ inherits: false;
31
+ }
32
+ @property --counterchange-size {
33
+ syntax: "*";
34
+ inherits: false;
35
+ }
36
+ @property --counterchange-position {
37
+ syntax: "*";
38
+ inherits: false;
39
+ }
40
+ @property --counterchange-repeat {
41
+ syntax: "*";
42
+ inherits: false;
43
+ }
44
+
16
45
  /* ---------------------------------------------------------------------------
17
46
  * Tinctures
18
47
  * ------------------------------------------------------------------------- */
@@ -32,13 +61,27 @@
32
61
  /* ---------------------------------------------------------------------------
33
62
  * Bare tinctures
34
63
  * ------------------------------------------------------------------------- */
35
- @utility or { background-color: var(--color-or); }
36
- @utility argent { background-color: var(--color-argent); }
37
- @utility gules { background-color: var(--color-gules); }
38
- @utility azure { background-color: var(--color-azure); }
39
- @utility vert { background-color: var(--color-vert); }
40
- @utility purpure { background-color: var(--color-purpure); }
41
- @utility sable { background-color: var(--color-sable); }
64
+ @utility or {
65
+ background-color: var(--color-or);
66
+ }
67
+ @utility argent {
68
+ background-color: var(--color-argent);
69
+ }
70
+ @utility gules {
71
+ background-color: var(--color-gules);
72
+ }
73
+ @utility azure {
74
+ background-color: var(--color-azure);
75
+ }
76
+ @utility vert {
77
+ background-color: var(--color-vert);
78
+ }
79
+ @utility purpure {
80
+ background-color: var(--color-purpure);
81
+ }
82
+ @utility sable {
83
+ background-color: var(--color-sable);
84
+ }
42
85
 
43
86
  /* ---------------------------------------------------------------------------
44
87
  * Field divisions
@@ -88,6 +131,8 @@
88
131
  overflow: hidden;
89
132
  border-radius: 0 0 50% 50% / 0 0 45% 45%;
90
133
  box-shadow: 0 0 0 2px var(--color-sable);
134
+ /* The containing block for counterchanged charges' fixed layers. */
135
+ contain: layout;
91
136
  }
92
137
 
93
138
  /* ---------------------------------------------------------------------------
package/ordinaries.js CHANGED
@@ -5,24 +5,186 @@ import plugin from "tailwindcss/plugin";
5
5
 
6
6
  const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
7
7
 
8
+ // prettier-ignore
8
9
  const ORDINARIES = {
9
- pale: "polygon(35% 0, 65% 0, 65% 100%, 35% 100%)",
10
- fess: "polygon(0 30%, 100% 30%, 100% 60%, 0 60%)",
11
- bend: "polygon(0 0, 20% 0, 100% 80%, 100% 100%, 80% 100%, 0 20%)",
12
- "bend-sinister":
13
- "polygon(100% 0, 80% 0, 0 80%, 0 100%, 20% 100%, 100% 20%)",
14
- chevron: "polygon(50% 25%, 100% 75%, 100% 95%, 50% 45%, 0 95%, 0 75%)",
15
- cross:
16
- "polygon(40% 0, 60% 0, 60% 35%, 100% 35%, 100% 55%, 60% 55%, 60% 100%, 40% 100%, 40% 55%, 0 55%, 0 35%, 40% 35%)",
17
- saltire:
18
- "polygon(0 0, 15% 0, 50% 35%, 85% 0, 100% 0, 100% 15%, 65% 50%, 100% 85%, 100% 100%, 85% 100%, 50% 65%, 15% 100%, 0 100%, 0 85%, 35% 50%, 0 15%)",
19
- pall: "polygon(0 0, 15% 0, 50% 35%, 85% 0, 100% 0, 100% 15%, 60% 50%, 60% 100%, 40% 100%, 40% 50%, 0 15%)",
20
- chief: "polygon(0 0, 100% 0, 100% 30%, 0 30%)",
10
+ pale: [[35, 0], [65, 0], [65, 100], [35, 100]],
11
+ fess: [[0, 30], [100, 30], [100, 60], [0, 60]],
12
+ bend: [[0, 0], [20, 0], [100, 80], [100, 100], [80, 100], [0, 20]],
13
+ "bend-sinister": [[100, 0], [80, 0], [0, 80], [0, 100], [20, 100], [100, 20]],
14
+ chevron: [[50, 25], [100, 75], [100, 95], [50, 45], [0, 95], [0, 75]],
15
+ cross: [
16
+ [40, 0], [60, 0], [60, 35], [100, 35], [100, 55], [60, 55],
17
+ [60, 100], [40, 100], [40, 55], [0, 55], [0, 35], [40, 35],
18
+ ],
19
+ saltire: [
20
+ [0, 0], [15, 0], [50, 35], [85, 0], [100, 0], [100, 15], [65, 50], [100, 85],
21
+ [100, 100], [85, 100], [50, 65], [15, 100], [0, 100], [0, 85], [35, 50], [0, 15],
22
+ ],
23
+ pall: [
24
+ [0, 0], [15, 0], [50, 35], [85, 0], [100, 0], [100, 15],
25
+ [60, 55], [60, 100], [40, 100], [40, 55], [0, 15],
26
+ ],
27
+ chief: [[0, 0], [100, 0], [100, 30], [0, 30]],
21
28
  };
22
29
 
30
+ const num = (v) => +v.toFixed(3);
31
+ const pct = (v) => (num(v) === 0 ? "0" : `${num(v)}%`);
32
+ const point = ([x, y]) => `${pct(x)} ${pct(y)}`;
33
+ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
34
+
23
35
  const PSEUDO = { chief: "&::after" };
24
36
 
37
+ // A y in % of the field, as % of the whole shield when a chief takes the top of it.
38
+ const belowChief = (y) =>
39
+ y === 100 ? "100%" : `calc(${num(y)}% + var(--field-top, 0%) * ${num(1 - y / 100)})`;
40
+
41
+ // Fimbriation: the ordinary's clip is widened and filled with the fimbriation
42
+ // tincture, and the ordinary's own tincture is painted over it as gradient
43
+ // bands.
44
+ const W = 100;
45
+ const H = 120;
46
+ const FIMBRIATION = 3;
47
+
48
+ const real = ([x, y]) => [(x * W) / 100, (y * H) / 100];
49
+ const onShieldEdge = (a, b) =>
50
+ (a[0] === b[0] && (a[0] === 0 || a[0] === 100)) ||
51
+ (a[1] === b[1] && (a[1] === 0 || a[1] === 100));
52
+ const signedArea = (vertices) =>
53
+ vertices.reduce((sum, [x1, y1], i) => {
54
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
55
+ return sum + x1 * y2 - x2 * y1;
56
+ }, 0) / 2;
57
+
58
+ // The edge a→b as a line n·p = c, moved outward by `distance` real units.
59
+ const offsetLine = (a, b, outward, distance) => {
60
+ const [ax, ay] = real(a);
61
+ const [bx, by] = real(b);
62
+ const length = Math.hypot(bx - ax, by - ay);
63
+ const n = [(outward * (by - ay)) / length, (outward * (ax - bx)) / length];
64
+ return { n, c: n[0] * ax + n[1] * ay + distance };
65
+ };
66
+
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) => {
70
+ const outward = Math.sign(signedArea(vertices));
71
+ const count = vertices.length;
72
+ const line = (i) => {
73
+ const a = vertices[i];
74
+ const b = vertices[(i + 1) % count];
75
+ return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : FIMBRIATION);
76
+ };
77
+ return vertices.map((v, i) => {
78
+ const p = line((i - 1 + count) % count);
79
+ const q = line(i);
80
+ const det = p.n[0] * q.n[1] - p.n[1] * q.n[0];
81
+ const [x, y] = [(p.c * q.n[1] - q.c * p.n[1]) / det, (p.n[0] * q.c - q.n[0] * p.c) / det];
82
+ const [rx, ry] = real(v);
83
+ return [((x - rx) * 100) / W, ((y - ry) * 100) / H];
84
+ });
85
+ };
86
+
87
+ const shift = (v, d) =>
88
+ num(d) === 0
89
+ ? pct(v)
90
+ : `calc(${num(v)}% ${d < 0 ? "-" : "+"} ${num(Math.abs(d))}% * var(--fimbriated, 0))`;
91
+ const widenedPolygon = (vertices) => {
92
+ const offsets = widen(vertices);
93
+ const points = vertices.map(
94
+ ([x, y], i) => `${shift(x, offsets[i][0])} ${shift(y, offsets[i][1])}`,
95
+ );
96
+ return `polygon(${points.join(", ")})`;
97
+ };
98
+
99
+ // The ordinary's tincture, top layer first. `x`/`y` bands run between two x or y
100
+ // values in %; `u-v`/`u+v` are diagonal, between values of x − y or x + y.
101
+ // A `tile` [x0, y0, x1, y1] confines a layer to part of the box (square, if
102
+ // diagonal). Stripes repaint the fimbriation where one arm's band runs on into
103
+ // another's.
104
+ const S = 12;
105
+ const band = (kind, from, to, tile) => ({ kind, from, to, tile });
106
+ const stripe = (kind, from, to, tile) => ({ kind, from, to, tile, stripe: true });
107
+ // prettier-ignore
108
+ const BANDS = {
109
+ pale: [band("x", 35, 65)],
110
+ fess: [band("y", 30, 60)],
111
+ bend: [band("u-v", -20, 20)],
112
+ "bend-sinister": [band("u+v", 80, 120)],
113
+ chevron: [
114
+ stripe("u+v", 75 - S, 75),
115
+ stripe("u-v", 25, 25 + S),
116
+ band("u+v", 75, 95),
117
+ band("u-v", 5, 25),
118
+ ],
119
+ cross: [band("x", 40, 60), band("y", 35, 55)],
120
+ saltire: [band("u-v", -15, 15), band("u+v", 85, 115)],
121
+ pall: [
122
+ stripe("x", 60, 60 + S, [0, 55, 100, 100]),
123
+ stripe("x", 40 - S, 40, [0, 55, 100, 100]),
124
+ stripe("u+v", 115, 115 + S, [60, 30, 100, 70]),
125
+ stripe("u-v", -15 - S, -15, [0, 30, 40, 70]),
126
+ band("u-v", -15, 15),
127
+ band("u+v", 85, 115),
128
+ band("x", 40, 60, [0, 55, 100, 100]),
129
+ ],
130
+ };
131
+
132
+ const DIRECTION = {
133
+ x: "to right",
134
+ y: "to bottom",
135
+ "u-v": "to top right",
136
+ "u+v": "to bottom right",
137
+ };
138
+
139
+ // A band's edges as % along its gradient, within its tile.
140
+ const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
141
+ const w = x1 - x0;
142
+ const h = y1 - y0;
143
+ if (kind.length > 1 && w !== h) throw new Error("diagonal bands need a square tile");
144
+ switch (kind) {
145
+ case "x":
146
+ return ((value - x0) * 100) / w;
147
+ case "y":
148
+ return ((value - y0) * 100) / h;
149
+ case "u-v":
150
+ return ((value - (x0 - y0)) * 100) / w / 2 + 50;
151
+ case "u+v":
152
+ return ((value - x0 - y0) * 100) / w / 2;
153
+ }
154
+ };
155
+
156
+ // Soften each edge by a pixel, as hard gradient stops aren't antialiased.
157
+ const gradient = (layer, colour) => {
158
+ const a = num(stop(layer, layer.from));
159
+ const b = num(stop(layer, layer.to));
160
+ return (
161
+ `linear-gradient(${DIRECTION[layer.kind]}, transparent calc(${a}% - 0.5px), ` +
162
+ `${colour} calc(${a}% + 0.5px) calc(${b}% - 0.5px), transparent calc(${b}% + 0.5px))`
163
+ );
164
+ };
165
+
166
+ const tilePosition = (start, size) => (size === 100 ? "0" : pct((start * 100) / (100 - size)));
167
+
168
+ const bands = (layers, tincture) => ({
169
+ "background-image": layers
170
+ .map((l) =>
171
+ gradient(l, l.stripe ? "var(--fimbriation, transparent)" : `var(--color-${tincture})`),
172
+ )
173
+ .join(", "),
174
+ "background-size": layers
175
+ .map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) => `${pct(x1 - x0)} ${pct(y1 - y0)}`)
176
+ .join(", "),
177
+ "background-position": layers
178
+ .map(
179
+ ({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) =>
180
+ `${tilePosition(x0, x1 - x0)} ${tilePosition(y0, y1 - y0)}`,
181
+ )
182
+ .join(", "),
183
+ "background-repeat": "no-repeat",
184
+ });
185
+
25
186
  // Where charges go for "between", as [left%, top%], plus their size as % of width.
187
+ // prettier-ignore
26
188
  const BETWEEN = {
27
189
  pale: { size: 18, points: [[18, 42], [82, 42]] },
28
190
  fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]] },
@@ -34,6 +196,19 @@ const BETWEEN = {
34
196
  pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
35
197
  };
36
198
 
199
+ // Where charges go for "on", along the ordinary's centre line, by number of charges.
200
+ // prettier-ignore
201
+ const ON = {
202
+ pale: { size: 20, counts: { 1: [[50, 45]], 2: [[50, 28], [50, 62]], 3: [[50, 18], [50, 45], [50, 72]] } },
203
+ fess: { size: 22, counts: { 1: [[50, 45]], 2: [[30, 45], [70, 45]], 3: [[20, 45], [50, 45], [80, 45]] } },
204
+ bend: { size: 18, counts: { 1: [[50, 50]], 2: [[33, 33], [67, 67]], 3: [[24, 24], [50, 50], [76, 76]] } },
205
+ "bend-sinister": { size: 18, counts: { 1: [[50, 50]], 2: [[67, 33], [33, 67]], 3: [[76, 24], [50, 50], [24, 76]] } },
206
+ chevron: { size: 12, counts: { 1: [[50, 36]], 3: [[20, 65], [50, 36], [80, 65]] } },
207
+ cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
208
+ saltire: { size: 14, counts: { 1: [[50, 50]], 5: [[22, 22], [78, 22], [50, 50], [22, 78], [78, 78]] } },
209
+ pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
210
+ };
211
+
37
212
  const betweenVars = ({ size, points }) => {
38
213
  const vars = { "--between-size": `${size}%` };
39
214
  points.forEach(([left, top], i) => {
@@ -43,24 +218,80 @@ const betweenVars = ({ size, points }) => {
43
218
  return vars;
44
219
  };
45
220
 
221
+ const onVars = ({ size, counts }) => {
222
+ const vars = { "--on-size": `${size}%` };
223
+ for (const [count, points] of Object.entries(counts)) {
224
+ points.forEach(([left, top], i) => {
225
+ vars[`--on-${count}-${i + 1}-x`] = `${left}%`;
226
+ vars[`--on-${count}-${i + 1}-y`] = `${top}%`;
227
+ });
228
+ }
229
+ return vars;
230
+ };
231
+
46
232
  export default plugin(({ addUtilities }) => {
47
233
  const utilities = {};
48
234
 
49
- for (const [ordinary, shape] of Object.entries(ORDINARIES)) {
235
+ for (const tincture of TINCTURES) {
236
+ utilities[`.fimbriated-${tincture}`] = {
237
+ "--fimbriated": "1",
238
+ "--fimbriation": `var(--color-${tincture})`,
239
+ };
240
+ }
241
+
242
+ const ordinaryRules = (ordinary, pseudo) => ({
243
+ position: "relative",
244
+ ...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
245
+ ...(ON[ordinary] && onVars(ON[ordinary])),
246
+ // With a chief, everything else is drawn in the field below it.
247
+ ...(ordinary === "chief" && {
248
+ "--field-top": "30%",
249
+ // The chief's centre line, relative to the charges container below it.
250
+ "--chief-points-y": "calc(-15% / 0.7)",
251
+ "--chief-points-size": "18%",
252
+ }),
253
+ [PSEUDO[ordinary] ?? "&::before"]: { content: '""', position: "absolute", ...pseudo },
254
+ });
255
+
256
+ for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
257
+ // Covers the whole shield, so the swapped field lines up with the real one,
258
+ // and the clip moves the ordinary below any chief instead.
259
+ utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
260
+ inset: "0",
261
+ // Registered not to inherit, so a shield inside another can't pick up its swap.
262
+ ...Object.fromEntries(
263
+ ["color", "image", "size", "position", "repeat"].map((n) => [
264
+ `--counterchange-${n}`,
265
+ "inherit",
266
+ ]),
267
+ ),
268
+ "background-color": "var(--counterchange-color, transparent)",
269
+ "background-image": "var(--counterchange-image, none)",
270
+ "background-size": "var(--counterchange-size, auto)",
271
+ "background-position": "var(--counterchange-position, 0 0)",
272
+ "background-repeat": "var(--counterchange-repeat, repeat)",
273
+ "clip-path":
274
+ ordinary === "chief"
275
+ ? polygon(vertices)
276
+ : `polygon(${vertices.map(([x, y]) => `${pct(x)} ${belowChief(y)}`).join(", ")})`,
277
+ });
278
+
50
279
  for (const tincture of TINCTURES) {
51
- utilities[`.${ordinary}-${tincture}`] = {
52
- position: "relative",
53
- ...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
54
- // With a chief, everything else is drawn in the field below it.
55
- ...(ordinary === "chief" && { "--field-top": "30%" }),
56
- [PSEUDO[ordinary] ?? "&::before"]: {
57
- content: '""',
58
- position: "absolute",
59
- inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
60
- "background-color": `var(--color-${tincture})`,
61
- "clip-path": shape,
62
- },
63
- };
280
+ utilities[`.${ordinary}-${tincture}`] = ordinaryRules(ordinary, {
281
+ inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
282
+ ...(BANDS[ordinary]
283
+ ? {
284
+ "--fimbriated": "inherit",
285
+ "--fimbriation": "inherit",
286
+ "background-color": `var(--fimbriation, var(--color-${tincture}))`,
287
+ ...bands(BANDS[ordinary], tincture),
288
+ "clip-path": widenedPolygon(vertices),
289
+ }
290
+ : {
291
+ "background-color": `var(--color-${tincture})`,
292
+ "clip-path": polygon(vertices),
293
+ }),
294
+ });
64
295
  }
65
296
  }
66
297
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
7
- "test": "node test/modes.mjs"
7
+ "test": "node test/modes.mjs",
8
+ "dev:watch": "tailwindcss -i docs/app.css -o docs/site.css --watch=always & python3 -m http.server ${PORT:-8000} -d docs",
9
+ "format": "prettier --write .",
10
+ "format:check": "prettier --check ."
8
11
  },
9
12
  "keywords": [
10
13
  "tailwindcss",
@@ -18,6 +21,7 @@
18
21
  "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
19
22
  "devDependencies": {
20
23
  "@tailwindcss/cli": "^4.3.3",
24
+ "prettier": "^3.9.10",
21
25
  "tailwindcss": "^4.3.3"
22
26
  },
23
27
  "type": "module",
@@ -38,12 +42,13 @@
38
42
  "ordinaries.js",
39
43
  "charges.js",
40
44
  "strict.css",
41
- "palette-reset.css"
45
+ "palette-reset.css",
46
+ "CHANGELOG.md"
42
47
  ],
43
48
  "peerDependencies": {
44
49
  "tailwindcss": "^4.0.0"
45
50
  },
46
- "homepage": "https://pafirmin.github.io/grailwind/",
51
+ "homepage": "https://grailwind.dev/",
47
52
  "repository": {
48
53
  "type": "git",
49
54
  "url": "git+https://github.com/pafirmin/grailwind.git"