grailwind 0.3.0 → 0.4.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,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-11-03
10
+
11
+ ### Added
12
+
13
+ - `grailwind/data` (experimental): the tinctures, metals and colours, furs,
14
+ ordinaries and their lines, charges, arrangements and points as plain data,
15
+ for tooling such as `@grailwind/eslint-plugin`. Its shape may change in any
16
+ minor release.
17
+ - `@grailwind/eslint-plugin` 0.1.0-beta.0, a separate package: the rule of
18
+ tincture and charge structure, for JSX and HTML. Install with
19
+ `@grailwind/eslint-plugin@beta`. It knows engrailed, invected and fur
20
+ ordinaries, and counts furs by their ground (configurable).
21
+ - `in-bend-sinister`: two or three charges in a line from sinister chief to
22
+ dexter base.
23
+ - Engrailed and invected ordinaries, e.g. `fess-engrailed-or` and
24
+ `chief-invected-azure`. Browsers without `clip-path: shape()` draw them with
25
+ straight edges.
26
+ - Furs: `ermine`, `ermines`, `erminois` and `pean`, on the field, on a charge,
27
+ or in a division (`per-pale-ermine-gules`).
28
+ - Fur ordinaries, e.g. `chief-ermine` and `cross-engrailed-erminois`. They
29
+ aren't fimbriated.
30
+
31
+ ### Fixed
32
+
33
+ - Five charges now default to 2, 1, 2 (in saltire). They were invisible on a
34
+ plain field, and piled up in a corner on an ordinary without five points.
35
+ - An ordinary in a quarter no longer paints over the shield's own ordinary.
36
+
9
37
  ## [0.3.0] - 2026-11-01
10
38
 
11
39
  ### Added
@@ -43,7 +71,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
43
71
  Initial release: tinctures, divisions, ordinaries, charges, strict mode and
44
72
  custom SVG charges.
45
73
 
46
- [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.3.0...HEAD
74
+ [Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.4.0...HEAD
75
+ [0.4.0]: https://github.com/pafirmin/grailwind/compare/v0.3.0...v0.4.0
47
76
  [0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
48
77
  [0.2.0]: https://github.com/pafirmin/grailwind/compare/v0.1.0...v0.2.0
49
78
  [0.1.0]: https://github.com/pafirmin/grailwind/releases/tag/v0.1.0
package/README.md CHANGED
@@ -47,25 +47,59 @@ tinctures replace Tailwind's colours entirely.
47
47
  ## What's included
48
48
 
49
49
  - **Tinctures:** a tincture on its own sets the background (`azure`), and
50
- Tailwind's prefixes work as usual (`text-or`).
50
+ Tailwind's prefixes work as usual (`text-or`). There are also the furs `ermine`,
51
+ `ermines`, `erminois` and `pean`, which work in divisions too.
51
52
  - **The shield:** `escutcheon`, plus `bordure-*`.
52
53
  - **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
53
54
  `gyronny-or-sable` and more, or `per-pale`, `per-fess` and `quarterly` for
54
55
  layout.
55
56
  - **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`
56
- and more, with `fimbriated-*` for a narrow border, or `chevron-counterchanged`
57
- and the like on a divided field.
57
+ and more, with `fimbriated-*` for a narrow border, `fess-engrailed-or` and
58
+ the like for an engrailed or invected edge, `chief-ermine` and the like in a
59
+ fur, or `chevron-counterchanged` and the like on a divided field.
58
60
  - **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
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.
61
+ number, `in-pale`, `in-fess`, `in-bend`, `in-bend-sinister`, `on` or `between`
62
+ the ordinary, or placed at a named point such as `in-dexter-chief`, sized
63
+ with `charge-size-*`, and `counterchanged` on a divided field.
62
64
  - **Your own charges:** any SVG, via `charge-[url(…)]`.
63
65
 
66
+ ## Linting
67
+
68
+ For typos and clashing classes, use
69
+ [`eslint-plugin-better-tailwindcss`](https://github.com/schoero/eslint-plugin-better-tailwindcss).
70
+ It reads Grailwind's classes from your CSS entry file, so it flags `fess-ore` or
71
+ `per-pale-or-or`, and catches two classes fighting over the same thing, such as
72
+ `bend-sable bend-or`:
73
+
74
+ ```js
75
+ // eslint.config.js
76
+ import betterTailwindcss from "eslint-plugin-better-tailwindcss";
77
+
78
+ export default [
79
+ {
80
+ plugins: { "better-tailwindcss": betterTailwindcss },
81
+ settings: { "better-tailwindcss": { entryPoint: "src/app.css" } },
82
+ rules: {
83
+ "better-tailwindcss/no-unknown-classes": "error",
84
+ "better-tailwindcss/no-conflicting-classes": "error",
85
+ },
86
+ },
87
+ ];
88
+ ```
89
+
90
+ For the heraldry itself, such as the rule of tincture and charges outside a `charges`
91
+ container, there is [`@grailwind/eslint-plugin`](packages/eslint-plugin) (beta).
92
+
93
+ The plugin reads names from `grailwind/data`. That export is **experimental**: it's there for
94
+ Grailwind's own tooling, and its shape may change in any minor release.
95
+
64
96
  ## Development
65
97
 
66
98
  ```sh
67
99
  npm install
68
- npm test # checks both the default and strict builds
100
+ npm test # checks the default and strict builds, and the ESLint plugin
101
+ npm run test:render # renders shields in Chromium and Firefox and checks their colours;
102
+ # run `npx playwright install chromium firefox` first
69
103
  npm run format # formats with Prettier; CI runs format:check
70
104
  npm run dev:watch # rebuilds the docs site on change and serves it at http://localhost:8000
71
105
  ```
package/charges.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Grailwind CSS: charges and their arrangement.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
+ import { POINTS, ROUNDELS } from "./data.js";
5
6
 
6
7
  const star = (cx, cy, r, points = 5) => {
7
8
  const coords = [];
@@ -30,15 +31,6 @@ const SHAPES = {
30
31
  <path d="M42 65C38 74 28 80 16 78C20 88 36 90 44 74Z"/>`,
31
32
  };
32
33
 
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
34
  // Points as [left%, top%] of the shield, plus a charge size as % of its width.
43
35
  // The default for each number of charges, e.g. three are placed 2 and 1.
44
36
  // prettier-ignore
@@ -47,21 +39,10 @@ const DEFAULTS = {
47
39
  2: { size: 30, points: [[50, 24], [50, 66]] },
48
40
  3: { size: 28, points: [[28, 25], [72, 25], [50, 64]] },
49
41
  4: { size: 24, points: [[30, 24], [70, 24], [32, 60], [68, 60]] },
42
+ 5: { size: 22, points: [[27, 20], [73, 20], [50, 42], [32, 64], [68, 64]] },
50
43
  6: { size: 20, points: [[20, 18], [50, 18], [80, 18], [35, 42], [65, 42], [50, 66]] },
51
44
  };
52
45
 
53
- // The points of the shield, for placing a single charge, e.g. `in-dexter-chief`.
54
- const POINTS = {
55
- "dexter-chief": [25, 17],
56
- chief: [50, 15],
57
- "sinister-chief": [75, 17],
58
- "honour-point": [50, 30],
59
- "fess-point": [50, 45],
60
- "nombril-point": [50, 60],
61
- "dexter-base": [30, 72],
62
- base: [50, 84],
63
- "sinister-base": [70, 72],
64
- };
65
46
  const POINT_SIZE = 22;
66
47
 
67
48
  // With a chief on the shield, these points sit on it (see ordinaries.js).
@@ -81,6 +62,10 @@ const ARRANGEMENTS = {
81
62
  2: { size: 28, points: [[30, 26], [70, 64]] },
82
63
  3: { size: 22, points: [[22, 16], [50, 45], [78, 74]] },
83
64
  },
65
+ "in-bend-sinister": {
66
+ 2: { size: 28, points: [[70, 26], [30, 64]] },
67
+ 3: { size: 22, points: [[78, 16], [50, 45], [22, 74]] },
68
+ },
84
69
  };
85
70
 
86
71
  const svgUrl = (shape) =>
package/data.js ADDED
@@ -0,0 +1,100 @@
1
+ /*
2
+ * Grailwind data shared by the CSS plugins and tooling such as the ESLint plugin.
3
+ */
4
+
5
+ export const METALS = ["or", "argent"];
6
+ export const COLOURS = ["gules", "azure", "vert", "purpure", "sable"];
7
+ export const TINCTURES = [...METALS, ...COLOURS];
8
+
9
+ export const DIVISIONS = [
10
+ "per-pale",
11
+ "per-fess",
12
+ "per-bend",
13
+ "per-bend-sinister",
14
+ "quarterly",
15
+ "gyronny",
16
+ ];
17
+
18
+ export const ORDINARIES = [
19
+ "pale",
20
+ "fess",
21
+ "bend",
22
+ "bend-sinister",
23
+ "chevron",
24
+ "cross",
25
+ "saltire",
26
+ "pall",
27
+ "chief",
28
+ ];
29
+
30
+ export const CHARGES = [
31
+ "roundel",
32
+ "annulet",
33
+ "mullet",
34
+ "lozenge",
35
+ "billet",
36
+ "crescent",
37
+ "fleur-de-lis",
38
+ ];
39
+
40
+ // Roundels have their own names by tincture.
41
+ export const ROUNDELS = {
42
+ bezant: "or",
43
+ plate: "argent",
44
+ torteau: "gules",
45
+ hurt: "azure",
46
+ pellet: "sable",
47
+ };
48
+
49
+ // Lines of partition for an ordinary's edge, e.g. `fess-engrailed-or`.
50
+ export const LINES = ["engrailed", "invected"];
51
+
52
+ // Each fur's [ground, spot] tinctures.
53
+ export const FURS = {
54
+ ermine: ["argent", "sable"],
55
+ ermines: ["sable", "argent"],
56
+ erminois: ["or", "sable"],
57
+ pean: ["sable", "or"],
58
+ };
59
+
60
+ // The points of the shield, as [left%, top%], for placing a single charge, e.g. `in-dexter-chief`.
61
+ export const POINTS = {
62
+ "dexter-chief": [25, 17],
63
+ chief: [50, 15],
64
+ "sinister-chief": [75, 17],
65
+ "honour-point": [50, 30],
66
+ "fess-point": [50, 45],
67
+ "nombril-point": [50, 60],
68
+ "dexter-base": [30, 72],
69
+ base: [50, 84],
70
+ "sinister-base": [70, 72],
71
+ };
72
+
73
+ // Plain-field arrangements of a `charges` container, besides `between` and `on`.
74
+ export const ARRANGEMENTS = ["in-pale", "in-fess", "in-bend", "in-bend-sinister"];
75
+
76
+ // Where charges go for "between", as [left%, top%], plus their size as % of width.
77
+ // prettier-ignore
78
+ export const BETWEEN = {
79
+ pale: { size: 18, points: [[18, 42], [82, 42]] },
80
+ fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]] },
81
+ bend: { size: 24, points: [[70, 26], [30, 68]] },
82
+ "bend-sinister": { size: 24, points: [[30, 26], [70, 68]] },
83
+ chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]] },
84
+ cross: { size: 18, points: [[20, 17], [80, 17], [24, 72], [76, 72]] },
85
+ saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]] },
86
+ pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
87
+ };
88
+
89
+ // Where charges go for "on", along the ordinary's centre line, by number of charges.
90
+ // prettier-ignore
91
+ export const ON = {
92
+ pale: { size: 20, counts: { 1: [[50, 45]], 2: [[50, 28], [50, 62]], 3: [[50, 18], [50, 45], [50, 72]] } },
93
+ fess: { size: 22, counts: { 1: [[50, 45]], 2: [[30, 45], [70, 45]], 3: [[20, 45], [50, 45], [80, 45]] } },
94
+ bend: { size: 18, counts: { 1: [[50, 50]], 2: [[33, 33], [67, 67]], 3: [[24, 24], [50, 50], [76, 76]] } },
95
+ "bend-sinister": { size: 18, counts: { 1: [[50, 50]], 2: [[67, 33], [33, 67]], 3: [[76, 24], [50, 50], [24, 76]] } },
96
+ chevron: { size: 12, counts: { 1: [[50, 36]], 3: [[20, 65], [50, 36], [80, 65]] } },
97
+ cross: { size: 15, counts: { 1: [[50, 45]], 5: [[50, 15], [18, 45], [50, 45], [82, 45], [50, 80]] } },
98
+ saltire: { size: 14, counts: { 1: [[50, 50]], 5: [[22, 22], [78, 22], [50, 50], [22, 78], [78, 78]] } },
99
+ pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
100
+ };
package/divisions.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * Grailwind CSS: field divisions as decoration.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
-
6
- const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
5
+ import { FURS, TINCTURES } from "./data.js";
6
+ import { furLayers } from "./furs.js";
7
7
 
8
8
  // Horizontal lines of partition pass through the fess point, as the cross and fess do.
9
9
  const FESS_POINT = "45%";
@@ -27,10 +27,11 @@ const DIVISIONS = {
27
27
 
28
28
  // Quarters 1 and 4 (dexter chief, sinister base) are the first tincture.
29
29
  // Linear gradients rather than conic, which render jagged edges in some browsers.
30
- quarterly: (a, b) => ({
30
+ // With a fur, the bands can't overlap, or the fur's transparent half shows the band below.
31
+ quarterly: (a, b, fur) => ({
31
32
  "background-image": `linear-gradient(to right, ${a} 50%, ${b} 50%), linear-gradient(to right, ${b} 50%, ${a} 50%)`,
32
- "background-size": `100% ${FESS_POINT}, 100% 100%`,
33
- "background-position": "top, top",
33
+ "background-size": `100% ${FESS_POINT}, 100% ${fur ? `calc(100% - ${FESS_POINT})` : "100%"}`,
34
+ "background-position": fur ? "top, bottom" : "top, top",
34
35
  "background-repeat": "no-repeat",
35
36
  }),
36
37
 
@@ -51,6 +52,38 @@ const DIVISIONS = {
51
52
  }),
52
53
  };
53
54
 
55
+ // A division's background rules as one { image, size, position, repeat } per layer.
56
+ const toLayers = (rules) => {
57
+ return rules["background-image"].split(/, (?=linear-gradient)/).map((gradient, i) => {
58
+ const size = (rules["background-size"] ?? "100% 100%").split(", ");
59
+ const position = (rules["background-position"] ?? "0 0").split(", ");
60
+ const repeat = (rules["background-repeat"] ?? "no-repeat").split(", ");
61
+
62
+ return {
63
+ image: gradient,
64
+ size: size[i % size.length],
65
+ position: position[i % position.length],
66
+ repeat: repeat[i % repeat.length],
67
+ };
68
+ });
69
+ };
70
+
71
+ // A division with one fur: the fur's part is left transparent, over the fur's own layers.
72
+ const withFur = (division, first, second, theme) => {
73
+ const fur = FURS[first] ? first : second;
74
+ const paint = (t) => (t === fur ? "transparent" : `var(--color-${t})`);
75
+ const all = [
76
+ ...toLayers(DIVISIONS[division](paint(first), paint(second), true)),
77
+ ...furLayers(fur, theme),
78
+ ];
79
+ return Object.fromEntries(
80
+ ["image", "size", "position", "repeat"].map((p) => [
81
+ `background-${p}`,
82
+ all.map((layer) => layer[p]).join(", "),
83
+ ]),
84
+ );
85
+ };
86
+
54
87
  // The field with its tinctures swapped, for counterchanged ordinaries to paint.
55
88
  const counterchange = (rules) =>
56
89
  Object.fromEntries(
@@ -60,13 +93,15 @@ const counterchange = (rules) =>
60
93
  ]),
61
94
  );
62
95
 
63
- export default plugin(({ addUtilities }) => {
96
+ export default plugin(({ addUtilities, theme }) => {
64
97
  const utilities = {};
65
98
 
66
99
  for (const [division, background] of Object.entries(DIVISIONS)) {
67
100
  for (const first of TINCTURES) {
68
101
  for (const second of TINCTURES) {
69
- if (first === second) continue;
102
+ if (first === second) {
103
+ continue;
104
+ }
70
105
 
71
106
  const a = `var(--color-${first})`;
72
107
  const b = `var(--color-${second})`;
@@ -78,5 +113,22 @@ export default plugin(({ addUtilities }) => {
78
113
  }
79
114
  }
80
115
 
116
+ // One fur and one tincture; two furs can't each be kept to their own part.
117
+ for (const division of Object.keys(DIVISIONS)) {
118
+ for (const fur of Object.keys(FURS)) {
119
+ for (const tincture of TINCTURES) {
120
+ for (const [first, second] of [
121
+ [fur, tincture],
122
+ [tincture, fur],
123
+ ]) {
124
+ utilities[`.${division}-${first}-${second}`] = {
125
+ ...withFur(division, first, second, theme),
126
+ ...counterchange(withFur(division, second, first, theme)),
127
+ };
128
+ }
129
+ }
130
+ }
131
+ }
132
+
81
133
  addUtilities(utilities);
82
134
  });
package/furs.js ADDED
@@ -0,0 +1,78 @@
1
+ /*
2
+ * Grailwind CSS: furs, as a tiled pattern of spots over a ground tincture.
3
+ */
4
+ import plugin from "tailwindcss/plugin";
5
+ import { FURS } from "./data.js";
6
+
7
+ const SPOT = [
8
+ `<circle cx='0' cy='-8.5' r='1.9'/><circle cx='-3.4' cy='-5.6' r='1.9'/><circle cx='3.4' cy='-5.6' r='1.9'/>`,
9
+ `<path d='M0,-4.5 C0.8,-1 2.6,4 5,9.5 L2.1,7.6 L0,11 L-2.1,7.6 L-5,9.5 C-2.6,4 -0.8,-1 0,-4.5 Z'/>`,
10
+ ].join("");
11
+
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
+ // corner that join up with the neighbouring tiles.
14
+ const AT = [
15
+ [25, 30],
16
+ [0, 0],
17
+ [50, 0],
18
+ [0, 60],
19
+ [50, 60],
20
+ ];
21
+ const tile = (fill) =>
22
+ `url("data:image/svg+xml,${encodeURIComponent(
23
+ `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 60'><g fill='${fill}'>${AT.map(
24
+ ([x, y]) => `<g transform='translate(${x} ${y})'>${SPOT}</g>`,
25
+ ).join("")}</g></svg>`,
26
+ )}")`;
27
+
28
+ // The spots are baked into an SVG, which can't read CSS variables, so they take the theme's
29
+ // value at build time.
30
+ const spots = (fur, theme) => tile(theme(`colors.${FURS[fur][1]}`));
31
+
32
+ // Spots across the field, one per tile in each row.
33
+ const ACROSS = 6;
34
+
35
+ // A quarter of a tile up and left, so whole spots sit along the top and sides. A % position is a
36
+ // share of (box - tile), which is (ACROSS - 1) tiles.
37
+ const SHIFT = `${-100 / (4 * (ACROSS - 1))}%`;
38
+ const FIELD = { size: `calc(100% / ${ACROSS}) auto`, position: `${SHIFT} ${SHIFT}` };
39
+
40
+ // The layers under a fur in a divided field: the spots, then the ground.
41
+ export const furLayers = (fur, theme) => {
42
+ const ground = `var(--color-${FURS[fur][0]})`;
43
+ return [
44
+ { image: spots(fur, theme), ...FIELD, repeat: "repeat" },
45
+ {
46
+ image: `linear-gradient(${ground}, ${ground})`,
47
+ size: "100% 100%",
48
+ position: "0 0",
49
+ repeat: "no-repeat",
50
+ },
51
+ ];
52
+ };
53
+
54
+ // A fur on its own, as on the field or an ordinary.
55
+ export const furBackground = (fur, theme) => ({
56
+ "background-color": `var(--color-${FURS[fur][0]})`,
57
+ "background-image": spots(fur, theme),
58
+ "background-size": FIELD.size,
59
+ "background-position": FIELD.position,
60
+ "background-repeat": "repeat",
61
+ });
62
+
63
+ export default plugin(({ addUtilities, theme }) => {
64
+ const utilities = {};
65
+
66
+ for (const fur of Object.keys(FURS)) {
67
+ utilities[`.${fur}`] = {
68
+ ...furBackground(fur, theme),
69
+ // Two tiles across a charge, centred, so a whole spot sits in its middle.
70
+ ".charges > &": {
71
+ "background-size": "50% auto",
72
+ "background-position": "center",
73
+ },
74
+ };
75
+ }
76
+
77
+ addUtilities(utilities);
78
+ });
package/grailwind.css CHANGED
@@ -12,6 +12,7 @@
12
12
  @plugin "./divisions.js";
13
13
  @plugin "./ordinaries.js";
14
14
  @plugin "./charges.js";
15
+ @plugin "./furs.js";
15
16
 
16
17
  @property --fimbriated {
17
18
  syntax: "*";
@@ -60,6 +61,9 @@
60
61
 
61
62
  /* ---------------------------------------------------------------------------
62
63
  * Bare tinctures
64
+ *
65
+ * The furs (`ermine` and the like) are in furs.js, as they need the theme's
66
+ * colours at build time.
63
67
  * ------------------------------------------------------------------------- */
64
68
  @utility or {
65
69
  background-color: var(--color-or);
package/ordinaries.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * Grailwind CSS: ordinaries, e.g. `fess-or`.
3
3
  */
4
4
  import plugin from "tailwindcss/plugin";
5
-
6
- const TINCTURES = ["or", "argent", "gules", "azure", "vert", "purpure", "sable"];
5
+ import { BETWEEN, FURS, ON, TINCTURES } from "./data.js";
6
+ import { furBackground } from "./furs.js";
7
7
 
8
8
  // prettier-ignore
9
9
  const ORDINARIES = {
@@ -140,7 +140,9 @@ const DIRECTION = {
140
140
  const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
141
141
  const w = x1 - x0;
142
142
  const h = y1 - y0;
143
- if (kind.length > 1 && w !== h) throw new Error("diagonal bands need a square tile");
143
+ if (kind.length > 1 && w !== h) {
144
+ throw new Error("diagonal bands need a square tile");
145
+ }
144
146
  switch (kind) {
145
147
  case "x":
146
148
  return ((value - x0) * 100) / w;
@@ -183,30 +185,95 @@ const bands = (layers, tincture) => ({
183
185
  "background-repeat": "no-repeat",
184
186
  });
185
187
 
186
- // Where charges go for "between", as [left%, top%], plus their size as % of width.
187
- // prettier-ignore
188
- const BETWEEN = {
189
- pale: { size: 18, points: [[18, 42], [82, 42]] },
190
- fess: { size: 20, points: [[30, 15], [70, 15], [50, 78]] },
191
- bend: { size: 24, points: [[70, 26], [30, 68]] },
192
- "bend-sinister": { size: 24, points: [[30, 26], [70, 68]] },
193
- chevron: { size: 22, points: [[24, 22], [76, 22], [50, 70]] },
194
- cross: { size: 18, points: [[20, 17], [80, 17], [24, 72], [76, 72]] },
195
- saltire: { size: 16, points: [[50, 13], [14, 50], [86, 50], [50, 84]] },
196
- pall: { size: 18, points: [[50, 11], [18, 56], [82, 56]] },
188
+ // Lines of partition. Scallops are round on the 5:6 shield; below a chief the
189
+ // box is shorter, so they are slightly squashed.
190
+ const SCALLOP = 14;
191
+ const SCALLOPS = { chevron: 10 };
192
+ const DEPTH = 0.35;
193
+
194
+ const at = ([x, y]) => point([(x * 100) / W, (y * 100) / H]);
195
+
196
+ // Arcs along an edge, in real units: { to, r, sweep }.
197
+ const arcs = (a, b, scallop, sweep) => {
198
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
199
+ const n = Math.max(1, Math.round(length / scallop));
200
+ const chord = length / n;
201
+ const depth = chord * DEPTH;
202
+ const r = (chord * chord) / 4 / (2 * depth) + depth / 2;
203
+ return Array.from({ length: n }, (_, i) => {
204
+ const t = (i + 1) / n;
205
+ return { to: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], r, sweep };
206
+ });
197
207
  };
198
208
 
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]] } },
209
+ // Each line maps an edge to arcs; `inward` is the sweep that bulges into the ordinary.
210
+ const LINES = {
211
+ engrailed: (a, b, scallop, inward) => arcs(a, b, scallop, inward),
212
+ invected: (a, b, scallop, inward) => arcs(a, b, scallop, inward === "cw" ? "ccw" : "cw"),
213
+ };
214
+
215
+ const side = (a, b, p) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
216
+
217
+ // Centre of the minor arc from a to b.
218
+ const centre = (a, b, { r, sweep }) => {
219
+ const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
220
+ const len = Math.hypot(dx, dy);
221
+ const k = Math.sqrt(Math.max(0, r * r - (len / 2) ** 2)) * (sweep === "cw" ? 1 : -1);
222
+ return [(a[0] + b[0]) / 2 - (k * dy) / len, (a[1] + b[1]) / 2 + (k * dx) / len];
223
+ };
224
+
225
+ // Where the arc a→v crosses the arc v→b, if it does. Two circles through v meet
226
+ // again at v's reflection across the line between their centres.
227
+ const crossing = (a, v, b, first, second) => {
228
+ const c1 = centre(a, v, first);
229
+ const c2 = centre(v, b, second);
230
+ const [dx, dy] = [c2[0] - c1[0], c2[1] - c1[1]];
231
+ const t = ((v[0] - c1[0]) * dx + (v[1] - c1[1]) * dy) / (dx * dx + dy * dy);
232
+ const p = [2 * (c1[0] + t * dx) - v[0], 2 * (c1[1] + t * dy) - v[1]];
233
+ const onArc = (from, to, c) => Math.sign(side(from, to, p)) === -Math.sign(side(from, to, c));
234
+ return Math.hypot(p[0] - v[0], p[1] - v[1]) > 1e-6 && onArc(a, v, c1) && onArc(v, b, c2)
235
+ ? p
236
+ : null;
237
+ };
238
+
239
+ const shape = (vertices, line, scallop) => {
240
+ const inward = signedArea(vertices) > 0 ? "ccw" : "cw";
241
+ const pts = vertices.map(real);
242
+ const edges = pts.map((a, i) => {
243
+ const j = (i + 1) % pts.length;
244
+ return onShieldEdge(vertices[i], vertices[j])
245
+ ? [{ to: pts[j] }]
246
+ : line(a, pts[j], scallop, inward);
247
+ });
248
+
249
+ // Where arcs cross at a corner, e.g. a chevron engrailed's peak, end both at the
250
+ // crossing; otherwise the loop beyond it is filled.
251
+ let start = pts[0];
252
+ edges.forEach((edge, i) => {
253
+ const last = edge.at(-1);
254
+ const first = edges[(i + 1) % edges.length][0];
255
+ if (!last.r || !first.r) {
256
+ return;
257
+ }
258
+ const from = edge.length > 1 ? edge.at(-2).to : pts[i];
259
+ const p = crossing(from, last.to, first.to, last, first);
260
+ if (!p) {
261
+ return;
262
+ }
263
+ last.to = p;
264
+ if (i === edges.length - 1) {
265
+ start = p;
266
+ }
267
+ });
268
+
269
+ const commands = edges
270
+ .flat()
271
+ .map(({ to, r, sweep }) =>
272
+ r
273
+ ? `arc to ${at(to)} of ${pct((r / W) * 100)} ${pct((r / H) * 100)} ${sweep}`
274
+ : `line to ${at(to)}`,
275
+ );
276
+ return `shape(from ${at(start)}, ${commands.join(", ")}, close)`;
210
277
  };
211
278
 
212
279
  const betweenVars = ({ size, points }) => {
@@ -229,7 +296,7 @@ const onVars = ({ size, counts }) => {
229
296
  return vars;
230
297
  };
231
298
 
232
- export default plugin(({ addUtilities }) => {
299
+ export default plugin(({ addUtilities, theme }) => {
233
300
  const utilities = {};
234
301
 
235
302
  for (const tincture of TINCTURES) {
@@ -239,8 +306,11 @@ export default plugin(({ addUtilities }) => {
239
306
  };
240
307
  }
241
308
 
309
+ // Isolated, so an ordinary in a quarter paints under the shield's own ordinary.
310
+ // The chief's ::after stays unraised, so charges on it stay on top.
242
311
  const ordinaryRules = (ordinary, pseudo) => ({
243
312
  position: "relative",
313
+ isolation: "isolate",
244
314
  ...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
245
315
  ...(ON[ordinary] && onVars(ON[ordinary])),
246
316
  // With a chief, everything else is drawn in the field below it.
@@ -250,7 +320,12 @@ export default plugin(({ addUtilities }) => {
250
320
  "--chief-points-y": "calc(-15% / 0.7)",
251
321
  "--chief-points-size": "18%",
252
322
  }),
253
- [PSEUDO[ordinary] ?? "&::before"]: { content: '""', position: "absolute", ...pseudo },
323
+ [PSEUDO[ordinary] ?? "&::before"]: {
324
+ content: '""',
325
+ position: "absolute",
326
+ ...(!PSEUDO[ordinary] && { "z-index": "1" }),
327
+ ...pseudo,
328
+ },
254
329
  });
255
330
 
256
331
  for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
@@ -292,6 +367,32 @@ export default plugin(({ addUtilities }) => {
292
367
  "clip-path": polygon(vertices),
293
368
  }),
294
369
  });
370
+ // Not fimbriated. Browsers without shape() keep the straight polygon.
371
+ for (const [name, line] of Object.entries(LINES)) {
372
+ utilities[`.${ordinary}-${name}-${tincture}`] = ordinaryRules(ordinary, {
373
+ inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
374
+ "background-color": `var(--color-${tincture})`,
375
+ "clip-path": [polygon(vertices), shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP)],
376
+ });
377
+ }
378
+ }
379
+
380
+ // A plain polygon, without the fimbriation vars, so fimbriated-* can't reach a fur ordinary.
381
+ for (const fur of Object.keys(FURS)) {
382
+ const pseudo = {
383
+ inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
384
+ ...furBackground(fur, theme),
385
+ };
386
+ utilities[`.${ordinary}-${fur}`] = ordinaryRules(ordinary, {
387
+ ...pseudo,
388
+ "clip-path": polygon(vertices),
389
+ });
390
+ for (const [name, line] of Object.entries(LINES)) {
391
+ utilities[`.${ordinary}-${name}-${fur}`] = ordinaryRules(ordinary, {
392
+ ...pseudo,
393
+ "clip-path": [polygon(vertices), shape(vertices, line, SCALLOPS[ordinary] ?? SCALLOP)],
394
+ });
395
+ }
295
396
  }
296
397
  }
297
398
 
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.3.0",
3
+ "version": "0.4.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 && npm test -w packages/eslint-plugin",
8
+ "test:render": "node test/render.mjs",
8
9
  "dev:watch": "tailwindcss -i docs/app.css -o docs/site.css --watch=always & python3 -m http.server ${PORT:-8000} -d docs",
10
+ "lint": "eslint .",
9
11
  "format": "prettier --write .",
10
12
  "format:check": "prettier --check ."
11
13
  },
@@ -20,11 +22,18 @@
20
22
  "license": "MIT",
21
23
  "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
22
24
  "devDependencies": {
25
+ "@grailwind/eslint-plugin": "^0.1.0-beta.0",
26
+ "@html-eslint/parser": "^0.67.0",
23
27
  "@tailwindcss/cli": "^4.3.3",
28
+ "eslint": "^10.14.0",
29
+ "playwright": "~1.64.1",
24
30
  "prettier": "^3.9.10",
25
31
  "tailwindcss": "^4.3.3"
26
32
  },
27
33
  "type": "module",
34
+ "workspaces": [
35
+ "packages/*"
36
+ ],
28
37
  "style": "grailwind.css",
29
38
  "exports": {
30
39
  ".": {
@@ -34,13 +43,16 @@
34
43
  "./strict": {
35
44
  "style": "./strict.css",
36
45
  "default": "./strict.css"
37
- }
46
+ },
47
+ "./data": "./data.js"
38
48
  },
39
49
  "files": [
50
+ "data.js",
40
51
  "grailwind.css",
41
52
  "divisions.js",
42
53
  "ordinaries.js",
43
54
  "charges.js",
55
+ "furs.js",
44
56
  "strict.css",
45
57
  "palette-reset.css",
46
58
  "CHANGELOG.md"