grailwind 0.1.0 → 0.2.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,27 @@
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
+ ## [0.2.0] - 2026-11-01
8
+
9
+ ### Added
10
+
11
+ - Charges `on` an ordinary: `charges on` places one, two, three or five charges
12
+ along the pale, fess, bend, bend sinister, chevron, cross, saltire or pall.
13
+ - Fimbriation: `fimbriated-<tincture>` edges an ordinary with a narrow border,
14
+ e.g. `cross-azure fimbriated-argent`.
15
+
16
+ ### Fixed
17
+
18
+ - Charges at `in-dexter-chief`, `in-chief` and `in-sinister-chief` now sit on
19
+ the chief when one is present, and charges are drawn above it.
20
+
21
+ ## [0.1.0] - 2026-10-31
22
+
23
+ Initial release: tinctures, divisions, ordinaries, charges, strict mode and
24
+ custom SVG charges.
25
+
26
+ [0.2.0]: https://github.com/pafirmin/grailwind/compare/v0.1.0...v0.2.0
27
+ [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://pafirmin.github.io/grailwind/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
 
@@ -51,9 +53,9 @@ 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.
55
57
  - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
56
- number, `in-pale`, `in-fess`, `in-bend` or `between` the ordinary, or
58
+ number, `in-pale`, `in-fess`, `in-bend`, `on` or `between` the ordinary, or
57
59
  placed at a named point such as `in-dexter-chief`, and sized with
58
60
  `charge-size-*`.
59
61
  - **Your own charges:** any SVG, via `charge-[url(…)]`.
@@ -62,9 +64,8 @@ tinctures replace Tailwind's colours entirely.
62
64
 
63
65
  ```sh
64
66
  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
67
+ npm test # checks both the default and strict builds
68
+ npm run dev:watch # rebuilds the docs site on change and serves it at http://localhost:8000
68
69
  ```
69
70
 
70
71
  ## Licence
package/charges.js CHANGED
@@ -63,6 +63,9 @@ const POINTS = {
63
63
  };
64
64
  const POINT_SIZE = 22;
65
65
 
66
+ // With a chief on the shield, these points sit on it (see ordinaries.js).
67
+ const CHIEF_POINTS = new Set(["dexter-chief", "chief", "sinister-chief"]);
68
+
66
69
  const ARRANGEMENTS = {
67
70
  "in-pale": {
68
71
  2: { size: 30, points: [[50, 24], [50, 66]] },
@@ -119,6 +122,8 @@ export default plugin(({ addUtilities, matchUtilities }) => {
119
122
  const charges = {
120
123
  position: "absolute",
121
124
  inset: "var(--field-top, 0%) 0 0",
125
+ // Above the chief, which is painted last, so charges can sit on it.
126
+ "z-index": "1",
122
127
  "& > *": {
123
128
  position: "absolute",
124
129
  "aspect-ratio": "1",
@@ -155,8 +160,34 @@ export default plugin(({ addUtilities, matchUtilities }) => {
155
160
  }
156
161
  utilities[".between"] = between;
157
162
 
158
- for (const [name, point] of Object.entries(POINTS)) {
159
- utilities[`.in-${name}`] = { ".charges > &": place(point, POINT_SIZE) };
163
+ // Falls back to the plain-field default when the ordinary has no points for this count.
164
+ // An undefined var() would otherwise unset `left`/`top` instead of deferring to the :where() default.
165
+ const on = {};
166
+ for (const count of [1, 2, 3, 5]) {
167
+ const fallback = DEFAULTS[count];
168
+ for (let i = 1; i <= count; i++) {
169
+ const [left, top] = fallback?.points[i - 1] ?? [];
170
+ const at = (axis, value) =>
171
+ value === undefined ? `var(--on-${count}-${i}-${axis})` : `var(--on-${count}-${i}-${axis}, ${value}%)`;
172
+ on[`&${hasCount(count)} > :nth-child(${i})`] = {
173
+ left: at("x", left),
174
+ top: at("y", top),
175
+ width: `var(--charge-size, var(--on-size, ${fallback?.size ?? 16}%))`,
176
+ };
177
+ }
178
+ }
179
+ utilities[".on"] = on;
180
+
181
+ for (const [name, [left, top]] of Object.entries(POINTS)) {
182
+ utilities[`.in-${name}`] = {
183
+ ".charges > &": CHIEF_POINTS.has(name)
184
+ ? {
185
+ left: `${left}%`,
186
+ top: `var(--chief-points-y, ${top}%)`,
187
+ width: `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
188
+ }
189
+ : place([left, top], POINT_SIZE),
190
+ };
160
191
  }
161
192
 
162
193
  addUtilities(utilities);
package/grailwind.css CHANGED
@@ -13,6 +13,15 @@
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
+
16
25
  /* ---------------------------------------------------------------------------
17
26
  * Tinctures
18
27
  * ------------------------------------------------------------------------- */
package/ordinaries.js CHANGED
@@ -6,22 +6,164 @@ import plugin from "tailwindcss/plugin";
6
6
  const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
7
7
 
8
8
  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%)",
9
+ pale: [[35, 0], [65, 0], [65, 100], [35, 100]],
10
+ fess: [[0, 30], [100, 30], [100, 60], [0, 60]],
11
+ bend: [[0, 0], [20, 0], [100, 80], [100, 100], [80, 100], [0, 20]],
12
+ "bend-sinister": [[100, 0], [80, 0], [0, 80], [0, 100], [20, 100], [100, 20]],
13
+ chevron: [[50, 25], [100, 75], [100, 95], [50, 45], [0, 95], [0, 75]],
14
+ cross: [
15
+ [40, 0], [60, 0], [60, 35], [100, 35], [100, 55], [60, 55],
16
+ [60, 100], [40, 100], [40, 55], [0, 55], [0, 35], [40, 35],
17
+ ],
18
+ saltire: [
19
+ [0, 0], [15, 0], [50, 35], [85, 0], [100, 0], [100, 15], [65, 50], [100, 85],
20
+ [100, 100], [85, 100], [50, 65], [15, 100], [0, 100], [0, 85], [35, 50], [0, 15],
21
+ ],
22
+ pall: [
23
+ [0, 0], [15, 0], [50, 35], [85, 0], [100, 0], [100, 15],
24
+ [60, 55], [60, 100], [40, 100], [40, 55], [0, 15],
25
+ ],
26
+ chief: [[0, 0], [100, 0], [100, 30], [0, 30]],
21
27
  };
22
28
 
29
+ const num = (v) => +v.toFixed(3);
30
+ const pct = (v) => (num(v) === 0 ? "0" : `${num(v)}%`);
31
+ const point = ([x, y]) => `${pct(x)} ${pct(y)}`;
32
+ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
33
+
23
34
  const PSEUDO = { chief: "&::after" };
24
35
 
36
+ // Fimbriation: the ordinary's clip is widened and filled with the fimbriation
37
+ // tincture, and the ordinary's own tincture is painted over it as gradient
38
+ // bands.
39
+ const W = 100;
40
+ const H = 120;
41
+ const FIMBRIATION = 3;
42
+
43
+ const real = ([x, y]) => [(x * W) / 100, (y * H) / 100];
44
+ const onShieldEdge = (a, b) =>
45
+ (a[0] === b[0] && (a[0] === 0 || a[0] === 100)) ||
46
+ (a[1] === b[1] && (a[1] === 0 || a[1] === 100));
47
+ const signedArea = (vertices) =>
48
+ vertices.reduce((sum, [x1, y1], i) => {
49
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
50
+ return sum + x1 * y2 - x2 * y1;
51
+ }, 0) / 2;
52
+
53
+ // The edge a→b as a line n·p = c, moved outward by `distance` real units.
54
+ const offsetLine = (a, b, outward, distance) => {
55
+ const [ax, ay] = real(a);
56
+ const [bx, by] = real(b);
57
+ const length = Math.hypot(bx - ax, by - ay);
58
+ const n = [(outward * (by - ay)) / length, (outward * (ax - bx)) / length];
59
+ return { n, c: n[0] * ax + n[1] * ay + distance };
60
+ };
61
+
62
+ // How far each vertex moves, in %, when fimbriated. Sides on the shield's edge
63
+ // don't move, so their vertices slide along it.
64
+ const widen = (vertices) => {
65
+ const outward = Math.sign(signedArea(vertices));
66
+ const count = vertices.length;
67
+ const line = (i) => {
68
+ const a = vertices[i];
69
+ const b = vertices[(i + 1) % count];
70
+ return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : FIMBRIATION);
71
+ };
72
+ return vertices.map((v, i) => {
73
+ const p = line((i - 1 + count) % count);
74
+ const q = line(i);
75
+ const det = p.n[0] * q.n[1] - p.n[1] * q.n[0];
76
+ 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];
77
+ const [rx, ry] = real(v);
78
+ return [((x - rx) * 100) / W, ((y - ry) * 100) / H];
79
+ });
80
+ };
81
+
82
+ const shift = (v, d) =>
83
+ num(d) === 0
84
+ ? pct(v)
85
+ : `calc(${num(v)}% ${d < 0 ? "-" : "+"} ${num(Math.abs(d))}% * var(--fimbriated, 0))`;
86
+ const widenedPolygon = (vertices) => {
87
+ const offsets = widen(vertices);
88
+ const points = vertices.map(
89
+ ([x, y], i) => `${shift(x, offsets[i][0])} ${shift(y, offsets[i][1])}`,
90
+ );
91
+ return `polygon(${points.join(", ")})`;
92
+ };
93
+
94
+ // The ordinary's tincture, top layer first. `x`/`y` bands run between two x or y
95
+ // values in %; `u-v`/`u+v` are diagonal, between values of x − y or x + y.
96
+ // A `tile` [x0, y0, x1, y1] confines a layer to part of the box (square, if
97
+ // diagonal). Stripes repaint the fimbriation where one arm's band runs on into
98
+ // another's.
99
+ const S = 12;
100
+ const band = (kind, from, to, tile) => ({ kind, from, to, tile });
101
+ const stripe = (kind, from, to, tile) => ({ kind, from, to, tile, stripe: true });
102
+ const BANDS = {
103
+ pale: [band("x", 35, 65)],
104
+ fess: [band("y", 30, 60)],
105
+ bend: [band("u-v", -20, 20)],
106
+ "bend-sinister": [band("u+v", 80, 120)],
107
+ chevron: [
108
+ stripe("u+v", 75 - S, 75),
109
+ stripe("u-v", 25, 25 + S),
110
+ band("u+v", 75, 95),
111
+ band("u-v", 5, 25),
112
+ ],
113
+ cross: [band("x", 40, 60), band("y", 35, 55)],
114
+ saltire: [band("u-v", -15, 15), band("u+v", 85, 115)],
115
+ pall: [
116
+ stripe("x", 60, 60 + S, [0, 55, 100, 100]),
117
+ stripe("x", 40 - S, 40, [0, 55, 100, 100]),
118
+ stripe("u+v", 115, 115 + S, [60, 30, 100, 70]),
119
+ stripe("u-v", -15 - S, -15, [0, 30, 40, 70]),
120
+ band("u-v", -15, 15),
121
+ band("u+v", 85, 115),
122
+ band("x", 40, 60, [0, 55, 100, 100]),
123
+ ],
124
+ };
125
+
126
+ const DIRECTION = { x: "to right", y: "to bottom", "u-v": "to top right", "u+v": "to bottom right" };
127
+
128
+ // A band's edges as % along its gradient, within its tile.
129
+ const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
130
+ const w = x1 - x0;
131
+ const h = y1 - y0;
132
+ if (kind.length > 1 && w !== h) throw new Error("diagonal bands need a square tile");
133
+ switch (kind) {
134
+ case "x": return ((value - x0) * 100) / w;
135
+ case "y": return ((value - y0) * 100) / h;
136
+ case "u-v": return ((value - (x0 - y0)) * 100) / w / 2 + 50;
137
+ case "u+v": return ((value - x0 - y0) * 100) / w / 2;
138
+ }
139
+ };
140
+
141
+ // Soften each edge by a pixel, as hard gradient stops aren't antialiased.
142
+ const gradient = (layer, colour) => {
143
+ const a = num(stop(layer, layer.from));
144
+ const b = num(stop(layer, layer.to));
145
+ return (
146
+ `linear-gradient(${DIRECTION[layer.kind]}, transparent calc(${a}% - 0.5px), ` +
147
+ `${colour} calc(${a}% + 0.5px) calc(${b}% - 0.5px), transparent calc(${b}% + 0.5px))`
148
+ );
149
+ };
150
+
151
+ const tilePosition = (start, size) => (size === 100 ? "0" : pct((start * 100) / (100 - size)));
152
+
153
+ const bands = (layers, tincture) => ({
154
+ "background-image": layers
155
+ .map((l) => gradient(l, l.stripe ? "var(--fimbriation, transparent)" : `var(--color-${tincture})`))
156
+ .join(", "),
157
+ "background-size": layers
158
+ .map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) => `${pct(x1 - x0)} ${pct(y1 - y0)}`)
159
+ .join(", "),
160
+ "background-position": layers
161
+ .map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) =>
162
+ `${tilePosition(x0, x1 - x0)} ${tilePosition(y0, y1 - y0)}`)
163
+ .join(", "),
164
+ "background-repeat": "no-repeat",
165
+ });
166
+
25
167
  // Where charges go for "between", as [left%, top%], plus their size as % of width.
26
168
  const BETWEEN = {
27
169
  pale: { size: 18, points: [[18, 42], [82, 42]] },
@@ -34,6 +176,18 @@ const BETWEEN = {
34
176
  pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
35
177
  };
36
178
 
179
+ // Where charges go for "on", along the ordinary's centre line, by number of charges.
180
+ const ON = {
181
+ pale: { size: 20, counts: { 1: [[50, 45]], 2: [[50, 28], [50, 62]], 3: [[50, 18], [50, 45], [50, 72]] } },
182
+ fess: { size: 22, counts: { 1: [[50, 45]], 2: [[30, 45], [70, 45]], 3: [[20, 45], [50, 45], [80, 45]] } },
183
+ bend: { size: 18, counts: { 1: [[50, 50]], 2: [[33, 33], [67, 67]], 3: [[24, 24], [50, 50], [76, 76]] } },
184
+ "bend-sinister": { size: 18, counts: { 1: [[50, 50]], 2: [[67, 33], [33, 67]], 3: [[76, 24], [50, 50], [24, 76]] } },
185
+ chevron: { size: 12, counts: { 1: [[50, 36]], 3: [[20, 65], [50, 36], [80, 65]] } },
186
+ cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
187
+ saltire: { size: 14, counts: { 1: [[50, 50]], 5: [[22, 22], [78, 22], [50, 50], [22, 78], [78, 78]] } },
188
+ pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
189
+ };
190
+
37
191
  const betweenVars = ({ size, points }) => {
38
192
  const vars = { "--between-size": `${size}%` };
39
193
  points.forEach(([left, top], i) => {
@@ -43,22 +197,56 @@ const betweenVars = ({ size, points }) => {
43
197
  return vars;
44
198
  };
45
199
 
200
+ const onVars = ({ size, counts }) => {
201
+ const vars = { "--on-size": `${size}%` };
202
+ for (const [count, points] of Object.entries(counts)) {
203
+ points.forEach(([left, top], i) => {
204
+ vars[`--on-${count}-${i + 1}-x`] = `${left}%`;
205
+ vars[`--on-${count}-${i + 1}-y`] = `${top}%`;
206
+ });
207
+ }
208
+ return vars;
209
+ };
210
+
46
211
  export default plugin(({ addUtilities }) => {
47
212
  const utilities = {};
48
213
 
49
- for (const [ordinary, shape] of Object.entries(ORDINARIES)) {
214
+ for (const tincture of TINCTURES) {
215
+ utilities[`.fimbriated-${tincture}`] = {
216
+ "--fimbriated": "1",
217
+ "--fimbriation": `var(--color-${tincture})`,
218
+ };
219
+ }
220
+
221
+ for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
50
222
  for (const tincture of TINCTURES) {
51
223
  utilities[`.${ordinary}-${tincture}`] = {
52
224
  position: "relative",
53
225
  ...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
226
+ ...(ON[ordinary] && onVars(ON[ordinary])),
54
227
  // With a chief, everything else is drawn in the field below it.
55
- ...(ordinary === "chief" && { "--field-top": "30%" }),
228
+ ...(ordinary === "chief" && {
229
+ "--field-top": "30%",
230
+ // The chief's centre line, relative to the charges container below it.
231
+ "--chief-points-y": "calc(-15% / 0.7)",
232
+ "--chief-points-size": "18%",
233
+ }),
56
234
  [PSEUDO[ordinary] ?? "&::before"]: {
57
235
  content: '""',
58
236
  position: "absolute",
59
237
  inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
60
- "background-color": `var(--color-${tincture})`,
61
- "clip-path": shape,
238
+ ...(BANDS[ordinary]
239
+ ? {
240
+ "--fimbriated": "inherit",
241
+ "--fimbriation": "inherit",
242
+ "background-color": `var(--fimbriation, var(--color-${tincture}))`,
243
+ ...bands(BANDS[ordinary], tincture),
244
+ "clip-path": widenedPolygon(vertices),
245
+ }
246
+ : {
247
+ "background-color": `var(--color-${tincture})`,
248
+ "clip-path": polygon(vertices),
249
+ }),
62
250
  },
63
251
  };
64
252
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.1.0",
3
+ "version": "0.2.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"
8
9
  },
9
10
  "keywords": [
10
11
  "tailwindcss",
@@ -38,7 +39,8 @@
38
39
  "ordinaries.js",
39
40
  "charges.js",
40
41
  "strict.css",
41
- "palette-reset.css"
42
+ "palette-reset.css",
43
+ "CHANGELOG.md"
42
44
  ],
43
45
  "peerDependencies": {
44
46
  "tailwindcss": "^4.0.0"