grailwind 0.5.0 → 0.7.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 +47 -1
- package/README.md +8 -5
- package/charges.js +60 -1
- package/data.js +62 -0
- package/divisions.js +108 -16
- package/furs.js +1 -1
- package/grailwind.css +32 -0
- package/ordinaries.js +94 -12
- package/package.json +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.0] - 2026-11-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `per-chevron-*`: a field divided along a chevron's centre line, the first
|
|
14
|
+
tincture in chief.
|
|
15
|
+
- `bendy-*`, `bendy-sinister-*`, `chevronny-*` (each with `-of-eight-*`) and
|
|
16
|
+
`chequy-*`: six or eight pieces, or five squares by six.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `@grailwind/eslint-plugin` 0.1.0-beta.3 needs `grailwind` 0.7.x.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- On a shield, `quarterly` and two-part `per-fess` split at the fess point, as
|
|
25
|
+
the divisions do, not halfway down.
|
|
26
|
+
- No seam down the centre of `per-saltire-*` at odd widths.
|
|
27
|
+
|
|
28
|
+
## [0.6.0] - 2026-11-06
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Crosses and the shakefork as charges: `cross-couped`, `cross-potent`,
|
|
33
|
+
`cross-pattee`, `maltese-cross`, `cross-crosslet`, `cross-moline`,
|
|
34
|
+
`cross-fleury` and `shakefork`.
|
|
35
|
+
- `semy`: a semé, one built-in charge strewn across the field, e.g. bezanty or
|
|
36
|
+
crusilly.
|
|
37
|
+
- Voided ordinaries, e.g. `cross-voided-argent`: the field shows through.
|
|
38
|
+
`cross-voided-counterchanged` and the like counterchange the outline.
|
|
39
|
+
- Reversed ordinaries: `pile-reversed-*`, `chevron-reversed-*` and
|
|
40
|
+
`pall-reversed-*`, upside down.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- `@grailwind/eslint-plugin` 0.1.0-beta.2 needs `grailwind` 0.6.x, for voided
|
|
45
|
+
ordinaries.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Charges, a canton or an inescutcheon in a quarter (or a half of `per-pale` or
|
|
50
|
+
`per-fess`) are placed and counterchanged within it, under the shield's own
|
|
51
|
+
ordinary, rather than against the whole shield.
|
|
52
|
+
|
|
9
53
|
## [0.5.0] - 2026-11-04
|
|
10
54
|
|
|
11
55
|
### Added
|
|
@@ -113,7 +157,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
113
157
|
Initial release: tinctures, divisions, ordinaries, charges, strict mode and
|
|
114
158
|
custom SVG charges.
|
|
115
159
|
|
|
116
|
-
[Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.
|
|
160
|
+
[Unreleased]: https://github.com/pafirmin/grailwind/compare/v0.7.0...HEAD
|
|
161
|
+
[0.7.0]: https://github.com/pafirmin/grailwind/compare/v0.6.0...v0.7.0
|
|
162
|
+
[0.6.0]: https://github.com/pafirmin/grailwind/compare/v0.5.0...v0.6.0
|
|
117
163
|
[0.5.0]: https://github.com/pafirmin/grailwind/compare/v0.4.0...v0.5.0
|
|
118
164
|
[0.4.0]: https://github.com/pafirmin/grailwind/compare/v0.3.0...v0.4.0
|
|
119
165
|
[0.3.0]: https://github.com/pafirmin/grailwind/compare/v0.2.0...v0.3.0
|
package/README.md
CHANGED
|
@@ -57,17 +57,20 @@ tinctures replace Tailwind's colours entirely.
|
|
|
57
57
|
- **The shield:** `escutcheon`, plus `bordure-*`, `inescutcheon` (a small shield at the centre)
|
|
58
58
|
and `canton` (a ninth of the shield in dexter chief), each with its own field and charges.
|
|
59
59
|
- **Divided fields:** `per-pale-or-gules`, `quarterly-azure-argent`,
|
|
60
|
-
`gyronny-or-sable`, `barry-argent-azure
|
|
61
|
-
layout.
|
|
60
|
+
`gyronny-or-sable`, `barry-argent-azure`, `bendy-argent-gules`, `chequy-or-azure` and more, or
|
|
61
|
+
`per-pale`, `per-fess` and `quarterly` for layout.
|
|
62
62
|
- **Ordinaries:** `fess-or`, `chevron-gules`, `cross-argent`, `chief-azure`,
|
|
63
63
|
`two-bars-or` and more, with `fimbriated-*` for a narrow border,
|
|
64
64
|
`fess-engrailed-or` and the like for an engrailed or invected edge,
|
|
65
|
+
`cross-voided-argent` and the like to show the field through it,
|
|
66
|
+
`pile-reversed-or` (also the chevron and pall) to turn one upside down,
|
|
65
67
|
`chief-ermine` and the like in a fur, or `chevron-counterchanged` and the like
|
|
66
68
|
on a divided field.
|
|
67
|
-
- **Charges:** `mullet`, `fleur-de-lis`, `bezant
|
|
69
|
+
- **Charges:** `mullet`, `fleur-de-lis`, `bezant`, crosses and more, arranged by
|
|
68
70
|
number, `in-pale`, `in-fess`, `in-bend`, `in-bend-sinister`, `on` or `between`
|
|
69
|
-
the ordinary, or placed at a named point such as `in-dexter-chief`,
|
|
70
|
-
with `charge-size-*`, and `counterchanged` on
|
|
71
|
+
the ordinary, or placed at a named point such as `in-dexter-chief`, strewn
|
|
72
|
+
`semy` across the field, sized with `charge-size-*`, and `counterchanged` on
|
|
73
|
+
a divided field.
|
|
71
74
|
- **Your own charges:** any SVG, via `charge-[url(…)]`.
|
|
72
75
|
|
|
73
76
|
## Linting
|
package/charges.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import plugin from "tailwindcss/plugin";
|
|
5
5
|
import { POINTS, ROUNDELS } from "./data.js";
|
|
6
|
+
import { AT } from "./furs.js";
|
|
6
7
|
|
|
7
8
|
const star = (cx, cy, r, points = 5) => {
|
|
8
9
|
const coords = [];
|
|
@@ -14,6 +15,20 @@ const star = (cx, cy, r, points = 5) => {
|
|
|
14
15
|
return `<polygon points="${coords.join(" ")}"/>`;
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
// One arm drawn upward from the centre, repeated x times evenly about (50, 50).
|
|
19
|
+
const rotateX = (d, x) => {
|
|
20
|
+
const path = (d, deg) => `<path d="${d}" transform="rotate(${deg} 50 50)" />`;
|
|
21
|
+
|
|
22
|
+
const paths = [];
|
|
23
|
+
const rotation = 360 / x;
|
|
24
|
+
let curr = 0;
|
|
25
|
+
for (let i = 0; i < x; i++) {
|
|
26
|
+
paths.push(path(d, curr));
|
|
27
|
+
curr += rotation;
|
|
28
|
+
}
|
|
29
|
+
return paths.join("");
|
|
30
|
+
};
|
|
31
|
+
|
|
17
32
|
const SHAPES = {
|
|
18
33
|
roundel: `<circle cx="50" cy="50" r="46"/>`,
|
|
19
34
|
annulet: `<circle cx="50" cy="50" r="39" fill="none" stroke="#000" stroke-width="14"/>`,
|
|
@@ -29,6 +44,20 @@ const SHAPES = {
|
|
|
29
44
|
<path d="M45 65H55C55 74 53 82 50 90C47 82 45 74 45 65Z"/>
|
|
30
45
|
<path d="M58 65C62 74 72 80 84 78C80 88 64 90 56 74Z"/>
|
|
31
46
|
<path d="M42 65C38 74 28 80 16 78C20 88 36 90 44 74Z"/>`,
|
|
47
|
+
"cross-couped": rotateX("M39 50V2H61V50Z", 4),
|
|
48
|
+
"cross-potent": rotateX("M43 50V16H31V2H69V16H57V50Z", 4),
|
|
49
|
+
"cross-pattee": rotateX("M40 50V40Q36 20 22 2H78Q64 20 60 40V50Z", 4),
|
|
50
|
+
"maltese-cross": rotateX("M50 50L30 2L50 18L70 2Z", 4),
|
|
51
|
+
"cross-crosslet": rotateX("M43 50V25H32V13H43V2H57V13H68V25H57V50Z", 4),
|
|
52
|
+
"cross-moline": rotateX(
|
|
53
|
+
"M43 50V18C42 12 38 11 33 16C31 6 40 -1 50 7C60 -1 69 6 67 16C62 11 58 12 57 18V50Z",
|
|
54
|
+
4,
|
|
55
|
+
),
|
|
56
|
+
"cross-fleury": rotateX(
|
|
57
|
+
"M43 50V21C41 17 37 16 33 20C30 12 37 8 45 13C45 8 47 5 50 2C53 5 55 8 55 13C63 8 70 12 67 20C63 16 59 17 57 21V50Z",
|
|
58
|
+
4,
|
|
59
|
+
),
|
|
60
|
+
shakefork: `<polygon points="50,96 39,85 39,43 12.6,20.9 11.3,5.4 26.8,4 50,23.5 73.2,4 88.7,5.4 87.4,20.9 61,43 61,85"/>`,
|
|
32
61
|
};
|
|
33
62
|
|
|
34
63
|
// Points as [left%, top%] of the shield, plus a charge size as % of its width.
|
|
@@ -88,6 +117,19 @@ const svgUrl = (shape) =>
|
|
|
88
117
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shape}</svg>`,
|
|
89
118
|
)}")`;
|
|
90
119
|
|
|
120
|
+
// Semé: whole charges across the field, staggered on the furs' tile (see furs.js), cut at the
|
|
121
|
+
// edges. The shape is defined once and reused, so a shape with its own ids stays valid.
|
|
122
|
+
const SEMY_ACROSS = 4;
|
|
123
|
+
const SEMY_SCALE = 0.25;
|
|
124
|
+
const SEMY_SHIFT = `${-100 / (4 * (SEMY_ACROSS - 1))}%`;
|
|
125
|
+
const semyTile = (shape) => {
|
|
126
|
+
const at = (x, y) => `translate(${x - 50 * SEMY_SCALE} ${y - 50 * SEMY_SCALE})`;
|
|
127
|
+
const uses = AT.map(([x, y]) => `<use href="#s" transform="${at(x, y)} scale(${SEMY_SCALE})"/>`);
|
|
128
|
+
return `url("data:image/svg+xml,${encodeURIComponent(
|
|
129
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 60"><defs><g id="s">${shape}</g></defs>${uses.join("")}</svg>`,
|
|
130
|
+
)}")`;
|
|
131
|
+
};
|
|
132
|
+
|
|
91
133
|
const maskRules = (image) => ({
|
|
92
134
|
"mask-image": image,
|
|
93
135
|
"mask-size": "contain",
|
|
@@ -143,12 +185,13 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
143
185
|
const utilities = {};
|
|
144
186
|
|
|
145
187
|
for (const [name, shape] of Object.entries(SHAPES)) {
|
|
146
|
-
utilities[`.${name}`] = maskRules(svgUrl(shape));
|
|
188
|
+
utilities[`.${name}`] = { ...maskRules(svgUrl(shape)), "--semy-image": semyTile(shape) };
|
|
147
189
|
}
|
|
148
190
|
|
|
149
191
|
for (const [name, tincture] of Object.entries(ROUNDELS)) {
|
|
150
192
|
utilities[`.${name}`] = {
|
|
151
193
|
...maskRules(svgUrl(SHAPES.roundel)),
|
|
194
|
+
"--semy-image": semyTile(SHAPES.roundel),
|
|
152
195
|
"background-color": `var(--color-${tincture})`,
|
|
153
196
|
};
|
|
154
197
|
}
|
|
@@ -242,6 +285,22 @@ export default plugin(({ addUtilities, matchUtilities }) => {
|
|
|
242
285
|
utilities[`.in-${name}`] = { ".charges > &": atPoint(name) };
|
|
243
286
|
}
|
|
244
287
|
|
|
288
|
+
// The whole field, under the ordinaries and the chief. A custom charge has no tile, so it
|
|
289
|
+
// draws nothing: `none` would mean no mask, and flood the field.
|
|
290
|
+
utilities[".semy"] = {
|
|
291
|
+
"&.charges": { inset: "0", "z-index": "0" },
|
|
292
|
+
"&.charges > *": {
|
|
293
|
+
inset: "0",
|
|
294
|
+
width: "auto",
|
|
295
|
+
margin: "0",
|
|
296
|
+
"aspect-ratio": "auto",
|
|
297
|
+
"mask-image": "var(--semy-image, linear-gradient(transparent, transparent))",
|
|
298
|
+
"mask-size": `calc(100% / ${SEMY_ACROSS}) auto`,
|
|
299
|
+
"mask-repeat": "repeat",
|
|
300
|
+
"mask-position": `${SEMY_SHIFT} ${SEMY_SHIFT}`,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
245
304
|
// On a charge, or on `charges` for all of them.
|
|
246
305
|
utilities[".counterchanged"] = {
|
|
247
306
|
".charges > &": counterchanged,
|
package/data.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DIVISIONS = [
|
|
|
13
13
|
"per-fess",
|
|
14
14
|
"per-bend",
|
|
15
15
|
"per-bend-sinister",
|
|
16
|
+
"per-chevron",
|
|
16
17
|
"per-saltire",
|
|
17
18
|
"quarterly",
|
|
18
19
|
"gyronny",
|
|
@@ -21,8 +22,19 @@ export const DIVISIONS = [
|
|
|
21
22
|
"barry-of-ten",
|
|
22
23
|
"paly",
|
|
23
24
|
"paly-of-eight",
|
|
25
|
+
"bendy",
|
|
26
|
+
"bendy-of-eight",
|
|
27
|
+
"bendy-sinister",
|
|
28
|
+
"bendy-sinister-of-eight",
|
|
29
|
+
"chevronny",
|
|
30
|
+
"chevronny-of-eight",
|
|
31
|
+
"chequy",
|
|
24
32
|
];
|
|
25
33
|
|
|
34
|
+
// Ordinaries that can be reversed, e.g. `pile-reversed-or`: the same shape upside down.
|
|
35
|
+
export const REVERSIBLE = ["chevron", "pall", "pile"];
|
|
36
|
+
const reversed = (ordinary) => `${ordinary}-reversed`;
|
|
37
|
+
|
|
26
38
|
export const ORDINARIES = [
|
|
27
39
|
"pale",
|
|
28
40
|
"fess",
|
|
@@ -37,6 +49,7 @@ export const ORDINARIES = [
|
|
|
37
49
|
"pall",
|
|
38
50
|
"chief",
|
|
39
51
|
"pile",
|
|
52
|
+
...REVERSIBLE.map(reversed),
|
|
40
53
|
];
|
|
41
54
|
|
|
42
55
|
export const CHARGES = [
|
|
@@ -47,6 +60,14 @@ export const CHARGES = [
|
|
|
47
60
|
"billet",
|
|
48
61
|
"crescent",
|
|
49
62
|
"fleur-de-lis",
|
|
63
|
+
"cross-couped",
|
|
64
|
+
"cross-potent",
|
|
65
|
+
"cross-pattee",
|
|
66
|
+
"maltese-cross",
|
|
67
|
+
"cross-crosslet",
|
|
68
|
+
"cross-moline",
|
|
69
|
+
"cross-fleury",
|
|
70
|
+
"shakefork",
|
|
50
71
|
];
|
|
51
72
|
|
|
52
73
|
// Roundels have their own names by tincture.
|
|
@@ -61,6 +82,21 @@ export const ROUNDELS = {
|
|
|
61
82
|
// Lines of partition for an ordinary's edge, e.g. `fess-engrailed-or`.
|
|
62
83
|
export const LINES = ["engrailed", "invected"];
|
|
63
84
|
|
|
85
|
+
// Ordinaries that can be voided, e.g. `cross-voided-argent`.
|
|
86
|
+
export const VOIDABLE = [
|
|
87
|
+
"pale",
|
|
88
|
+
"fess",
|
|
89
|
+
"bar",
|
|
90
|
+
"bend",
|
|
91
|
+
"bend-sinister",
|
|
92
|
+
"chevron",
|
|
93
|
+
"cross",
|
|
94
|
+
"saltire",
|
|
95
|
+
"pall",
|
|
96
|
+
"pile",
|
|
97
|
+
...REVERSIBLE.map(reversed),
|
|
98
|
+
];
|
|
99
|
+
|
|
64
100
|
// Each fur's [ground, spot] tinctures.
|
|
65
101
|
export const FURS = {
|
|
66
102
|
ermine: ["argent", "sable"],
|
|
@@ -90,6 +126,7 @@ export const ARRANGEMENTS = [
|
|
|
90
126
|
"in-bend-sinister",
|
|
91
127
|
"in-chief",
|
|
92
128
|
"in-base",
|
|
129
|
+
"semy",
|
|
93
130
|
];
|
|
94
131
|
|
|
95
132
|
// Where charges go for "between", as [left%, top%], plus their size as % of width.
|
|
@@ -128,3 +165,28 @@ export const ON = {
|
|
|
128
165
|
pall: { size: 14, counts: { 1: [[50, 50]], 3: [[22, 22], [78, 22], [50, 75]] } },
|
|
129
166
|
pile: { size: 14, counts: { 1: [[50, 25]], 3: [[50, 14], [50, 34], [50, 54]] } },
|
|
130
167
|
};
|
|
168
|
+
|
|
169
|
+
const mirror = (points) => points.map(([x, y]) => [x, 100 - y]);
|
|
170
|
+
const mapValues = (object, fn) =>
|
|
171
|
+
Object.fromEntries(Object.entries(object).map(([key, value]) => [key, fn(value)]));
|
|
172
|
+
|
|
173
|
+
for (const ordinary of REVERSIBLE) {
|
|
174
|
+
const { size, points, counts = {} } = BETWEEN[ordinary];
|
|
175
|
+
BETWEEN[reversed(ordinary)] = {
|
|
176
|
+
size,
|
|
177
|
+
points: mirror(points),
|
|
178
|
+
counts: mapValues(counts, (count) => ({ ...count, points: mirror(count.points) })),
|
|
179
|
+
};
|
|
180
|
+
ON[reversed(ordinary)] = {
|
|
181
|
+
size: ON[ordinary].size,
|
|
182
|
+
counts: mapValues(ON[ordinary].counts, mirror),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Mirrored, the lower charges would clip on the heater's curved flanks.
|
|
187
|
+
// prettier-ignore
|
|
188
|
+
BETWEEN["chevron-reversed"] = {
|
|
189
|
+
...BETWEEN["chevron-reversed"],
|
|
190
|
+
points: [[28, 74], [72, 74], [50, 30]],
|
|
191
|
+
counts: { 6: { size: 12, points: [[26, 78], [50, 89], [74, 78], [38, 34], [62, 34], [50, 16]] } },
|
|
192
|
+
};
|
package/divisions.js
CHANGED
|
@@ -22,6 +22,57 @@ const stripes = (to, pieces) => (a, b) => {
|
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// How much of the corner-to-corner extent the shield covers: the base corner is cut away by the
|
|
26
|
+
// border-radius of 0 0 50% 50% / 0 0 45% 45% and the 5:6 aspect ratio in grailwind.css. It runs
|
|
27
|
+
// from the chief corner the bands start in to the tangent on the base curve.
|
|
28
|
+
const BENDY_VISIBLE = (() => {
|
|
29
|
+
const [width, height] = [5, 6];
|
|
30
|
+
const [radiusX, radiusY] = [width / 2, 0.45 * height];
|
|
31
|
+
const [centreX, centreY] = [width / 2, height - radiusY];
|
|
32
|
+
const tangent = Math.sqrt((height * radiusX) ** 2 + (width * radiusY) ** 2);
|
|
33
|
+
return (width * height - height * centreX + width * centreY + tangent) / (2 * width * height);
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
// Diagonal bands parallel to per bend, `pieces` equal ones across the visible part of the shield,
|
|
37
|
+
// the last running to the edge. Explicit stops, not a repeating gradient, so a canton's square
|
|
38
|
+
// corners don't show the first tincture again.
|
|
39
|
+
const bendy = (to, pieces) => (a, b) => {
|
|
40
|
+
const stops = [];
|
|
41
|
+
for (let i = 0; i < pieces; i++) {
|
|
42
|
+
const start = i === 0 ? 0 : ((i * BENDY_VISIBLE * 100) / pieces).toFixed(2);
|
|
43
|
+
const end = i === pieces - 1 ? 100 : (((i + 1) * BENDY_VISIBLE * 100) / pieces).toFixed(2);
|
|
44
|
+
stops.push(`${i % 2 === 0 ? a : b} ${start}% ${end}%`);
|
|
45
|
+
}
|
|
46
|
+
return layer(`linear-gradient(to ${to}, ${stops.join(", ")})`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The direction of `to bottom right` on the shield (aspect-ratio 5 / 6 in grailwind.css), so
|
|
50
|
+
// chevronny's lines run parallel to per chevron's, the field's diagonals.
|
|
51
|
+
const CHEVRON_ANGLE = 180 - (Math.atan(6 / 5) * 180) / Math.PI;
|
|
52
|
+
|
|
53
|
+
// A half box is 75% as long as the whole along this angle (45/sqrt(61) over 60/sqrt(61)).
|
|
54
|
+
// Plain, the sinister half sits over a full-width dexter layer, which hides the centre seam.
|
|
55
|
+
// With a fur the layers can't overlap, as the transparent stripes would show the layer below.
|
|
56
|
+
const chevronny = (pieces) => (a, b, fur) => {
|
|
57
|
+
const stripe = (angle, length) =>
|
|
58
|
+
`repeating-linear-gradient(${angle}deg, ${a} 0 calc(${length}% / ${pieces}), ${b} 0 calc(${2 * length}% / ${pieces}))`;
|
|
59
|
+
const sinister = stripe(-CHEVRON_ANGLE, 100);
|
|
60
|
+
|
|
61
|
+
return fur
|
|
62
|
+
? {
|
|
63
|
+
"background-image": [stripe(CHEVRON_ANGLE, 100), sinister].join(", "),
|
|
64
|
+
"background-size": "50% 100%",
|
|
65
|
+
"background-position": "left, right",
|
|
66
|
+
"background-repeat": "no-repeat",
|
|
67
|
+
}
|
|
68
|
+
: {
|
|
69
|
+
"background-image": [sinister, stripe(CHEVRON_ANGLE, 75)].join(", "),
|
|
70
|
+
"background-size": "50% 100%, 100% 100%",
|
|
71
|
+
"background-position": "right, left",
|
|
72
|
+
"background-repeat": "no-repeat",
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
25
76
|
const DIVISIONS = {
|
|
26
77
|
// Vertical split: first on the dexter (viewer's left).
|
|
27
78
|
"per-pale": (a, b) => layer(`linear-gradient(to right, ${a} 50%, ${b} 50%)`),
|
|
@@ -36,18 +87,39 @@ const DIVISIONS = {
|
|
|
36
87
|
// Line from sinister chief to dexter base: first in the upper half.
|
|
37
88
|
"per-bend-sinister": (a, b) => layer(`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`),
|
|
38
89
|
|
|
90
|
+
"per-chevron": (a, b) =>
|
|
91
|
+
a === "transparent"
|
|
92
|
+
? {
|
|
93
|
+
"background-image": [
|
|
94
|
+
`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
|
|
95
|
+
`linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
|
|
96
|
+
`linear-gradient(${b}, ${b})`,
|
|
97
|
+
].join(", "),
|
|
98
|
+
"background-size": "50% 50%, 50% 50%, 100% 15%",
|
|
99
|
+
"background-position": "left 70%, right 70%, bottom",
|
|
100
|
+
"background-repeat": "no-repeat",
|
|
101
|
+
}
|
|
102
|
+
: {
|
|
103
|
+
"background-image": [
|
|
104
|
+
`linear-gradient(to bottom right, ${a} 42.5%, transparent 42.5%)`,
|
|
105
|
+
`linear-gradient(to bottom left, ${a} 42.5%, ${b} 42.5%)`,
|
|
106
|
+
].join(", "),
|
|
107
|
+
},
|
|
108
|
+
|
|
39
109
|
// Both diagonals, crossing at the centre like the saltire: first in chief and base.
|
|
40
|
-
// Gyronny's four tiles, with the top-right and bottom-left swapped.
|
|
110
|
+
// Gyronny's four tiles, with the top-right and bottom-left swapped. Two strips under them fill
|
|
111
|
+
// the sub-pixel seam where the tiles meet: the first tincture down, the second across.
|
|
41
112
|
"per-saltire": (a, b) => ({
|
|
42
|
-
"background-color": b,
|
|
43
113
|
"background-image": [
|
|
44
114
|
`linear-gradient(to bottom left, ${a} 50%, ${b} 50%)`,
|
|
45
115
|
`linear-gradient(to bottom right, ${a} 50%, ${b} 50%)`,
|
|
46
116
|
`linear-gradient(to bottom left, ${b} 50%, ${a} 50%)`,
|
|
47
117
|
`linear-gradient(to bottom right, ${b} 50%, ${a} 50%)`,
|
|
118
|
+
`linear-gradient(${a}, ${a})`,
|
|
119
|
+
`linear-gradient(${b}, ${b})`,
|
|
48
120
|
].join(", "),
|
|
49
|
-
"background-size": "50% 50%",
|
|
50
|
-
"background-position": "left top, right top, right bottom, left bottom",
|
|
121
|
+
"background-size": "50% 50%, 50% 50%, 50% 50%, 50% 50%, 2px 100%, 100% 2px",
|
|
122
|
+
"background-position": "left top, right top, right bottom, left bottom, center, center",
|
|
51
123
|
"background-repeat": "no-repeat",
|
|
52
124
|
}),
|
|
53
125
|
|
|
@@ -83,22 +155,42 @@ const DIVISIONS = {
|
|
|
83
155
|
"barry-of-ten": stripes("bottom", 10),
|
|
84
156
|
paly: stripes("right", 6),
|
|
85
157
|
"paly-of-eight": stripes("right", 8),
|
|
158
|
+
|
|
159
|
+
// Bendy has the first tincture in the sinister chief corner, bendy sinister in the dexter.
|
|
160
|
+
bendy: bendy("bottom left", 6),
|
|
161
|
+
"bendy-of-eight": bendy("bottom left", 8),
|
|
162
|
+
"bendy-sinister": bendy("bottom right", 6),
|
|
163
|
+
"bendy-sinister-of-eight": bendy("bottom right", 8),
|
|
164
|
+
|
|
165
|
+
// The first tincture in the chief corners.
|
|
166
|
+
chevronny: chevronny(6),
|
|
167
|
+
"chevronny-of-eight": chevronny(8),
|
|
168
|
+
|
|
169
|
+
// 5 across and 6 down, the first tincture in the dexter chief square. Conic starts at the top.
|
|
170
|
+
// The repeat must be explicit, as with stripes.
|
|
171
|
+
chequy: (a, b) => ({
|
|
172
|
+
"background-image": `conic-gradient(${b} 0 25%, ${a} 0 50%, ${b} 0 75%, ${a} 0)`,
|
|
173
|
+
"background-size": "40% calc(100% / 3)",
|
|
174
|
+
"background-repeat": "repeat",
|
|
175
|
+
}),
|
|
86
176
|
};
|
|
87
177
|
|
|
88
178
|
// A division's background rules as one { image, size, position, repeat } per layer.
|
|
89
179
|
const toLayers = (rules) => {
|
|
90
|
-
return rules["background-image"]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
180
|
+
return rules["background-image"]
|
|
181
|
+
.split(/, (?=(?:repeating-)?(?:linear|conic)-gradient)/)
|
|
182
|
+
.map((gradient, i) => {
|
|
183
|
+
const size = (rules["background-size"] ?? "100% 100%").split(", ");
|
|
184
|
+
const position = (rules["background-position"] ?? "0 0").split(", ");
|
|
185
|
+
const repeat = (rules["background-repeat"] ?? "no-repeat").split(", ");
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
image: gradient,
|
|
189
|
+
size: size[i % size.length],
|
|
190
|
+
position: position[i % position.length],
|
|
191
|
+
repeat: repeat[i % repeat.length],
|
|
192
|
+
};
|
|
193
|
+
});
|
|
102
194
|
};
|
|
103
195
|
|
|
104
196
|
// A division with one fur: the fur's part is left transparent, over the fur's own layers.
|
package/furs.js
CHANGED
|
@@ -11,7 +11,7 @@ const SPOT = [
|
|
|
11
11
|
|
|
12
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
13
|
// corner that join up with the neighbouring tiles.
|
|
14
|
-
const AT = [
|
|
14
|
+
export const AT = [
|
|
15
15
|
[25, 30],
|
|
16
16
|
[0, 0],
|
|
17
17
|
[50, 0],
|
package/grailwind.css
CHANGED
|
@@ -120,6 +120,13 @@
|
|
|
120
120
|
display: grid;
|
|
121
121
|
grid-auto-flow: column;
|
|
122
122
|
grid-auto-columns: 1fr;
|
|
123
|
+
|
|
124
|
+
/* On a shield, each part is a coat of its own: what's in it is placed, stacked and
|
|
125
|
+
* counterchanged within it. */
|
|
126
|
+
&:is(.escutcheon, .escutcheon *) > * {
|
|
127
|
+
position: relative;
|
|
128
|
+
contain: layout;
|
|
129
|
+
}
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/* Per fess: split horizontally into chief / base. */
|
|
@@ -127,6 +134,20 @@
|
|
|
127
134
|
display: grid;
|
|
128
135
|
grid-auto-flow: row;
|
|
129
136
|
grid-auto-rows: 1fr;
|
|
137
|
+
|
|
138
|
+
/* On a shield, two parts split at the fess point, FESS_POINT in divisions.js.
|
|
139
|
+
* More parts stay equal. */
|
|
140
|
+
&:is(.escutcheon, .escutcheon *) {
|
|
141
|
+
&:has(> :last-child:nth-child(2)) {
|
|
142
|
+
grid-template-rows: 45fr 55fr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* See per-pale. */
|
|
146
|
+
& > * {
|
|
147
|
+
position: relative;
|
|
148
|
+
contain: layout;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
130
151
|
}
|
|
131
152
|
|
|
132
153
|
/* Quarterly: four quarters, numbered 1–4 across then down.
|
|
@@ -141,6 +162,17 @@
|
|
|
141
162
|
--the-first: var(--field-first);
|
|
142
163
|
--outer-inset: 0px;
|
|
143
164
|
}
|
|
165
|
+
|
|
166
|
+
/* On a shield, split at the fess point, as per-fess. */
|
|
167
|
+
&:is(.escutcheon, .escutcheon *) {
|
|
168
|
+
grid-template-rows: 45fr 55fr;
|
|
169
|
+
|
|
170
|
+
/* See per-pale. */
|
|
171
|
+
& > * {
|
|
172
|
+
position: relative;
|
|
173
|
+
contain: layout;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
144
176
|
}
|
|
145
177
|
|
|
146
178
|
/* ---------------------------------------------------------------------------
|
package/ordinaries.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Grailwind CSS: ordinaries, e.g. `fess-or`.
|
|
3
3
|
*/
|
|
4
4
|
import plugin from "tailwindcss/plugin";
|
|
5
|
-
import { BETWEEN, FURS, OF_THE_FIRST, ON, TINCTURES } from "./data.js";
|
|
5
|
+
import { BETWEEN, FURS, OF_THE_FIRST, ON, REVERSIBLE, TINCTURES, VOIDABLE } from "./data.js";
|
|
6
6
|
import { furBackground } from "./furs.js";
|
|
7
7
|
|
|
8
8
|
// prettier-ignore
|
|
@@ -34,6 +34,10 @@ const ORDINARIES = {
|
|
|
34
34
|
chief: [[0, 0], [100, 0], [100, 30], [0, 30]],
|
|
35
35
|
pile: [[20, 0], [80, 0], [50, 85]],
|
|
36
36
|
};
|
|
37
|
+
// signedArea() follows the flip's winding, so the order stays.
|
|
38
|
+
for (const ordinary of REVERSIBLE) {
|
|
39
|
+
ORDINARIES[`${ordinary}-reversed`] = ORDINARIES[ordinary].map(([x, y]) => [x, 100 - y]);
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
const num = (v) => +v.toFixed(3);
|
|
39
43
|
const pct = (v) => (num(v) === 0 ? "0" : `${num(v)}%`);
|
|
@@ -59,6 +63,7 @@ const outer = ([x, y]) => {
|
|
|
59
63
|
// A y in % of the field, as % of the whole shield when a chief takes the top of it.
|
|
60
64
|
const belowChief = (y) =>
|
|
61
65
|
y === 100 ? "100%" : `calc(${num(y)}% + var(--field-top, 0%) * ${num(1 - y / 100)})`;
|
|
66
|
+
const pointBelowChief = ([x, y]) => `${pct(x)} ${belowChief(y)}`;
|
|
62
67
|
|
|
63
68
|
// Fimbriation: the ordinary's clip is widened and filled with the fimbriation
|
|
64
69
|
// tincture, and the ordinary's own tincture is painted over it as gradient
|
|
@@ -86,15 +91,15 @@ const offsetLine = (a, b, outward, distance) => {
|
|
|
86
91
|
return { n, c: n[0] * ax + n[1] * ay + distance };
|
|
87
92
|
};
|
|
88
93
|
|
|
89
|
-
// How far each vertex moves, in %, when
|
|
90
|
-
// don't move, so their vertices slide along it.
|
|
91
|
-
const widen = (vertices) => {
|
|
94
|
+
// How far each vertex moves, in %, when the sides move out by `distance` (in by a negative one).
|
|
95
|
+
// Sides on the shield's edge don't move, so their vertices slide along it.
|
|
96
|
+
const widen = (vertices, distance = FIMBRIATION) => {
|
|
92
97
|
const outward = Math.sign(signedArea(vertices));
|
|
93
98
|
const count = vertices.length;
|
|
94
99
|
const line = (i) => {
|
|
95
100
|
const a = vertices[i];
|
|
96
101
|
const b = vertices[(i + 1) % count];
|
|
97
|
-
return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 :
|
|
102
|
+
return offsetLine(a, b, outward, onShieldEdge(a, b) ? 0 : distance);
|
|
98
103
|
};
|
|
99
104
|
return vertices.map((v, i) => {
|
|
100
105
|
const p = line((i - 1 + count) % count);
|
|
@@ -106,6 +111,23 @@ const widen = (vertices) => {
|
|
|
106
111
|
});
|
|
107
112
|
};
|
|
108
113
|
|
|
114
|
+
const rotate = (list, start) => [...list.slice(start), ...list.slice(0, start)];
|
|
115
|
+
|
|
116
|
+
// The ordinary with a hole the shape of it shrunk by `distance`, cut with evenodd.
|
|
117
|
+
const voidedPolygon = (vertices, distance, format = point) => {
|
|
118
|
+
const offsets = widen(vertices, -distance);
|
|
119
|
+
const inner = vertices.map(([x, y], i) => [x + offsets[i][0], y + offsets[i][1]]);
|
|
120
|
+
// Start and end at the shield's edge so we don't draw through it.
|
|
121
|
+
const edge = vertices.findIndex((v, i) => onShieldEdge(v, vertices[(i + 1) % vertices.length]));
|
|
122
|
+
if (edge === -1) {
|
|
123
|
+
throw new Error("a voided ordinary needs a side on the shield's edge");
|
|
124
|
+
}
|
|
125
|
+
const outerStr = [...rotate(vertices, edge), vertices[edge]].map(format).join(", ");
|
|
126
|
+
const innerStr = [...rotate(inner, edge), inner[edge]].map(format).join(", ");
|
|
127
|
+
|
|
128
|
+
return `polygon(evenodd, ${outerStr}, ${innerStr})`;
|
|
129
|
+
};
|
|
130
|
+
|
|
109
131
|
const shift = (v, d) =>
|
|
110
132
|
num(d) === 0
|
|
111
133
|
? pct(v)
|
|
@@ -162,6 +184,28 @@ const BANDS = {
|
|
|
162
184
|
pile: [half("u-v", "end", [20, 0, 50, 85]), half("u+v", "start", [50, 0, 80, 85]), seam(83)],
|
|
163
185
|
};
|
|
164
186
|
|
|
187
|
+
// The bands upside down. A diagonal swaps kind, as x − y becomes x + (100 − y) − 100. A half's
|
|
188
|
+
// stops stay: its 50% line is the flipped tile's other diagonal, which is the flipped side.
|
|
189
|
+
const reverseBand = ({ kind, from, to, tile, ...rest }) => {
|
|
190
|
+
const layer = { ...rest, tile: tile && [tile[0], 100 - tile[3], tile[2], 100 - tile[1]] };
|
|
191
|
+
if (from === undefined) {
|
|
192
|
+
return { ...layer, kind: { "u-v": "u+v", "u+v": "u-v" }[kind] ?? kind };
|
|
193
|
+
}
|
|
194
|
+
switch (kind) {
|
|
195
|
+
case "x":
|
|
196
|
+
return { ...layer, kind, from, to };
|
|
197
|
+
case "y":
|
|
198
|
+
return { ...layer, kind, from: 100 - to, to: 100 - from };
|
|
199
|
+
case "u-v":
|
|
200
|
+
return { ...layer, kind: "u+v", from: from + 100, to: to + 100 };
|
|
201
|
+
case "u+v":
|
|
202
|
+
return { ...layer, kind: "u-v", from: from - 100, to: to - 100 };
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
for (const ordinary of REVERSIBLE) {
|
|
206
|
+
BANDS[`${ordinary}-reversed`] = BANDS[ordinary].map(reverseBand);
|
|
207
|
+
}
|
|
208
|
+
|
|
165
209
|
const DIRECTION = {
|
|
166
210
|
x: "to right",
|
|
167
211
|
y: "to bottom",
|
|
@@ -223,6 +267,19 @@ const bands = (layers, colour) => ({
|
|
|
223
267
|
// box is shorter, so they are slightly squashed.
|
|
224
268
|
const SCALLOP = 14;
|
|
225
269
|
const SCALLOPS = { chevron: 10 };
|
|
270
|
+
|
|
271
|
+
// A quarter of the ordinary's width, in real units. The pile tapers, so its is set by eye.
|
|
272
|
+
// prettier-ignore
|
|
273
|
+
const VOIDS = {
|
|
274
|
+
pale: 7.5, fess: 9, bar: 4.2, bend: 7.7, "bend-sinister": 7.7,
|
|
275
|
+
chevron: 3.8, cross: 5, saltire: 5.8, pall: 5, pile: 8,
|
|
276
|
+
};
|
|
277
|
+
for (const ordinary of REVERSIBLE) {
|
|
278
|
+
if (SCALLOPS[ordinary]) {
|
|
279
|
+
SCALLOPS[`${ordinary}-reversed`] = SCALLOPS[ordinary];
|
|
280
|
+
}
|
|
281
|
+
VOIDS[`${ordinary}-reversed`] = VOIDS[ordinary];
|
|
282
|
+
}
|
|
226
283
|
const DEPTH = 0.35;
|
|
227
284
|
|
|
228
285
|
const at = ([x, y]) => point([(x * 100) / W, (y * 100) / H]);
|
|
@@ -376,10 +433,11 @@ const PAINTS = [
|
|
|
376
433
|
];
|
|
377
434
|
|
|
378
435
|
export default plugin(({ addBase, addUtilities, theme }) => {
|
|
379
|
-
// Unset on an inescutcheon or
|
|
380
|
-
// layer, so an ordinary on it still sets its own.
|
|
436
|
+
// Unset on an inescutcheon, a canton or a part of a divided shield, so it can't inherit its
|
|
437
|
+
// shield's layout. In the base layer, so an ordinary on it still sets its own.
|
|
381
438
|
addBase({
|
|
382
|
-
".inescutcheon, .canton
|
|
439
|
+
".inescutcheon, .canton, :is(.escutcheon, .escutcheon *):is(.quarterly, .per-pale, .per-fess) > *":
|
|
440
|
+
Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
|
|
383
441
|
});
|
|
384
442
|
|
|
385
443
|
const utilities = {};
|
|
@@ -429,7 +487,7 @@ export default plugin(({ addBase, addUtilities, theme }) => {
|
|
|
429
487
|
|
|
430
488
|
// Covers the whole shield, so the swapped field lines up with the real one,
|
|
431
489
|
// and the clip moves the ordinary below any chief instead.
|
|
432
|
-
|
|
490
|
+
const counterchanged = {
|
|
433
491
|
...(chief ? OVER_BORDURE : { inset: "0" }),
|
|
434
492
|
// Registered not to inherit, so a shield inside another can't pick up its swap.
|
|
435
493
|
...Object.fromEntries(
|
|
@@ -445,9 +503,10 @@ export default plugin(({ addBase, addUtilities, theme }) => {
|
|
|
445
503
|
"background-repeat": "var(--counterchange-repeat, repeat)",
|
|
446
504
|
// The swapped field's tiles would wrap into the bordure's strips, so the bordure shows there.
|
|
447
505
|
...(chief && { "background-clip": "content-box" }),
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
506
|
+
};
|
|
507
|
+
utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
|
|
508
|
+
...counterchanged,
|
|
509
|
+
"clip-path": chief ? plain : `polygon(${vertices.map(pointBelowChief).join(", ")})`,
|
|
451
510
|
});
|
|
452
511
|
|
|
453
512
|
for (const [tincture, colour] of PAINTS) {
|
|
@@ -490,6 +549,29 @@ export default plugin(({ addBase, addUtilities, theme }) => {
|
|
|
490
549
|
});
|
|
491
550
|
}
|
|
492
551
|
}
|
|
552
|
+
|
|
553
|
+
// Not fimbriated.
|
|
554
|
+
if (VOIDABLE.includes(ordinary)) {
|
|
555
|
+
const clip = voidedPolygon(vertices, VOIDS[ordinary]);
|
|
556
|
+
for (const [tincture, colour] of PAINTS) {
|
|
557
|
+
utilities[`.${ordinary}-voided-${tincture}`] = ordinaryRules(ordinary, {
|
|
558
|
+
...placed,
|
|
559
|
+
"background-color": colour,
|
|
560
|
+
"clip-path": clip,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
for (const fur of Object.keys(FURS)) {
|
|
564
|
+
utilities[`.${ordinary}-voided-${fur}`] = ordinaryRules(ordinary, {
|
|
565
|
+
...placed,
|
|
566
|
+
...furBackground(fur, theme),
|
|
567
|
+
"clip-path": clip,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
utilities[`.${ordinary}-voided-counterchanged`] = ordinaryRules(ordinary, {
|
|
571
|
+
...counterchanged,
|
|
572
|
+
"clip-path": voidedPolygon(vertices, VOIDS[ordinary], pointBelowChief),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
493
575
|
}
|
|
494
576
|
|
|
495
577
|
addUtilities(utilities);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grailwind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"dev:watch": "tailwindcss -i docs/app.css -o docs/site.css --watch=always & python3 -m http.server ${PORT:-8000} -d docs",
|
|
10
10
|
"lint": "eslint .",
|
|
11
11
|
"format": "prettier --write .",
|
|
12
|
-
"format:check": "prettier --check ."
|
|
12
|
+
"format:check": "prettier --check .",
|
|
13
|
+
"prepare": "husky"
|
|
13
14
|
},
|
|
14
15
|
"keywords": [
|
|
15
16
|
"tailwindcss",
|
|
@@ -26,10 +27,12 @@
|
|
|
26
27
|
"license": "MIT",
|
|
27
28
|
"description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@grailwind/eslint-plugin": "^0.1.0-beta.
|
|
30
|
+
"@grailwind/eslint-plugin": "^0.1.0-beta.3",
|
|
30
31
|
"@html-eslint/parser": "^0.67.0",
|
|
31
32
|
"@tailwindcss/cli": "^4.3.3",
|
|
32
33
|
"eslint": "^10.14.0",
|
|
34
|
+
"husky": "^9.1.7",
|
|
35
|
+
"lint-staged": "^17.6.0",
|
|
33
36
|
"playwright": "~1.64.1",
|
|
34
37
|
"prettier": "^3.9.10",
|
|
35
38
|
"tailwindcss": "^4.3.3"
|
|
@@ -71,5 +74,8 @@
|
|
|
71
74
|
},
|
|
72
75
|
"bugs": {
|
|
73
76
|
"url": "https://github.com/pafirmin/grailwind/issues"
|
|
77
|
+
},
|
|
78
|
+
"lint-staged": {
|
|
79
|
+
"*": "prettier --write --ignore-unknown"
|
|
74
80
|
}
|
|
75
81
|
}
|