grailwind 0.8.0 → 0.9.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/ordinaries.js CHANGED
@@ -5,6 +5,9 @@ import plugin from "tailwindcss/plugin";
5
5
  import {
6
6
  BETWEEN,
7
7
  BETWEEN_LINED,
8
+ BORDURE_LINES,
9
+ CHARGES,
10
+ FRAMES,
8
11
  FURS,
9
12
  LINES as LINE_NAMES,
10
13
  lineOrdinaries,
@@ -15,7 +18,7 @@ import {
15
18
  TINCTURES,
16
19
  VOIDABLE,
17
20
  } from "./data.js";
18
- import { furBackground } from "./furs.js";
21
+ import { furBackground, ringBackground } from "./furs.js";
19
22
  import { PATHS } from "./paths.js";
20
23
 
21
24
  // prettier-ignore
@@ -61,17 +64,25 @@ const polygon = (vertices) => `polygon(${vertices.map(point).join(", ")})`;
61
64
 
62
65
  const PSEUDO = { chief: "&::after" };
63
66
 
67
+ // A split field's parts: its children that aren't over all of it. Written out in grailwind.css.
68
+ const PART = ":not(.charges, .inescutcheon, .canton)";
69
+
64
70
  // The chief surmounts a bordure.
65
71
  const INSET = "var(--bordure-inset)";
72
+ // Each side's step-out. A bordure on a per-pale part sets its palar side's to 0.
73
+ const DEXTER = `var(--bordure-inset-dexter, ${INSET})`;
74
+ const SINISTER = `var(--bordure-inset-sinister, ${INSET})`;
66
75
  const OVER_BORDURE = {
67
76
  "--bordure-inset": "inherit",
68
- inset: `calc(-1 * ${INSET}) calc(-1 * ${INSET}) 0`,
69
- padding: `${INSET} ${INSET} 0`,
77
+ "--bordure-inset-dexter": "inherit",
78
+ "--bordure-inset-sinister": "inherit",
79
+ inset: `calc(-1 * ${INSET}) calc(-1 * ${SINISTER}) 0 calc(-1 * ${DEXTER})`,
80
+ padding: `${INSET} ${SINISTER} 0 ${DEXTER}`,
70
81
  "background-origin": "content-box",
71
82
  };
72
83
  // A vertex on the shield's top or side edges, moved out to the bordure's outer edge.
73
84
  const outer = ([x, y]) => {
74
- const ox = x === 0 ? `calc(-1 * ${INSET})` : x === 100 ? `calc(100% + ${INSET})` : pct(x);
85
+ const ox = x === 0 ? `calc(-1 * ${DEXTER})` : x === 100 ? `calc(100% + ${SINISTER})` : pct(x);
75
86
  return `${ox} ${y === 0 ? `calc(-1 * ${INSET})` : pct(y)}`;
76
87
  };
77
88
 
@@ -603,6 +614,303 @@ const pathShape = (segments) => {
603
614
  return `shape(from ${at([startX, startY])}, ${commands.join(", ")}, close)`;
604
615
  };
605
616
 
617
+ // Polygon points per curve or arc, where shape() isn't supported.
618
+ const SAMPLES = 16;
619
+
620
+ /**
621
+ * A fraction of the frame across or down, in a box inset by `inset` on every side.
622
+ * @param {number} fraction 0 at the frame's top or left edge, 1 at its bottom or right.
623
+ * @param {string|null} inset A CSS length, or null for the frame's own edge.
624
+ * @returns {string} A CSS length-percentage.
625
+ */
626
+ const insetCoordinate = (fraction, inset) => {
627
+ if (!inset) {
628
+ return pct(fraction * 100);
629
+ }
630
+ if (num(fraction) === 0) {
631
+ return inset;
632
+ }
633
+ if (num(fraction) === 1) {
634
+ return `calc(100% - ${inset})`;
635
+ }
636
+ return `calc(${inset} + ${num(fraction)} * (100% - 2 * ${inset}))`;
637
+ };
638
+
639
+ /**
640
+ * A drawing's point, in real units, in the frame inset by `inset`.
641
+ * @param {number[]} point [x, y] in real units (W × H).
642
+ * @param {string|null} inset A CSS length, or null for the frame's own edge.
643
+ * @returns {string} A CSS position.
644
+ */
645
+ const insetPoint = ([x, y], inset) =>
646
+ `${insetCoordinate(x / W, inset)} ${insetCoordinate(y / H, inset)}`;
647
+
648
+ /**
649
+ * An arc's radius, scaled with the inset box as its points are.
650
+ * @param {number} radius In real units.
651
+ * @param {number} across W for a horizontal radius, H for a vertical one.
652
+ * @param {string|null} inset A CSS length, or null for the frame's own edge.
653
+ * @returns {string} A CSS length-percentage.
654
+ */
655
+ const insetRadius = (radius, across, inset) =>
656
+ inset ? `calc(${num(radius / across)} * (100% - 2 * ${inset}))` : pct((radius * 100) / across);
657
+
658
+ /**
659
+ * A drawn outline's shape() commands after its start, in the frame inset by `inset`.
660
+ * @param {Array[]} segments The drawing's segments, as in paths.js, ending in ["Z"].
661
+ * @param {string|null} inset A CSS length, or null for the frame's own edge.
662
+ * @returns {string[]} The commands, ending in "close".
663
+ */
664
+ const insetCommands = (segments, inset) =>
665
+ segments.slice(1).map(([command, ...n]) => {
666
+ switch (command) {
667
+ case "L":
668
+ return `line to ${insetPoint([n[0], n[1]], inset)}`;
669
+ case "C":
670
+ return `curve to ${insetPoint([n[4], n[5]], inset)} with ${insetPoint([n[0], n[1]], inset)} / ${insetPoint([n[2], n[3]], inset)}`;
671
+ case "A": {
672
+ const [rx, ry, rotation, large, sweep, x, y] = n;
673
+ return [
674
+ `arc to ${insetPoint([x, y], inset)} of ${insetRadius(rx, W, inset)} ${insetRadius(ry, H, inset)}`,
675
+ sweep ? "cw" : "ccw",
676
+ large ? "large" : "",
677
+ rotation ? `rotate ${rotation}deg` : "",
678
+ ]
679
+ .filter(Boolean)
680
+ .join(" ");
681
+ }
682
+ case "Z":
683
+ return "close";
684
+ default:
685
+ throw new Error(`a frame's outline can't use ${command}`);
686
+ }
687
+ });
688
+
689
+ /**
690
+ * Points along an SVG arc, after its start, from its endpoint form (SVG 1.1, F.6.5).
691
+ * @param {number[]} from The arc's start, in real units.
692
+ * @param {number[]} arc [rx, ry, rotation, large, sweep, x, y], as in paths.js.
693
+ * @returns {number[][]} SAMPLES points, the last at the arc's end.
694
+ */
695
+ const arcPoints = (from, [rx, ry, rotation, large, sweep, x, y]) => {
696
+ const angle = (rotation * Math.PI) / 180;
697
+ const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
698
+ const [dx, dy] = [(from[0] - x) / 2, (from[1] - y) / 2];
699
+ const [x1, y1] = [cos * dx + sin * dy, -sin * dx + cos * dy];
700
+ // Radii too small to reach are scaled up, as SVG does.
701
+ const scale = Math.max(1, Math.sqrt((x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry)));
702
+ const [radiusX, radiusY] = [rx * scale, ry * scale];
703
+ const numerator = Math.max(
704
+ 0,
705
+ radiusX ** 2 * radiusY ** 2 - radiusX ** 2 * y1 ** 2 - radiusY ** 2 * x1 ** 2,
706
+ );
707
+ const sign = large === sweep ? -1 : 1;
708
+ const factor =
709
+ sign * Math.sqrt(numerator / (radiusX ** 2 * y1 ** 2 + radiusY ** 2 * x1 ** 2) || 0);
710
+ const [cx1, cy1] = [(factor * radiusX * y1) / radiusY, (-factor * radiusY * x1) / radiusX];
711
+ const centre = [
712
+ cos * cx1 - sin * cy1 + (from[0] + x) / 2,
713
+ sin * cx1 + cos * cy1 + (from[1] + y) / 2,
714
+ ];
715
+ const start = Math.atan2((y1 - cy1) / radiusY, (x1 - cx1) / radiusX);
716
+ let sweepAngle = Math.atan2((-y1 - cy1) / radiusY, (-x1 - cx1) / radiusX) - start;
717
+ if (sweep && sweepAngle < 0) {
718
+ sweepAngle += 2 * Math.PI;
719
+ } else if (!sweep && sweepAngle > 0) {
720
+ sweepAngle -= 2 * Math.PI;
721
+ }
722
+ return Array.from({ length: SAMPLES }, (_, i) => {
723
+ const theta = start + (sweepAngle * (i + 1)) / SAMPLES;
724
+ const [ex, ey] = [radiusX * Math.cos(theta), radiusY * Math.sin(theta)];
725
+ return [centre[0] + cos * ex - sin * ey, centre[1] + sin * ex + cos * ey];
726
+ });
727
+ };
728
+
729
+ /**
730
+ * A drawn outline as polygon points, its curves and arcs sampled.
731
+ * @param {Array[]} segments The drawing's segments, as in paths.js.
732
+ * @param {number} [samples] Points per curve.
733
+ * @returns {number[][]} Points in real units, starting at the move.
734
+ */
735
+ const outlinePoints = (segments, samples = SAMPLES) => {
736
+ const points = [[segments[0][1], segments[0][2]]];
737
+ for (const [command, ...n] of segments.slice(1)) {
738
+ const from = points.at(-1);
739
+ if (command === "L") {
740
+ points.push([n[0], n[1]]);
741
+ } else if (command === "C") {
742
+ for (let i = 1; i <= samples; i++) {
743
+ const t = i / samples;
744
+ const [a, b, c, d] = [(1 - t) ** 3, 3 * (1 - t) ** 2 * t, 3 * (1 - t) * t ** 2, t ** 3];
745
+ points.push([
746
+ a * from[0] + b * n[0] + c * n[2] + d * n[4],
747
+ a * from[1] + b * n[1] + c * n[3] + d * n[5],
748
+ ]);
749
+ }
750
+ } else if (command === "A") {
751
+ points.push(...arcPoints(from, n));
752
+ }
753
+ }
754
+ return points;
755
+ };
756
+
757
+ // A bordure's width, as a fraction of its frame's. grailwind.css has it as `calc(100cqw / 8)` on a
758
+ // frame and a third of that on a canton, which test/modes.mjs checks. A lined bordure is drawn for
759
+ // this width.
760
+ const BORDURE_WIDTH = 1 / 8;
761
+ // How deep a lined bordure's scallops are, as a fraction of its frame's width.
762
+ const SCALLOP_DEPTH = 0.037;
763
+ // The same as a fraction of the bordure's width: an engrailed field's border narrows by it.
764
+ const BORDURE_DEPTH = SCALLOP_DEPTH / BORDURE_WIDTH;
765
+ // A lined bordure's scallop length in real units, as SCALLOP is an ordinary's: a run between
766
+ // corners gets one scallop per this much of its length inside the bordure.
767
+ const BORDURE_SCALLOP = 12;
768
+ // An engrailed bordure's top corners are round lobes, their radius this many times the scallops'
769
+ // depth (at least 1), centred inside the corner so they reach the depth past each edge, as the
770
+ // bites do. Every other corner is a cusp.
771
+ const CORNER_LOBE = 1.8;
772
+ // Points per curve when a run is cut into scallops by arc length.
773
+ const RUN_SAMPLES = 256;
774
+
775
+ /**
776
+ * @param {number[]} vector A vector.
777
+ * @returns {number[]} The unit vector the same way.
778
+ */
779
+ const unit = ([x, y]) => [x / Math.hypot(x, y), y / Math.hypot(x, y)];
780
+
781
+ /**
782
+ * Points evenly spaced along a polyline, one scallop apart.
783
+ * @param {number[][]} polyline Points of a drawing, in real units.
784
+ * @param {number} start How far along it to start.
785
+ * @param {number} [end] How far along it to end, by default its end.
786
+ * @returns {number[][]} The chords' ends, from `start` to `end`.
787
+ */
788
+ const chordEnds = (polyline, start, end) => {
789
+ const lengths = [0];
790
+ for (let i = 1; i < polyline.length; i++) {
791
+ const [from, to] = [polyline[i - 1], polyline[i]];
792
+ lengths.push(lengths[i - 1] + Math.hypot(to[0] - from[0], to[1] - from[1]));
793
+ }
794
+ const last = end ?? lengths.at(-1);
795
+ const count = Math.max(
796
+ 1,
797
+ Math.round(((last - start) * (1 - 2 * BORDURE_WIDTH)) / BORDURE_SCALLOP),
798
+ );
799
+ return Array.from({ length: count + 1 }, (_, part) => {
800
+ const distance = start + ((last - start) * part) / count;
801
+ const i = Math.max(
802
+ 1,
803
+ lengths.findIndex((length) => length >= distance),
804
+ );
805
+ const fraction = (distance - lengths[i - 1]) / (lengths[i] - lengths[i - 1]);
806
+ return polyline[i - 1].map((v, axis) => v + (polyline[i][axis] - v) * fraction);
807
+ });
808
+ };
809
+
810
+ /**
811
+ * A lined bordure's inner edge, clockwise, in real units. Its frame's outline, inside the bordure,
812
+ * is cut at the top corners and the base point into runs, and each run into equal chords by its
813
+ * length in the drawing. Each chord is a scallop, a cubic whose controls are its ends moved 4/3 of
814
+ * the depth along the chord's normal, so it peaks at the depth and leaves its cusps square to the
815
+ * chord. The left side is the right's mirror.
816
+ * @param {Array[]} segments A heater's drawing, as in paths.js: two lines, then two curves.
817
+ * @param {number} sweep 1 for engrailed, bulging toward the frame's edge; -1 for invected.
818
+ * @returns {{ from: number[], c1: number[], c2: number[], to: number[], kind: string }[]} Its
819
+ * cubics from the top edge's first cusp, each from where the last ends, and the last back to the
820
+ * first. `kind` is "scallop" or "lobe".
821
+ */
822
+ const bordurePath = (segments, sweep) => {
823
+ const inset = BORDURE_WIDTH * W;
824
+ const depth = SCALLOP_DEPTH * W;
825
+ const scale = [(W - 2 * inset) / W, (H - 2 * inset) / H];
826
+ const inside = (point) => point.map((v, axis) => inset + v * scale[axis]);
827
+ const scallops = (ends) =>
828
+ ends.slice(1).map((end, i) => {
829
+ const start = ends[i];
830
+ const normal = unit([end[1] - start[1], start[0] - end[0]]);
831
+ const push = (point) => point.map((v, axis) => v + (normal[axis] * sweep * 4 * depth) / 3);
832
+ const [from, to] = [inside(start), inside(end)];
833
+ return { from, c1: push(from), c2: push(to), to, kind: "scallop" };
834
+ });
835
+ // The top-right lobe: its circle's centre is `centre` inside the corner each way, and its cusps
836
+ // are `reach` from the corner along each edge. It runs clockwise from the top cusp through the
837
+ // apex to the side cusp. Without a lobe, the corner is the cusp.
838
+ const radius = CORNER_LOBE * depth;
839
+ const centre = radius - depth;
840
+ const reach = sweep > 0 ? centre + Math.sqrt(radius ** 2 - centre ** 2) : 0;
841
+ const corner = inside([W, 0]);
842
+ const middle = [corner[0] - centre, corner[1] + centre];
843
+ const onCircle = (angle) => [
844
+ middle[0] + radius * Math.cos(angle),
845
+ middle[1] + radius * Math.sin(angle),
846
+ ];
847
+ const [first, last] = [Math.atan2(-centre, centre - reach), Math.atan2(reach - centre, centre)];
848
+ const apex = (first + last) / 2;
849
+ const handle = (4 / 3) * Math.tan((last - first) / 8) * radius;
850
+ const toward = (angle, along) =>
851
+ onCircle(angle).map(
852
+ (v, axis) => v + [-Math.sin(angle), Math.cos(angle)][axis] * handle * along,
853
+ );
854
+ const lobe = [
855
+ { from: onCircle(first), c1: toward(first, 1), c2: toward(apex, -1), to: onCircle(apex) },
856
+ { from: onCircle(apex), c1: toward(apex, 1), c2: toward(last, -1), to: onCircle(last) },
857
+ ].map((piece) => ({ ...piece, kind: "lobe" }));
858
+ const outline = outlinePoints(segments, RUN_SAMPLES);
859
+ const top = chordEnds(outline.slice(0, 2), reach / scale[0], W - reach / scale[0]);
860
+ const side = outline.slice(1, 3 + RUN_SAMPLES);
861
+ const right = [...(sweep > 0 ? lobe : []), ...scallops(chordEnds(side, reach / scale[1]))];
862
+ const flip = ([x, y]) => [W - x, y];
863
+ const left = right
864
+ .map(({ from, c1, c2, to, kind }) => ({
865
+ from: flip(to),
866
+ c1: flip(c2),
867
+ c2: flip(c1),
868
+ to: flip(from),
869
+ kind,
870
+ }))
871
+ .reverse();
872
+ return [...scallops(top), ...right, ...left];
873
+ };
874
+
875
+ /**
876
+ * A frame's clip-paths from its drawing: the outline, and rings just inside it. Each is a polygon
877
+ * fallback, then the shape().
878
+ * @param {Array[]} segments The drawing's segments, as in paths.js.
879
+ * @returns {{ outer: string[], ring: (inset: string) => string[], lined: (inset: string, sweep: number) => string[] }}
880
+ * The outline, and a ring `inset` wide: the outline minus the outline mapped into a box inset by
881
+ * `inset`. A lined ring has its inner edge scalloped as bordurePath() draws it, bulging outward
882
+ * for a `sweep` of 1 and inward for -1. Its shape() is drawn for an `inset` of BORDURE_WIDTH,
883
+ * and its polygon is the plain ring. The outline must be a heater's.
884
+ */
885
+ const frameOutline = (segments) => {
886
+ const points = outlinePoints(segments);
887
+ const start = [segments[0][1], segments[0][2]];
888
+ const loop = (inset) => [...points, points[0]].map((p) => insetPoint(p, inset)).join(", ");
889
+ const ring = (inset) => [
890
+ `polygon(evenodd, ${loop(null)}, ${loop(inset)})`,
891
+ `shape(evenodd from ${insetPoint(start, null)}, ${insetCommands(segments, null).join(", ")}, move to ${insetPoint(start, inset)}, ${insetCommands(segments, inset).join(", ")})`,
892
+ ];
893
+ return {
894
+ outer: [
895
+ `polygon(${points.map((p) => insetPoint(p, null)).join(", ")})`,
896
+ `shape(from ${insetPoint(start, null)}, ${insetCommands(segments, null).join(", ")})`,
897
+ ],
898
+ ring,
899
+ lined: (inset, sweep) => {
900
+ // The inner subpath runs counter-clockwise, against the outer, so a nonzero fill leaves the
901
+ // field unpainted and still paints where invected scallops overlap.
902
+ const pieces = bordurePath(segments, sweep);
903
+ const inner = pieces
904
+ .toReversed()
905
+ .map(({ from, c1, c2 }) => `curve to ${at(from)} with ${at(c2)} / ${at(c1)}`);
906
+ return [
907
+ ring(inset)[0],
908
+ `shape(nonzero from ${insetPoint(start, null)}, ${insetCommands(segments, null).join(", ")}, move to ${at(pieces[0].from)}, ${inner.join(", ")}, close)`,
909
+ ];
910
+ },
911
+ };
912
+ };
913
+
606
914
  // The same shape upside down, for an ordinary's reversed form.
607
915
  const flipPath = (segments) =>
608
916
  segments.map(([command, ...n]) => {
@@ -621,9 +929,14 @@ const flipPath = (segments) =>
621
929
 
622
930
  // Drawn shapes by `ordinary-line`, each reversible one also upside down as `ordinary-reversed-line`
623
931
  // unless it was drawn too. Upper-only battlements would flip onto the lower edges, so those aren't.
624
- const DRAWN = { ...PATHS };
932
+ // Shield outlines, such as the heater, have no line and aren't ordinaries.
933
+ const DRAWN = {};
625
934
  for (const key of Object.keys(PATHS)) {
626
935
  const name = LINE_NAMES.find((line) => key.endsWith(`-${line}`));
936
+ if (!name) {
937
+ continue;
938
+ }
939
+ DRAWN[key] = PATHS[key];
627
940
  const ordinary = key.slice(0, -name.length - 1);
628
941
  const reversedKey = `${ordinary}-reversed-${name}`;
629
942
  const upperOnly = name === "embattled" && UPPER_ONLY.includes(ordinary);
@@ -659,8 +972,11 @@ const mergeLined = (ordinary, line) => {
659
972
 
660
973
  const onVars = ({ size, counts }, scale = 1) => {
661
974
  const vars = { "--on-size": `${+(size * scale).toFixed(2)}%` };
662
- for (const [count, points] of Object.entries(counts)) {
663
- points.forEach(([left, top], i) => {
975
+ for (const [count, override] of Object.entries(counts)) {
976
+ if (override.size !== undefined) {
977
+ vars[`--on-${count}-size`] = `${+(override.size * scale).toFixed(2)}%`;
978
+ }
979
+ override.points.forEach(([left, top], i) => {
664
980
  vars[`--on-${count}-${i + 1}-x`] = `${left}%`;
665
981
  vars[`--on-${count}-${i + 1}-y`] = `${top}%`;
666
982
  });
@@ -700,18 +1016,133 @@ const PAINTS = [
700
1016
  [OF_THE_FIRST, "var(--the-first, transparent)"],
701
1017
  ];
702
1018
 
703
- export { draftOutline, DRAWN, flipPath, generatedShape, ORDINARIES, pathShape, svgPath };
1019
+ // Any frame, e.g. the heater, and a frame's field as `${FRAME} > *`.
1020
+ const FRAME = `:is(${FRAMES.map((frame) => `.${frame}`).join(", ")})`;
1021
+
1022
+ // The outline's width, just inside the frame's edge.
1023
+ const OUTLINE_WIDTH = "2px";
1024
+
1025
+ export {
1026
+ BORDURE_DEPTH,
1027
+ BORDURE_WIDTH,
1028
+ bordurePath,
1029
+ CORNER_LOBE,
1030
+ outlinePoints,
1031
+ SCALLOP_DEPTH,
1032
+ draftOutline,
1033
+ DRAWN,
1034
+ flipPath,
1035
+ frameOutline,
1036
+ generatedShape,
1037
+ ORDINARIES,
1038
+ pathShape,
1039
+ svgPath,
1040
+ };
704
1041
 
705
1042
  export default plugin(({ addBase, addUtilities, theme }) => {
706
1043
  // Unset on an inescutcheon, a canton or a part of a divided shield, so it can't inherit its
707
1044
  // shield's layout. In the base layer, so an ordinary on it still sets its own.
708
1045
  addBase({
709
- ".inescutcheon, .canton, :is(.escutcheon, .escutcheon *):is(.quarterly, .per-pale, .per-fess) > *":
1046
+ [`.inescutcheon, .inescutcheon > *, .canton, :is(.heater, .heater *):is(.quarterly, .per-pale, .per-fess) > ${PART}`]:
710
1047
  Object.fromEntries(layoutVars().map((name) => [name, "initial"])),
711
1048
  });
712
1049
 
713
1050
  const utilities = {};
714
1051
 
1052
+ // The frame's shape. Inside it, from the bottom: the field's ordinaries (1), its charges, the
1053
+ // bordure's ring (2, set by bordure-*), the chief (3) and its charges (4), a canton or
1054
+ // inescutcheon (5), and the outline (6). Every frame is drawn as a heater.
1055
+ const heater = frameOutline(PATHS.heater);
1056
+ for (const frame of FRAMES) {
1057
+ utilities[`.${frame}`] = {
1058
+ "clip-path": heater.outer,
1059
+ "&::before": {
1060
+ content: '""',
1061
+ position: "absolute",
1062
+ inset: "0",
1063
+ "z-index": "6",
1064
+ "background-color": "var(--color-sable)",
1065
+ "clip-path": heater.ring(OUTLINE_WIDTH),
1066
+ },
1067
+ "&::after": {
1068
+ position: "absolute",
1069
+ inset: "0",
1070
+ "z-index": "2",
1071
+ "clip-path": heater.ring("var(--bordure-width)"),
1072
+ // A bordure on a per-pale part sets --ring-dexter or --ring-sinister: the ring on that half.
1073
+ "background-image":
1074
+ "linear-gradient(to right, var(--ring-dexter, transparent) 50%, var(--ring-sinister, transparent) 50%)",
1075
+ },
1076
+ };
1077
+ }
1078
+
1079
+ // On a frame's field the border is transparent and the ring paints the bordure. An engrailed
1080
+ // ring bites into the field, so the field's border is narrower by the scallops' depth and the
1081
+ // field grows into the bites. Anywhere else the border is the bordure, plain.
1082
+ //
1083
+ // A bordure on a per-pale part is the ring on that half (see the frame's ::after), and the part
1084
+ // has no border on the palar side, so the bordure stops at the line as the rule says. Limits:
1085
+ // a part's bordure is always plain, a fur's ground only, and of-the-first takes the shield's
1086
+ // first. A bordure on both the field and a part isn't handled. A chief on a bordured part goes
1087
+ // under the ring, as the part is a stacking context; it steps out only by the sides that have
1088
+ // a bordure (--bordure-inset-dexter and -sinister).
1089
+ const BORDER = "var(--bordure-width, 0.75rem)";
1090
+ const narrowed = `calc(var(--bordure-width) * ${num(1 - BORDURE_DEPTH)})`;
1091
+ // Each part, and the side it has no bordure on: its half of the ring, its palar border and the
1092
+ // step-out that's 0 there.
1093
+ const PARTS = {
1094
+ [`nth-child(1 of ${PART})`]: ["dexter", "border-right-width", "--bordure-inset-sinister"],
1095
+ [`nth-last-child(1 of ${PART})`]: ["sinister", "border-left-width", "--bordure-inset-dexter"],
1096
+ };
1097
+ const furPaints = Object.keys(FURS).map((fur) => ({
1098
+ name: fur,
1099
+ colour: `var(--color-${FURS[fur][0]})`,
1100
+ ring: ringBackground(fur, theme),
1101
+ }));
1102
+ const tinctures = PAINTS.map(([tincture, colour]) => ({
1103
+ name: tincture,
1104
+ colour,
1105
+ ring: {
1106
+ "background-color": tincture === OF_THE_FIRST ? "var(--frame-first, transparent)" : colour,
1107
+ },
1108
+ }));
1109
+ for (const line of ["", ...BORDURE_LINES]) {
1110
+ const sweep = line === "engrailed" ? 1 : -1;
1111
+ const clip = line && heater.lined("var(--bordure-width)", sweep);
1112
+ // Plain tinctures are in grailwind.css.
1113
+ for (const { name, colour, ring } of line ? [...tinctures, ...furPaints] : furPaints) {
1114
+ const ringColour = ring["background-color"];
1115
+ const part = {};
1116
+ for (const [child, [side, open, inset]] of Object.entries(PARTS)) {
1117
+ part[`${FRAME} > .per-pale > &:${child}`] = {
1118
+ "border-color": "transparent",
1119
+ [open]: "0",
1120
+ [inset]: "0px",
1121
+ };
1122
+ part[`${FRAME}:has(> .per-pale > &:${child})::after`] = {
1123
+ content: '""',
1124
+ [`--ring-${side}`]: ringColour,
1125
+ };
1126
+ }
1127
+ utilities[line ? `.bordure-${line}-${name}` : `.bordure-${name}`] = {
1128
+ "border-style": "solid",
1129
+ "border-width": BORDER,
1130
+ "border-color": colour,
1131
+ "--bordure-inset": BORDER,
1132
+ ":is(.heater, .inescutcheon) > &": {
1133
+ "border-color": "transparent",
1134
+ ...(sweep === 1 && { "border-width": narrowed, "--bordure-inset": narrowed }),
1135
+ },
1136
+ ":is(.heater, .inescutcheon):has(> &)::after": {
1137
+ content: '""',
1138
+ ...ring,
1139
+ ...(clip && { "clip-path": clip }),
1140
+ },
1141
+ ...part,
1142
+ };
1143
+ }
1144
+ }
1145
+
715
1146
  for (const [tincture, colour] of PAINTS) {
716
1147
  utilities[`.fimbriated-${tincture}`] = {
717
1148
  "--fimbriated": "1",
@@ -719,11 +1150,10 @@ export default plugin(({ addBase, addUtilities, theme }) => {
719
1150
  };
720
1151
  }
721
1152
 
722
- // Isolated, so an ordinary in a quarter paints under the shield's own ordinary.
723
- // The chief's ::after stays unraised, so charges on it stay on top.
1153
+ // Isolated, so an ordinary in a quarter paints under the shield's own ordinary. A frame's field
1154
+ // isn't, so the frame's bordure ring can go between its ordinaries and its chief.
724
1155
  const ordinaryRules = (ordinary, pseudo, line) => ({
725
1156
  position: "relative",
726
- isolation: "isolate",
727
1157
  ...(BETWEEN[ordinary] && betweenVars(mergeLined(ordinary, line))),
728
1158
  ...(ON[ordinary] && onVars(ON[ordinary], ON_SCALE[line] ?? 1)),
729
1159
  // With a chief, everything else is drawn in the field below it.
@@ -736,9 +1166,11 @@ export default plugin(({ addBase, addUtilities, theme }) => {
736
1166
  [PSEUDO[ordinary] ?? "&::before"]: {
737
1167
  content: '""',
738
1168
  position: "absolute",
739
- ...(!PSEUDO[ordinary] && { "z-index": "1" }),
1169
+ // The chief goes over a frame's bordure ring, and its charges over it.
1170
+ "z-index": PSEUDO[ordinary] ? "3" : "1",
740
1171
  ...pseudo,
741
1172
  },
1173
+ [`&:not(${FRAME} > *)`]: { isolation: "isolate" },
742
1174
  });
743
1175
 
744
1176
  for (const [ordinary, vertices] of Object.entries(ORDINARIES)) {
@@ -780,10 +1212,20 @@ export default plugin(({ addBase, addUtilities, theme }) => {
780
1212
  // The swapped field's tiles would wrap into the bordure's strips, so the bordure shows there.
781
1213
  ...(chief && { "background-clip": "content-box" }),
782
1214
  };
783
- utilities[`.${ordinary}-counterchanged`] = ordinaryRules(ordinary, {
784
- ...counterchanged,
785
- "clip-path": chief ? plain : `polygon(${vertices.map(pointBelowChief).join(", ")})`,
786
- });
1215
+ // Charges on it are counterchanged against it, so they get the field's own tinctures.
1216
+ utilities[`.${ordinary}-counterchanged`] = {
1217
+ ...ordinaryRules(ordinary, {
1218
+ ...counterchanged,
1219
+ "clip-path": chief ? plain : `polygon(${vertices.map(pointBelowChief).join(", ")})`,
1220
+ }),
1221
+ "--on-counterchange-color": "transparent",
1222
+ ...Object.fromEntries(
1223
+ ["image", "size", "position", "repeat"].map((n) => [
1224
+ `--on-counterchange-${n}`,
1225
+ `var(--field-${n})`,
1226
+ ]),
1227
+ ),
1228
+ };
787
1229
 
788
1230
  for (const [tincture, colour] of PAINTS) {
789
1231
  utilities[`.${ordinary}-${tincture}`] = ordinaryRules(ordinary, {
@@ -821,10 +1263,13 @@ export default plugin(({ addBase, addUtilities, theme }) => {
821
1263
  // A plain polygon, without the fimbriation vars, so fimbriated-* can't reach a fur ordinary.
822
1264
  for (const fur of Object.keys(FURS)) {
823
1265
  const pseudo = { ...placed, ...furBackground(fur, theme) };
824
- utilities[`.${ordinary}-${fur}`] = ordinaryRules(ordinary, {
825
- ...pseudo,
826
- "clip-path": plain,
827
- });
1266
+ // `cross-potent` is the charge, not a cross of the fur potent.
1267
+ if (!CHARGES.includes(`${ordinary}-${fur}`)) {
1268
+ utilities[`.${ordinary}-${fur}`] = ordinaryRules(ordinary, {
1269
+ ...pseudo,
1270
+ "clip-path": plain,
1271
+ });
1272
+ }
828
1273
  for (const [name, line] of Object.entries(LINES)) {
829
1274
  if (!lineOrdinaries(name).includes(ordinary)) {
830
1275
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grailwind",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -28,7 +28,7 @@
28
28
  "license": "MIT",
29
29
  "description": "Heraldic blazon for Tailwind CSS: tinctures, divisions, ordinaries and charges",
30
30
  "devDependencies": {
31
- "@grailwind/eslint-plugin": "^0.1.0-beta.4",
31
+ "@grailwind/eslint-plugin": "^0.1.0-beta.5",
32
32
  "@html-eslint/parser": "^0.67.0",
33
33
  "@tailwindcss/cli": "^4.3.3",
34
34
  "eslint": "^10.14.0",
package/paths.js CHANGED
@@ -312,6 +312,14 @@ export const PATHS = {
312
312
  ["L",65.4,120],
313
313
  ["Z"],
314
314
  ],
315
+ "heater": [
316
+ ["M",0,0],
317
+ ["L",100,0],
318
+ ["L",100,66],
319
+ ["C",100,96,75,114,50,120],
320
+ ["C",25,114,0,96,0,66],
321
+ ["Z"],
322
+ ],
315
323
  "pall-embattled": [
316
324
  ["M",50,36.22],
317
325
  ["L",42.5,29.01],