grailwind 0.2.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 +51 -0
- package/README.md +45 -9
- package/charges.js +48 -29
- package/data.js +100 -0
- package/divisions.js +79 -14
- package/furs.js +78 -0
- package/grailwind.css +47 -7
- package/ordinaries.js +203 -59
- package/package.json +20 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project are documented here. The format is based
|
|
|
4
4
|
on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
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
|
+
|
|
37
|
+
## [0.3.0] - 2026-11-01
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Counterchanged ordinaries: `<ordinary>-counterchanged` swaps the tinctures
|
|
42
|
+
of a divided field, e.g. `per-pale-argent-gules chevron-counterchanged`.
|
|
43
|
+
- Counterchanged charges: `counterchanged` on a charge, or on the `charges`
|
|
44
|
+
container for all of them.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- The horizontal line of `per-fess-*` and `quarterly-*` fields now passes
|
|
49
|
+
through the fess point, 45% down the shield, where the fess and cross are
|
|
50
|
+
centred. It was at 50%.
|
|
51
|
+
- Charges are centred with negative margins instead of `translate`, so
|
|
52
|
+
Tailwind's `translate-*` on a charge now moves it from its place.
|
|
53
|
+
- `escutcheon` sets `contain: layout`.
|
|
54
|
+
|
|
7
55
|
## [0.2.0] - 2026-11-01
|
|
8
56
|
|
|
9
57
|
### Added
|
|
@@ -23,5 +71,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
23
71
|
Initial release: tinctures, divisions, ordinaries, charges, strict mode and
|
|
24
72
|
custom SVG charges.
|
|
25
73
|
|
|
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
|
|
76
|
+
[0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
|
|
26
77
|
[0.2.0]: https://github.com/pafirmin/grailwind/compare/v0.1.0...v0.2.0
|
|
27
78
|
[0.1.0]: https://github.com/pafirmin/grailwind/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Grailwind CSS
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
Heraldic blazon for [Tailwind CSS](https://tailwindcss.com). Draw coats of
|
|
6
6
|
arms, or style anything else, in the language heralds have used for 800 years:
|
|
@@ -15,10 +15,10 @@ arms, or style anything else, in the language heralds have used for 800 years:
|
|
|
15
15
|
</div>
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
That's
|
|
18
|
+
That's _Azure, a chevron Or between three mullets Argent_: a blue shield, a
|
|
19
19
|
gold chevron, and three silver stars around it.
|
|
20
20
|
|
|
21
|
-
**[Read the documentation](https://
|
|
21
|
+
**[Read the documentation](https://grailwind.dev/)** for every class, with live examples.
|
|
22
22
|
|
|
23
23
|
> Grailwind is a light-hearted project at version 0.x, so class names may
|
|
24
24
|
> still change.
|
|
@@ -47,24 +47,60 @@ 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
|
|
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.
|
|
57
60
|
- **Charges:** `mullet`, `fleur-de-lis`, `bezant` and more, arranged by
|
|
58
|
-
number, `in-pale`, `in-fess`, `in-bend`, `on` or `between`
|
|
59
|
-
placed at a named point such as `in-dexter-chief`,
|
|
60
|
-
`charge-size
|
|
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.
|
|
61
64
|
- **Your own charges:** any SVG, via `charge-[url(…)]`.
|
|
62
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
|
+
|
|
63
96
|
## Development
|
|
64
97
|
|
|
65
98
|
```sh
|
|
66
99
|
npm install
|
|
67
|
-
npm test # checks
|
|
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
|
|
103
|
+
npm run format # formats with Prettier; CI runs format:check
|
|
68
104
|
npm run dev:watch # rebuilds the docs site on change and serves it at http://localhost:8000
|
|
69
105
|
```
|
|
70
106
|
|
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,42 +31,24 @@ 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.
|
|
36
|
+
// prettier-ignore
|
|
44
37
|
const DEFAULTS = {
|
|
45
38
|
1: { size: 50, points: [[50, 45]] },
|
|
46
39
|
2: { size: 30, points: [[50, 24], [50, 66]] },
|
|
47
40
|
3: { size: 28, points: [[28, 25], [72, 25], [50, 64]] },
|
|
48
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]] },
|
|
49
43
|
6: { size: 20, points: [[20, 18], [50, 18], [80, 18], [35, 42], [65, 42], [50, 66]] },
|
|
50
44
|
};
|
|
51
45
|
|
|
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
46
|
const POINT_SIZE = 22;
|
|
65
47
|
|
|
66
48
|
// With a chief on the shield, these points sit on it (see ordinaries.js).
|
|
67
49
|
const CHIEF_POINTS = new Set(["dexter-chief", "chief", "sinister-chief"]);
|
|
68
50
|
|
|
51
|
+
// prettier-ignore
|
|
69
52
|
const ARRANGEMENTS = {
|
|
70
53
|
"in-pale": {
|
|
71
54
|
2: { size: 30, points: [[50, 24], [50, 66]] },
|
|
@@ -79,6 +62,10 @@ const ARRANGEMENTS = {
|
|
|
79
62
|
2: { size: 28, points: [[30, 26], [70, 64]] },
|
|
80
63
|
3: { size: 22, points: [[22, 16], [50, 45], [78, 74]] },
|
|
81
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
|
+
},
|
|
82
69
|
};
|
|
83
70
|
|
|
84
71
|
const svgUrl = (shape) =>
|
|
@@ -96,9 +83,30 @@ const maskRules = (image) => ({
|
|
|
96
83
|
const place = ([left, top], size) => ({
|
|
97
84
|
left: `${left}%`,
|
|
98
85
|
top: `${top}%`,
|
|
99
|
-
width: `var(--charge-size, ${size}%)`,
|
|
86
|
+
"--charge-width": `var(--charge-size, ${size}%)`,
|
|
100
87
|
});
|
|
101
88
|
|
|
89
|
+
// The field with its tinctures swapped, from the shield's division (see divisions.js).
|
|
90
|
+
const COUNTERCHANGE = ["color", "image", "size", "position", "repeat"];
|
|
91
|
+
const inheritCounterchange = Object.fromEntries(
|
|
92
|
+
COUNTERCHANGE.map((n) => [`--counterchange-${n}`, "inherit"]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// A layer the size of the shield, as the escutcheon is the containing block
|
|
96
|
+
// for fixed positioning. The charge's mask cuts it to shape.
|
|
97
|
+
const counterchanged = {
|
|
98
|
+
...inheritCounterchange,
|
|
99
|
+
"&::before": {
|
|
100
|
+
content: '""',
|
|
101
|
+
position: "fixed",
|
|
102
|
+
inset: "0",
|
|
103
|
+
...inheritCounterchange,
|
|
104
|
+
...Object.fromEntries(
|
|
105
|
+
COUNTERCHANGE.map((n) => [`background-${n}`, `var(--counterchange-${n})`]),
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
102
110
|
const hasCount = (n) => `:has(> :nth-child(${n}):last-child)`;
|
|
103
111
|
|
|
104
112
|
export default plugin(({ addUtilities, matchUtilities }) => {
|
|
@@ -124,17 +132,20 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
124
132
|
inset: "var(--field-top, 0%) 0 0",
|
|
125
133
|
// Above the chief, which is painted last, so charges can sit on it.
|
|
126
134
|
"z-index": "1",
|
|
135
|
+
...inheritCounterchange,
|
|
127
136
|
"& > *": {
|
|
128
137
|
position: "absolute",
|
|
129
138
|
"aspect-ratio": "1",
|
|
130
|
-
translate:
|
|
139
|
+
// Centred with margins, not translate: a transform would stop a
|
|
140
|
+
// counterchanged charge's fixed layer from lining up with the shield.
|
|
141
|
+
width: "var(--charge-width)",
|
|
142
|
+
margin: "calc(var(--charge-width) / -2) 0 0 calc(var(--charge-width) / -2)",
|
|
131
143
|
"object-fit": "contain",
|
|
132
144
|
},
|
|
133
145
|
};
|
|
134
146
|
for (const [count, { size, points }] of Object.entries(DEFAULTS)) {
|
|
135
147
|
points.forEach((point, i) => {
|
|
136
|
-
charges[`&:where(${hasCount(count)}) > :where(:nth-child(${i + 1}))`] =
|
|
137
|
-
place(point, size);
|
|
148
|
+
charges[`&:where(${hasCount(count)}) > :where(:nth-child(${i + 1}))`] = place(point, size);
|
|
138
149
|
});
|
|
139
150
|
}
|
|
140
151
|
utilities[".charges"] = charges;
|
|
@@ -155,7 +166,7 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
155
166
|
between[`& > :nth-child(${i})`] = {
|
|
156
167
|
left: `var(--between-${i}-x)`,
|
|
157
168
|
top: `var(--between-${i}-y)`,
|
|
158
|
-
width: "var(--charge-size, var(--between-size))",
|
|
169
|
+
"--charge-width": "var(--charge-size, var(--between-size))",
|
|
159
170
|
};
|
|
160
171
|
}
|
|
161
172
|
utilities[".between"] = between;
|
|
@@ -168,11 +179,13 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
168
179
|
for (let i = 1; i <= count; i++) {
|
|
169
180
|
const [left, top] = fallback?.points[i - 1] ?? [];
|
|
170
181
|
const at = (axis, value) =>
|
|
171
|
-
value === undefined
|
|
182
|
+
value === undefined
|
|
183
|
+
? `var(--on-${count}-${i}-${axis})`
|
|
184
|
+
: `var(--on-${count}-${i}-${axis}, ${value}%)`;
|
|
172
185
|
on[`&${hasCount(count)} > :nth-child(${i})`] = {
|
|
173
186
|
left: at("x", left),
|
|
174
187
|
top: at("y", top),
|
|
175
|
-
width: `var(--charge-size, var(--on-size, ${fallback?.size ?? 16}%))`,
|
|
188
|
+
"--charge-width": `var(--charge-size, var(--on-size, ${fallback?.size ?? 16}%))`,
|
|
176
189
|
};
|
|
177
190
|
}
|
|
178
191
|
}
|
|
@@ -184,11 +197,17 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
184
197
|
? {
|
|
185
198
|
left: `${left}%`,
|
|
186
199
|
top: `var(--chief-points-y, ${top}%)`,
|
|
187
|
-
width: `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
|
|
200
|
+
"--charge-width": `var(--charge-size, var(--chief-points-size, ${POINT_SIZE}%))`,
|
|
188
201
|
}
|
|
189
202
|
: place([left, top], POINT_SIZE),
|
|
190
203
|
};
|
|
191
204
|
}
|
|
192
205
|
|
|
206
|
+
// On a charge, or on `charges` for all of them.
|
|
207
|
+
utilities[".counterchanged"] = {
|
|
208
|
+
".charges > &": counterchanged,
|
|
209
|
+
"&.charges > *": counterchanged,
|
|
210
|
+
};
|
|
211
|
+
|
|
193
212
|
addUtilities(utilities);
|
|
194
213
|
});
|
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,11 @@
|
|
|
2
2
|
* Grailwind CSS: field divisions as decoration.
|
|
3
3
|
*/
|
|
4
4
|
import plugin from "tailwindcss/plugin";
|
|
5
|
+
import { FURS, TINCTURES } from "./data.js";
|
|
6
|
+
import { furLayers } from "./furs.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
// Horizontal lines of partition pass through the fess point, as the cross and fess do.
|
|
9
|
+
const FESS_POINT = "45%";
|
|
7
10
|
|
|
8
11
|
// Each division maps (first, second) tincture colours to background rules.
|
|
9
12
|
const layer = (image) => ({ "background-image": image });
|
|
@@ -12,23 +15,23 @@ 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}
|
|
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
|
+
// With a fur, the bands can't overlap, or the fur's transparent half shows the band below.
|
|
31
|
+
quarterly: (a, b, fur) => ({
|
|
29
32
|
"background-image": `linear-gradient(to right, ${a} 50%, ${b} 50%), linear-gradient(to right, ${b} 50%, ${a} 50%)`,
|
|
30
|
-
"background-size":
|
|
31
|
-
"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",
|
|
32
35
|
"background-repeat": "no-repeat",
|
|
33
36
|
}),
|
|
34
37
|
|
|
@@ -49,18 +52,80 @@ const DIVISIONS = {
|
|
|
49
52
|
}),
|
|
50
53
|
};
|
|
51
54
|
|
|
52
|
-
|
|
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
|
+
|
|
87
|
+
// The field with its tinctures swapped, for counterchanged ordinaries to paint.
|
|
88
|
+
const counterchange = (rules) =>
|
|
89
|
+
Object.fromEntries(
|
|
90
|
+
Object.entries(rules).map(([property, value]) => [
|
|
91
|
+
property.replace("background-", "--counterchange-"),
|
|
92
|
+
value,
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export default plugin(({ addUtilities, theme }) => {
|
|
53
97
|
const utilities = {};
|
|
54
98
|
|
|
55
99
|
for (const [division, background] of Object.entries(DIVISIONS)) {
|
|
56
100
|
for (const first of TINCTURES) {
|
|
57
101
|
for (const second of TINCTURES) {
|
|
58
|
-
if (first === second)
|
|
102
|
+
if (first === second) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const a = `var(--color-${first})`;
|
|
107
|
+
const b = `var(--color-${second})`;
|
|
108
|
+
utilities[`.${division}-${first}-${second}`] = {
|
|
109
|
+
...background(a, b),
|
|
110
|
+
...counterchange(background(b, a)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
59
115
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
129
|
}
|
|
65
130
|
}
|
|
66
131
|
}
|
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: "*";
|
|
@@ -21,6 +22,26 @@
|
|
|
21
22
|
syntax: "*";
|
|
22
23
|
inherits: false;
|
|
23
24
|
}
|
|
25
|
+
@property --counterchange-color {
|
|
26
|
+
syntax: "*";
|
|
27
|
+
inherits: false;
|
|
28
|
+
}
|
|
29
|
+
@property --counterchange-image {
|
|
30
|
+
syntax: "*";
|
|
31
|
+
inherits: false;
|
|
32
|
+
}
|
|
33
|
+
@property --counterchange-size {
|
|
34
|
+
syntax: "*";
|
|
35
|
+
inherits: false;
|
|
36
|
+
}
|
|
37
|
+
@property --counterchange-position {
|
|
38
|
+
syntax: "*";
|
|
39
|
+
inherits: false;
|
|
40
|
+
}
|
|
41
|
+
@property --counterchange-repeat {
|
|
42
|
+
syntax: "*";
|
|
43
|
+
inherits: false;
|
|
44
|
+
}
|
|
24
45
|
|
|
25
46
|
/* ---------------------------------------------------------------------------
|
|
26
47
|
* Tinctures
|
|
@@ -40,14 +61,31 @@
|
|
|
40
61
|
|
|
41
62
|
/* ---------------------------------------------------------------------------
|
|
42
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.
|
|
43
67
|
* ------------------------------------------------------------------------- */
|
|
44
|
-
@utility or
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@utility
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@utility
|
|
68
|
+
@utility or {
|
|
69
|
+
background-color: var(--color-or);
|
|
70
|
+
}
|
|
71
|
+
@utility argent {
|
|
72
|
+
background-color: var(--color-argent);
|
|
73
|
+
}
|
|
74
|
+
@utility gules {
|
|
75
|
+
background-color: var(--color-gules);
|
|
76
|
+
}
|
|
77
|
+
@utility azure {
|
|
78
|
+
background-color: var(--color-azure);
|
|
79
|
+
}
|
|
80
|
+
@utility vert {
|
|
81
|
+
background-color: var(--color-vert);
|
|
82
|
+
}
|
|
83
|
+
@utility purpure {
|
|
84
|
+
background-color: var(--color-purpure);
|
|
85
|
+
}
|
|
86
|
+
@utility sable {
|
|
87
|
+
background-color: var(--color-sable);
|
|
88
|
+
}
|
|
51
89
|
|
|
52
90
|
/* ---------------------------------------------------------------------------
|
|
53
91
|
* Field divisions
|
|
@@ -97,6 +135,8 @@
|
|
|
97
135
|
overflow: hidden;
|
|
98
136
|
border-radius: 0 0 50% 50% / 0 0 45% 45%;
|
|
99
137
|
box-shadow: 0 0 0 2px var(--color-sable);
|
|
138
|
+
/* The containing block for counterchanged charges' fixed layers. */
|
|
139
|
+
contain: layout;
|
|
100
140
|
}
|
|
101
141
|
|
|
102
142
|
/* ---------------------------------------------------------------------------
|
package/ordinaries.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Grailwind CSS: ordinaries, e.g. `fess-or`.
|
|
3
3
|
*/
|
|
4
4
|
import plugin from "tailwindcss/plugin";
|
|
5
|
+
import { BETWEEN, FURS, ON, TINCTURES } from "./data.js";
|
|
6
|
+
import { furBackground } from "./furs.js";
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
// prettier-ignore
|
|
8
9
|
const ORDINARIES = {
|
|
9
10
|
pale: [[35, 0], [65, 0], [65, 100], [35, 100]],
|
|
10
11
|
fess: [[0, 30], [100, 30], [100, 60], [0, 60]],
|
|
@@ -33,6 +34,10 @@ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
|
|
|
33
34
|
|
|
34
35
|
const PSEUDO = { chief: "&::after" };
|
|
35
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
|
+
|
|
36
41
|
// Fimbriation: the ordinary's clip is widened and filled with the fimbriation
|
|
37
42
|
// tincture, and the ordinary's own tincture is painted over it as gradient
|
|
38
43
|
// bands.
|
|
@@ -99,6 +104,7 @@ const widenedPolygon = (vertices) => {
|
|
|
99
104
|
const S = 12;
|
|
100
105
|
const band = (kind, from, to, tile) => ({ kind, from, to, tile });
|
|
101
106
|
const stripe = (kind, from, to, tile) => ({ kind, from, to, tile, stripe: true });
|
|
107
|
+
// prettier-ignore
|
|
102
108
|
const BANDS = {
|
|
103
109
|
pale: [band("x", 35, 65)],
|
|
104
110
|
fess: [band("y", 30, 60)],
|
|
@@ -123,18 +129,29 @@ const BANDS = {
|
|
|
123
129
|
],
|
|
124
130
|
};
|
|
125
131
|
|
|
126
|
-
const DIRECTION = {
|
|
132
|
+
const DIRECTION = {
|
|
133
|
+
x: "to right",
|
|
134
|
+
y: "to bottom",
|
|
135
|
+
"u-v": "to top right",
|
|
136
|
+
"u+v": "to bottom right",
|
|
137
|
+
};
|
|
127
138
|
|
|
128
139
|
// A band's edges as % along its gradient, within its tile.
|
|
129
140
|
const stop = ({ kind, tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }, value) => {
|
|
130
141
|
const w = x1 - x0;
|
|
131
142
|
const h = y1 - y0;
|
|
132
|
-
if (kind.length > 1 && w !== h)
|
|
143
|
+
if (kind.length > 1 && w !== h) {
|
|
144
|
+
throw new Error("diagonal bands need a square tile");
|
|
145
|
+
}
|
|
133
146
|
switch (kind) {
|
|
134
|
-
case "x":
|
|
135
|
-
|
|
136
|
-
case "
|
|
137
|
-
|
|
147
|
+
case "x":
|
|
148
|
+
return ((value - x0) * 100) / w;
|
|
149
|
+
case "y":
|
|
150
|
+
return ((value - y0) * 100) / h;
|
|
151
|
+
case "u-v":
|
|
152
|
+
return ((value - (x0 - y0)) * 100) / w / 2 + 50;
|
|
153
|
+
case "u+v":
|
|
154
|
+
return ((value - x0 - y0) * 100) / w / 2;
|
|
138
155
|
}
|
|
139
156
|
};
|
|
140
157
|
|
|
@@ -152,40 +169,111 @@ const tilePosition = (start, size) => (size === 100 ? "0" : pct((start * 100) /
|
|
|
152
169
|
|
|
153
170
|
const bands = (layers, tincture) => ({
|
|
154
171
|
"background-image": layers
|
|
155
|
-
.map((l) =>
|
|
172
|
+
.map((l) =>
|
|
173
|
+
gradient(l, l.stripe ? "var(--fimbriation, transparent)" : `var(--color-${tincture})`),
|
|
174
|
+
)
|
|
156
175
|
.join(", "),
|
|
157
176
|
"background-size": layers
|
|
158
177
|
.map(({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) => `${pct(x1 - x0)} ${pct(y1 - y0)}`)
|
|
159
178
|
.join(", "),
|
|
160
179
|
"background-position": layers
|
|
161
|
-
.map(
|
|
162
|
-
|
|
180
|
+
.map(
|
|
181
|
+
({ tile: [x0, y0, x1, y1] = [0, 0, 100, 100] }) =>
|
|
182
|
+
`${tilePosition(x0, x1 - x0)} ${tilePosition(y0, y1 - y0)}`,
|
|
183
|
+
)
|
|
163
184
|
.join(", "),
|
|
164
185
|
"background-repeat": "no-repeat",
|
|
165
186
|
});
|
|
166
187
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
});
|
|
207
|
+
};
|
|
208
|
+
|
|
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"),
|
|
177
213
|
};
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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)`;
|
|
189
277
|
};
|
|
190
278
|
|
|
191
279
|
const betweenVars = ({ size, points }) => {
|
|
@@ -208,7 +296,7 @@ const onVars = ({ size, counts }) => {
|
|
|
208
296
|
return vars;
|
|
209
297
|
};
|
|
210
298
|
|
|
211
|
-
export default plugin(({ addUtilities }) => {
|
|
299
|
+
export default plugin(({ addUtilities, theme }) => {
|
|
212
300
|
const utilities = {};
|
|
213
301
|
|
|
214
302
|
for (const tincture of TINCTURES) {
|
|
@@ -218,37 +306,93 @@ export default plugin(({ addUtilities }) => {
|
|
|
218
306
|
};
|
|
219
307
|
}
|
|
220
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.
|
|
311
|
+
const ordinaryRules = (ordinary, pseudo) => ({
|
|
312
|
+
position: "relative",
|
|
313
|
+
isolation: "isolate",
|
|
314
|
+
...(BETWEEN[ordinary] && betweenVars(BETWEEN[ordinary])),
|
|
315
|
+
...(ON[ordinary] && onVars(ON[ordinary])),
|
|
316
|
+
// With a chief, everything else is drawn in the field below it.
|
|
317
|
+
...(ordinary === "chief" && {
|
|
318
|
+
"--field-top": "30%",
|
|
319
|
+
// The chief's centre line, relative to the charges container below it.
|
|
320
|
+
"--chief-points-y": "calc(-15% / 0.7)",
|
|
321
|
+
"--chief-points-size": "18%",
|
|
322
|
+
}),
|
|
323
|
+
[PSEUDO[ordinary] ?? "&::before"]: {
|
|
324
|
+
content: '""',
|
|
325
|
+
position: "absolute",
|
|
326
|
+
...(!PSEUDO[ordinary] && { "z-index": "1" }),
|
|
327
|
+
...pseudo,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
221
331
|
for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
|
|
332
|
+
// Covers the whole shield, so the swapped field lines up with the real one,
|
|
333
|
+
// and the clip moves the ordinary below any chief instead.
|
|
334
|
+
utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
|
|
335
|
+
inset: "0",
|
|
336
|
+
// Registered not to inherit, so a shield inside another can't pick up its swap.
|
|
337
|
+
...Object.fromEntries(
|
|
338
|
+
["color", "image", "size", "position", "repeat"].map((n) => [
|
|
339
|
+
`--counterchange-${n}`,
|
|
340
|
+
"inherit",
|
|
341
|
+
]),
|
|
342
|
+
),
|
|
343
|
+
"background-color": "var(--counterchange-color, transparent)",
|
|
344
|
+
"background-image": "var(--counterchange-image, none)",
|
|
345
|
+
"background-size": "var(--counterchange-size, auto)",
|
|
346
|
+
"background-position": "var(--counterchange-position, 0 0)",
|
|
347
|
+
"background-repeat": "var(--counterchange-repeat, repeat)",
|
|
348
|
+
"clip-path":
|
|
349
|
+
ordinary === "chief"
|
|
350
|
+
? polygon(vertices)
|
|
351
|
+
: `polygon(${vertices.map(([x, y]) => `${pct(x)} ${belowChief(y)}`).join(", ")})`,
|
|
352
|
+
});
|
|
353
|
+
|
|
222
354
|
for (const tincture of TINCTURES) {
|
|
223
|
-
utilities[`.${ordinary}-${tincture}`] = {
|
|
224
|
-
|
|
225
|
-
...(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
355
|
+
utilities[`.${ordinary}-${tincture}`] = ordinaryRules(ordinary, {
|
|
356
|
+
inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
|
|
357
|
+
...(BANDS[ordinary]
|
|
358
|
+
? {
|
|
359
|
+
"--fimbriated": "inherit",
|
|
360
|
+
"--fimbriation": "inherit",
|
|
361
|
+
"background-color": `var(--fimbriation, var(--color-${tincture}))`,
|
|
362
|
+
...bands(BANDS[ordinary], tincture),
|
|
363
|
+
"clip-path": widenedPolygon(vertices),
|
|
364
|
+
}
|
|
365
|
+
: {
|
|
366
|
+
"background-color": `var(--color-${tincture})`,
|
|
367
|
+
"clip-path": polygon(vertices),
|
|
368
|
+
}),
|
|
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, {
|
|
237
373
|
inset: ordinary === "chief" ? "0" : "var(--field-top, 0%) 0 0",
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}),
|
|
250
|
-
},
|
|
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),
|
|
251
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
|
+
}
|
|
252
396
|
}
|
|
253
397
|
}
|
|
254
398
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grailwind",
|
|
3
|
-
"version": "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",
|
|
8
|
-
"
|
|
7
|
+
"test": "node test/modes.mjs && npm test -w packages/eslint-plugin",
|
|
8
|
+
"test:render": "node test/render.mjs",
|
|
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 .",
|
|
11
|
+
"format": "prettier --write .",
|
|
12
|
+
"format:check": "prettier --check ."
|
|
9
13
|
},
|
|
10
14
|
"keywords": [
|
|
11
15
|
"tailwindcss",
|
|
@@ -18,10 +22,18 @@
|
|
|
18
22
|
"license": "MIT",
|
|
19
23
|
"description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
|
|
20
24
|
"devDependencies": {
|
|
25
|
+
"@grailwind/eslint-plugin": "^0.1.0-beta.0",
|
|
26
|
+
"@html-eslint/parser": "^0.67.0",
|
|
21
27
|
"@tailwindcss/cli": "^4.3.3",
|
|
28
|
+
"eslint": "^10.14.0",
|
|
29
|
+
"playwright": "~1.64.1",
|
|
30
|
+
"prettier": "^3.9.10",
|
|
22
31
|
"tailwindcss": "^4.3.3"
|
|
23
32
|
},
|
|
24
33
|
"type": "module",
|
|
34
|
+
"workspaces": [
|
|
35
|
+
"packages/*"
|
|
36
|
+
],
|
|
25
37
|
"style": "grailwind.css",
|
|
26
38
|
"exports": {
|
|
27
39
|
".": {
|
|
@@ -31,13 +43,16 @@
|
|
|
31
43
|
"./strict": {
|
|
32
44
|
"style": "./strict.css",
|
|
33
45
|
"default": "./strict.css"
|
|
34
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"./data": "./data.js"
|
|
35
48
|
},
|
|
36
49
|
"files": [
|
|
50
|
+
"data.js",
|
|
37
51
|
"grailwind.css",
|
|
38
52
|
"divisions.js",
|
|
39
53
|
"ordinaries.js",
|
|
40
54
|
"charges.js",
|
|
55
|
+
"furs.js",
|
|
41
56
|
"strict.css",
|
|
42
57
|
"palette-reset.css",
|
|
43
58
|
"CHANGELOG.md"
|
|
@@ -45,7 +60,7 @@
|
|
|
45
60
|
"peerDependencies": {
|
|
46
61
|
"tailwindcss": "^4.0.0"
|
|
47
62
|
},
|
|
48
|
-
"homepage": "https://
|
|
63
|
+
"homepage": "https://grailwind.dev/",
|
|
49
64
|
"repository": {
|
|
50
65
|
"type": "git",
|
|
51
66
|
"url": "git+https://github.com/pafirmin/grailwind.git"
|