grailwind 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Firmin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # Grailwind CSS
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ Heraldic blazon for [Tailwind CSS](https://tailwindcss.com). Draw coats of
6
+ arms, or style anything else, in the language heralds have used for 800 years:
7
+
8
+ ```html
9
+ <div class="escutcheon azure chevron-or">
10
+ <div class="charges between">
11
+ <i class="mullet argent"></i>
12
+ <i class="mullet argent"></i>
13
+ <i class="mullet argent"></i>
14
+ </div>
15
+ </div>
16
+ ```
17
+
18
+ That's *Azure, a chevron Or between three mullets Argent*: a blue shield, a
19
+ gold chevron, and three silver stars around it.
20
+
21
+ **[Read the documentation](https://pafirmin.github.io/grailwind/)** for every class, with live examples.
22
+
23
+ > Grailwind is a light-hearted project at version 0.x, so class names may
24
+ > still change.
25
+
26
+ ## Install
27
+
28
+ Grailwind is a plugin for Tailwind CSS v4.
29
+
30
+ ```sh
31
+ npm install -D grailwind
32
+ ```
33
+
34
+ Import it after Tailwind:
35
+
36
+ ```css
37
+ @import "tailwindcss";
38
+ @import "grailwind";
39
+ ```
40
+
41
+ This adds the heraldic tinctures (`or`, `argent`, `gules`, `azure`, `vert`,
42
+ `purpure`, `sable`) alongside Tailwind's own colours.
43
+
44
+ For a more authentic experience, import `grailwind/strict` instead, and the
45
+ tinctures replace Tailwind's colours entirely.
46
+
47
+ ## What's included
48
+
49
+ - **Tinctures:** a tincture on its own sets the background (`azure`), and
50
+ Tailwind's prefixes work as usual (`text-or`).
51
+ - **The shield:** `escutcheon`, plus `bordure-*`.
52
+ - **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
53
+ `gyronny-or-sable` and more, or `per-pale`, `per-fess` and `quarterly` for
54
+ layout.
55
+ - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`
56
+ and more, with `fimbriated-*` for a narrow border.
57
+ - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
58
+ number, `in-pale`, `in-fess`, `in-bend`, `on` or `between` the ordinary, or
59
+ placed at a named point such as `in-dexter-chief`, and sized with
60
+ `charge-size-*`.
61
+ - **Your own charges:** any SVG, via `charge-[url(…)]`.
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ npm install
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
69
+ ```
70
+
71
+ ## Licence
72
+
73
+ [MIT](LICENSE)
package/charges.js ADDED
@@ -0,0 +1,194 @@
1
+ /*
2
+ * Grailwind CSS: charges and their arrangement.
3
+ */
4
+ import plugin from "tailwindcss/plugin";
5
+
6
+ const star = (cx, cy, r, points = 5) => {
7
+ const coords = [];
8
+ for (let i = 0; i < points * 2; i++) {
9
+ const radius = i % 2 ? r * 0.382 : r;
10
+ const angle = (Math.PI / points) * i - Math.PI / 2;
11
+ coords.push(`${cx + radius * Math.cos(angle)},${cy + radius * Math.sin(angle)}`);
12
+ }
13
+ return `<polygon points="${coords.join(" ")}"/>`;
14
+ };
15
+
16
+ const SHAPES = {
17
+ roundel: `<circle cx="50" cy="50" r="46"/>`,
18
+ annulet: `<circle cx="50" cy="50" r="39" fill="none" stroke="#000" stroke-width="14"/>`,
19
+ mullet: star(50, 53, 48),
20
+ lozenge: `<polygon points="50,2 84,50 50,98 16,50"/>`,
21
+ billet: `<rect x="28" y="4" width="44" height="92"/>`,
22
+ crescent: `<mask id="m"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="35" r="35"/></mask><circle cx="50" cy="54" r="42" mask="url(#m)"/>`,
23
+ "fleur-de-lis": `
24
+ <path d="M50 2C61 16 64 36 55 56H45C36 36 39 16 50 2Z"/>
25
+ <path d="M55 56C57 38 70 24 84 26C97 29 99 46 89 52C92 44 87 37 80 39C71 42 66 50 66 56Z"/>
26
+ <path d="M45 56C43 38 30 24 16 26C3 29 1 46 11 52C8 44 13 37 20 39C29 42 34 50 34 56Z"/>
27
+ <rect x="28" y="56" width="44" height="9" rx="2"/>
28
+ <path d="M45 65H55C55 74 53 82 50 90C47 82 45 74 45 65Z"/>
29
+ <path d="M58 65C62 74 72 80 84 78C80 88 64 90 56 74Z"/>
30
+ <path d="M42 65C38 74 28 80 16 78C20 88 36 90 44 74Z"/>`,
31
+ };
32
+
33
+ // Roundels have their own names by tincture.
34
+ const ROUNDELS = {
35
+ bezant: "or",
36
+ plate: "argent",
37
+ torteau: "gules",
38
+ hurt: "azure",
39
+ pellet: "sable",
40
+ };
41
+
42
+ // Points as [left%, top%] of the shield, plus a charge size as % of its width.
43
+ // The default for each number of charges, e.g. three are placed 2 and 1.
44
+ const DEFAULTS = {
45
+ 1: { size: 50, points: [[50, 45]] },
46
+ 2: { size: 30, points: [[50, 24], [50, 66]] },
47
+ 3: { size: 28, points: [[28, 25], [72, 25], [50, 64]] },
48
+ 4: { size: 24, points: [[30, 24], [70, 24], [32, 60], [68, 60]] },
49
+ 6: { size: 20, points: [[20, 18], [50, 18], [80, 18], [35, 42], [65, 42], [50, 66]] },
50
+ };
51
+
52
+ // The points of the shield, for placing a single charge, e.g. `in-dexter-chief`.
53
+ const POINTS = {
54
+ "dexter-chief": [25, 17],
55
+ chief: [50, 15],
56
+ "sinister-chief": [75, 17],
57
+ "honour-point": [50, 30],
58
+ "fess-point": [50, 45],
59
+ "nombril-point": [50, 60],
60
+ "dexter-base": [30, 72],
61
+ base: [50, 84],
62
+ "sinister-base": [70, 72],
63
+ };
64
+ const POINT_SIZE = 22;
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
+
69
+ const ARRANGEMENTS = {
70
+ "in-pale": {
71
+ 2: { size: 30, points: [[50, 24], [50, 66]] },
72
+ 3: { size: 22, points: [[50, 16], [50, 43], [50, 70]] },
73
+ },
74
+ "in-fess": {
75
+ 2: { size: 30, points: [[30, 45], [70, 45]] },
76
+ 3: { size: 24, points: [[22, 45], [50, 45], [78, 45]] },
77
+ },
78
+ "in-bend": {
79
+ 2: { size: 28, points: [[30, 26], [70, 64]] },
80
+ 3: { size: 22, points: [[22, 16], [50, 45], [78, 74]] },
81
+ },
82
+ };
83
+
84
+ const svgUrl = (shape) =>
85
+ `url("data:image/svg+xml,${encodeURIComponent(
86
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shape}</svg>`,
87
+ )}")`;
88
+
89
+ const maskRules = (image) => ({
90
+ "mask-image": image,
91
+ "mask-size": "contain",
92
+ "mask-repeat": "no-repeat",
93
+ "mask-position": "center",
94
+ });
95
+
96
+ const place = ([left, top], size) => ({
97
+ left: `${left}%`,
98
+ top: `${top}%`,
99
+ width: `var(--charge-size, ${size}%)`,
100
+ });
101
+
102
+ const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
103
+
104
+ export default plugin(({ addUtilities, matchUtilities }) => {
105
+ // User-defined charges from images
106
+ matchUtilities({ charge: maskRules }, { type: ["url"] });
107
+
108
+ const utilities = {};
109
+
110
+ for (const [name, shape] of Object.entries(SHAPES)) {
111
+ utilities[`.${name}`] = maskRules(svgUrl(shape));
112
+ }
113
+
114
+ for (const [name, tincture] of Object.entries(ROUNDELS)) {
115
+ utilities[`.${name}`] = {
116
+ ...maskRules(svgUrl(SHAPES.roundel)),
117
+ "background-color": `var(--color-${tincture})`,
118
+ };
119
+ }
120
+
121
+ // Defaults use :where() so arrangements and `between` always win.
122
+ const charges = {
123
+ position: "absolute",
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",
127
+ "& > *": {
128
+ position: "absolute",
129
+ "aspect-ratio": "1",
130
+ translate: "-50% -50%",
131
+ "object-fit": "contain",
132
+ },
133
+ };
134
+ for (const [count, { size, points }] of Object.entries(DEFAULTS)) {
135
+ points.forEach((point, i) => {
136
+ charges[`&:where(${hasCount(count)}) > :where(:nth-child(${i + 1}))`] =
137
+ place(point, size);
138
+ });
139
+ }
140
+ utilities[".charges"] = charges;
141
+
142
+ for (const [name, counts] of Object.entries(ARRANGEMENTS)) {
143
+ const arrangement = {};
144
+ for (const [count, { size, points }] of Object.entries(counts)) {
145
+ points.forEach((point, i) => {
146
+ arrangement[`&${hasCount(count)} > :nth-child(${i + 1})`] = place(point, size);
147
+ });
148
+ }
149
+ utilities[`.${name}`] = arrangement;
150
+ }
151
+
152
+ // Positions come from the ordinary on the shield (see ordinaries.js).
153
+ const between = {};
154
+ for (let i = 1; i <= 4; i++) {
155
+ between[`& > :nth-child(${i})`] = {
156
+ left: `var(--between-${i}-x)`,
157
+ top: `var(--between-${i}-y)`,
158
+ width: "var(--charge-size, var(--between-size))",
159
+ };
160
+ }
161
+ utilities[".between"] = between;
162
+
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
+ };
191
+ }
192
+
193
+ addUtilities(utilities);
194
+ });
package/divisions.js ADDED
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Grailwind CSS: field divisions as decoration.
3
+ */
4
+ import plugin from "tailwindcss/plugin";
5
+
6
+ const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
7
+
8
+ // Each division maps (first, second) tincture colours to background rules.
9
+ const layer = (image) => ({ "background-image": image });
10
+
11
+ const DIVISIONS = {
12
+ // Vertical split: first on the dexter (viewer's left).
13
+ "per-pale": (a, b) => layer(`linear-gradient(to right, ${a} 50%, ${b} 50%)`),
14
+
15
+ // Horizontal split: first in chief.
16
+ "per-fess": (a, b) => layer(`linear-gradient(to bottom, ${a} 50%, ${b} 50%)`),
17
+
18
+ // Line from dexter chief to sinister base: first in the upper half.
19
+ // With a corner-to-corner gradient, the 50% line joins the other two corners.
20
+ "per-bend": (a, b) => layer(`linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`),
21
+
22
+ // 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%)`),
25
+
26
+ // Quarters 1 and 4 (dexter chief, sinister base) are the first tincture.
27
+ // Linear gradients rather than conic, which render jagged edges in some browsers.
28
+ quarterly: (a, b) => ({
29
+ "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%",
31
+ "background-position": "top, top",
32
+ "background-repeat": "no-repeat",
33
+ }),
34
+
35
+ // Gyronny of eight: each quarter split corner to corner. The first tincture
36
+ // is in the gyron just dexter of the top line. The background colour fills
37
+ // any sub-pixel seam between quarters.
38
+ gyronny: (a, b) => ({
39
+ "background-color": b,
40
+ "background-image": [
41
+ `linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
42
+ `linear-gradient(to bottom right, ${b} 50%, ${a} 50%)`,
43
+ `linear-gradient(to bottom left, ${b} 50%, ${a} 50%)`,
44
+ `linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
45
+ ].join(", "),
46
+ "background-size": "50% 50%",
47
+ "background-position": "left top, right top, right bottom, left bottom",
48
+ "background-repeat": "no-repeat",
49
+ }),
50
+ };
51
+
52
+ export default plugin(({ addUtilities }) => {
53
+ const utilities = {};
54
+
55
+ for (const [division, background] of Object.entries(DIVISIONS)) {
56
+ for (const first of TINCTURES) {
57
+ for (const second of TINCTURES) {
58
+ if (first === second) continue;
59
+
60
+ utilities[`.${division}-${first}-${second}`] = background(
61
+ `var(--color-${first})`,
62
+ `var(--color-${second})`,
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ addUtilities(utilities);
69
+ });
package/grailwind.css ADDED
@@ -0,0 +1,111 @@
1
+ /*
2
+ * Grailwind CSS: heraldic blazon for Tailwind v4.
3
+ *
4
+ * Import after Tailwind:
5
+ * @import "tailwindcss";
6
+ * @import "grailwind";
7
+ *
8
+ * Adds the tinctures alongside Tailwind's colours. For tinctures only,
9
+ * import "grailwind/strict" instead.
10
+ */
11
+
12
+ @plugin "./divisions.js";
13
+ @plugin "./ordinaries.js";
14
+ @plugin "./charges.js";
15
+
16
+ @property --fimbriated {
17
+ syntax: "*";
18
+ inherits: false;
19
+ }
20
+ @property --fimbriation {
21
+ syntax: "*";
22
+ inherits: false;
23
+ }
24
+
25
+ /* ---------------------------------------------------------------------------
26
+ * Tinctures
27
+ * ------------------------------------------------------------------------- */
28
+ @theme {
29
+ /* Metals */
30
+ --color-or: #f5c518;
31
+ --color-argent: #ffffff;
32
+
33
+ /* Colours */
34
+ --color-gules: #d21f1f;
35
+ --color-azure: #1f4fb0;
36
+ --color-vert: #1d8a3c;
37
+ --color-purpure: #7e3a8e;
38
+ --color-sable: #141414;
39
+ }
40
+
41
+ /* ---------------------------------------------------------------------------
42
+ * Bare tinctures
43
+ * ------------------------------------------------------------------------- */
44
+ @utility or { background-color: var(--color-or); }
45
+ @utility argent { background-color: var(--color-argent); }
46
+ @utility gules { background-color: var(--color-gules); }
47
+ @utility azure { background-color: var(--color-azure); }
48
+ @utility vert { background-color: var(--color-vert); }
49
+ @utility purpure { background-color: var(--color-purpure); }
50
+ @utility sable { background-color: var(--color-sable); }
51
+
52
+ /* ---------------------------------------------------------------------------
53
+ * Field divisions
54
+ * ------------------------------------------------------------------------- */
55
+
56
+ /* Per pale: split vertically into dexter | sinister. */
57
+ @utility per-pale {
58
+ display: grid;
59
+ grid-auto-flow: column;
60
+ grid-auto-columns: 1fr;
61
+ }
62
+
63
+ /* Per fess: split horizontally into chief / base. */
64
+ @utility per-fess {
65
+ display: grid;
66
+ grid-auto-flow: row;
67
+ grid-auto-rows: 1fr;
68
+ }
69
+
70
+ /* Quarterly: four quarters, numbered 1–4 across then down.
71
+ * Quarter 1 is dexter chief, i.e. the viewer's top left. */
72
+ @utility quarterly {
73
+ display: grid;
74
+ grid-template-columns: 1fr 1fr;
75
+ grid-template-rows: 1fr 1fr;
76
+ }
77
+
78
+ /* ---------------------------------------------------------------------------
79
+ * Subordinaries
80
+ * ------------------------------------------------------------------------- */
81
+
82
+ /* Bordure: a border in a given tincture, e.g. `bordure-or`. */
83
+ @utility bordure-* {
84
+ border-style: solid;
85
+ border-width: 0.75rem;
86
+ border-color: --value(--color-*);
87
+ }
88
+
89
+ /* ---------------------------------------------------------------------------
90
+ * The shield itself
91
+ * ------------------------------------------------------------------------- */
92
+
93
+ /* Escutcheon: a shield shape to draw arms in. */
94
+ @utility escutcheon {
95
+ position: relative;
96
+ aspect-ratio: 5 / 6;
97
+ overflow: hidden;
98
+ border-radius: 0 0 50% 50% / 0 0 45% 45%;
99
+ box-shadow: 0 0 0 2px var(--color-sable);
100
+ }
101
+
102
+ /* ---------------------------------------------------------------------------
103
+ * Charge size
104
+ * ------------------------------------------------------------------------- */
105
+
106
+ /* Charge width as a % of the shield, e.g. `charge-size-20` or `charge-size-[3rem]`.
107
+ * On a `charges` container it sizes every charge; on a charge, just that one. */
108
+ @utility charge-size-* {
109
+ --charge-size: calc(--value(integer) * 1%);
110
+ --charge-size: --value([percentage], [length]);
111
+ }
package/ordinaries.js ADDED
@@ -0,0 +1,256 @@
1
+ /*
2
+ * Grailwind CSS: ordinaries, e.g. `fess-or`.
3
+ */
4
+ import plugin from "tailwindcss/plugin";
5
+
6
+ const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
7
+
8
+ const ORDINARIES = {
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]],
27
+ };
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
+
34
+ const PSEUDO = { chief: "&::after" };
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
+
167
+ // Where charges go for "between", as [left%, top%], plus their size as % of width.
168
+ const BETWEEN = {
169
+ pale: { size: 18, points: [[18, 42], [82, 42]] },
170
+ fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]] },
171
+ bend: { size: 24, points: [[70, 26], [30, 68]] },
172
+ "bend-sinister": { size: 24, points: [[30, 26], [70, 68]] },
173
+ chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]] },
174
+ cross: { size: 18, points: [[20, 17], [80, 17], [24, 72], [76, 72]] },
175
+ saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]] },
176
+ pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
177
+ };
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
+
191
+ const betweenVars = ({ size, points }) => {
192
+ const vars = { "--between-size": `${size}%` };
193
+ points.forEach(([left, top], i) => {
194
+ vars[`--between-${i + 1}-x`] = `${left}%`;
195
+ vars[`--between-${i + 1}-y`] = `${top}%`;
196
+ });
197
+ return vars;
198
+ };
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
+
211
+ export default plugin(({ addUtilities }) => {
212
+ const utilities = {};
213
+
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)) {
222
+ for (const tincture of TINCTURES) {
223
+ utilities[`.${ordinary}-${tincture}`] = {
224
+ position: "relative",
225
+ ...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
226
+ ...(ON[ordinary] && onVars(ON[ordinary])),
227
+ // With a chief, everything else is drawn in the field below it.
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
+ }),
234
+ [PSEUDO[ordinary] ?? "&::before"]: {
235
+ content: '""',
236
+ position: "absolute",
237
+ inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
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
+ }),
250
+ },
251
+ };
252
+ }
253
+ }
254
+
255
+ addUtilities(utilities);
256
+ });
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "scripts": {
5
+ "build": "tailwindcss -i docs/app.css -o docs/site.css",
6
+ "dev": "tailwindcss -i docs/app.css -o docs/site.css --watch",
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
+ },
10
+ "keywords": [
11
+ "tailwindcss",
12
+ "tailwind",
13
+ "heraldry",
14
+ "blazon",
15
+ "css"
16
+ ],
17
+ "author": "Paul Firmin",
18
+ "license": "MIT",
19
+ "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
20
+ "devDependencies": {
21
+ "@tailwindcss/cli": "^4.3.3",
22
+ "tailwindcss": "^4.3.3"
23
+ },
24
+ "type": "module",
25
+ "style": "grailwind.css",
26
+ "exports": {
27
+ ".": {
28
+ "style": "./grailwind.css",
29
+ "default": "./grailwind.css"
30
+ },
31
+ "./strict": {
32
+ "style": "./strict.css",
33
+ "default": "./strict.css"
34
+ }
35
+ },
36
+ "files": [
37
+ "grailwind.css",
38
+ "divisions.js",
39
+ "ordinaries.js",
40
+ "charges.js",
41
+ "strict.css",
42
+ "palette-reset.css",
43
+ "CHANGELOG.md"
44
+ ],
45
+ "peerDependencies": {
46
+ "tailwindcss": "^4.0.0"
47
+ },
48
+ "homepage": "https://pafirmin.github.io/grailwind/",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/pafirmin/grailwind.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/pafirmin/grailwind/issues"
55
+ }
56
+ }
@@ -0,0 +1,4 @@
1
+ /* Removes Tailwind's colours. Used by strict.css. */
2
+ @theme {
3
+ --color-*: initial;
4
+ }
package/strict.css ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Grailwind CSS, strict: the tinctures replace Tailwind's colours entirely.
3
+ */
4
+ @import "./palette-reset.css";
5
+ @import "./grailwind.css";