@signal9/era-ui 20.0.0 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [21.0.0](https://github.com/sig-nine/era-ui/compare/v20.0.0...v21.0.0) (2026-08-09)
2
+
3
+ ### ⚠ BREAKING CHANGES
4
+
5
+ * **bar:** a `Bar` with `size="control"` and the default `content="tier"`
6
+ now pads and gaps by `chip-inset-control` (1/2/3) instead of `icon-inset-chip`
7
+ (4/5/6). That is the concentric answer for the chip-tier content the variant
8
+ describes, and it is visibly tighter. A control bar that actually holds
9
+ icon-tier buttons was never described by `content="tier"` and was relying on the
10
+ old value being wrong in a helpful direction — give it `size="chip"`, or pass
11
+ the wall explicitly. `size="bar"` and `size="chip"` are unchanged in value;
12
+ `size="bar"` now names its inset `gutter`, which is what era already calls that
13
+ pairing, rather than borrowing icon-inset-chip's name for the same number.
14
+
15
+ ### Bug Fixes
16
+
17
+ * **bar:** a bar's wall is the concentric inset for the pair it actually holds ([45f76a8](https://github.com/sig-nine/era-ui/commit/45f76a8c83c6d810c5346ccff03e1750342a57b4))
18
+
1
19
  ## [20.0.0](https://github.com/sig-nine/era-ui/compare/v19.0.0...v20.0.0) (2026-08-09)
2
20
 
3
21
  ### ⚠ BREAKING CHANGES
@@ -15,5 +15,6 @@ import { radiusConcentricity } from './radius-concentricity.js';
15
15
  import { restingGap } from './resting-gap.js';
16
16
  import { singleGlyphSquare } from './single-glyph-square.js';
17
17
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
18
+ import { wallIsIsotropic } from './wall-is-isotropic.js';
18
19
  import { wallMatchesGap } from './wall-matches-gap.js';
19
- export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, invisibleBox, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
20
+ export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, invisibleBox, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallIsIsotropic, wallMatchesGap };
@@ -16,8 +16,9 @@ import { radiusConcentricity } from './radius-concentricity.js';
16
16
  import { restingGap } from './resting-gap.js';
17
17
  import { singleGlyphSquare } from './single-glyph-square.js';
18
18
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
19
+ import { wallIsIsotropic } from './wall-is-isotropic.js';
19
20
  import { wallMatchesGap } from './wall-matches-gap.js';
20
- export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, invisibleBox, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
21
+ export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, invisibleBox, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallIsIsotropic, wallMatchesGap };
21
22
  // Auto-register built-ins. Consumers can still unregister or add their own.
22
23
  //
23
24
  // Two of these only tell the truth on a particular axis setting, because the bug
@@ -43,4 +44,5 @@ registry.register(radiusConcentricity);
43
44
  registry.register(restingGap);
44
45
  registry.register(singleGlyphSquare);
45
46
  registry.register(uniformSiblingGaps);
47
+ registry.register(wallIsIsotropic);
46
48
  registry.register(wallMatchesGap);
@@ -0,0 +1,2 @@
1
+ import type { Audit } from '../types.js';
2
+ export declare const wallIsIsotropic: Audit;
@@ -0,0 +1,147 @@
1
+ import { TOL, flexChildren, isHorizontalFlex, paintsChrome, tiers } from './_helpers.js';
2
+ /*
3
+ * A bar's wall is the same on every side.
4
+ *
5
+ * THE EVEN-GAP LAW IS "on ALL sides", and era had two audits enforcing it one
6
+ * axis at a time, with nothing looking across them:
7
+ *
8
+ * layout/wall-matches-gap horizontal wall vs horizontal gap
9
+ * layout/even-container-padding padding-top vs padding-bottom
10
+ *
11
+ * A bar that pads 4px horizontally, gaps 4px horizontally, and centres its
12
+ * children to 1px vertically passes BOTH — the first sees 4 against 4, the
13
+ * second sees equal top and bottom padding — and is visibly wrong. Two shipped
14
+ * this way. Bar's content="tier" used one token for all three sizes on the
15
+ * reasoning that chip⊃icon, control⊃chip and bar⊃control all equal sp/2, which
16
+ * is true for two of them (chip is the rung that is not a clean sp multiple, so
17
+ * the difference cancels for the pairs that span it and survives for the pair
18
+ * that does not). The Taskbar padded px-inset-chip — a TEXT inset, (h-chip −
19
+ * text)/2 — where the concentric inset belonged. Both read to a user as "more
20
+ * space on the left than on the top", which is exactly how it was reported.
21
+ *
22
+ * WHY THE VERTICAL SIDE IS MEASURED, NOT READ. On a bar the vertical wall is
23
+ * usually not padding at all — it is what `align-items: center` produces from
24
+ * the leftover height, so padding-top is 0 and the real gap is
25
+ * (contentHeight − childHeight)/2. Reading the padding property would report
26
+ * zero and see nothing. That is the whole reason this class of bug was
27
+ * invisible to a rule written in terms of padding.
28
+ *
29
+ * SCOPE. Only centred horizontal flex rows with a BOUNDED height — a tier. An
30
+ * auto-height row has no leftover to distribute, so there is no vertical wall to
31
+ * compare against, and an unbounded container's padding is optical rather than
32
+ * geometric (see the geometric/optical split in density.css).
33
+ *
34
+ * DELIBERATE ASYMMETRY. Some era chrome is legitimately not isotropic: a pane
35
+ * handle sits its leading side at the text inset while the trailing side keeps
36
+ * the concentric wall. Those opt out with data-era-anisotropic, which is a claim
37
+ * a reviewer can check rather than a silence — the same shape as
38
+ * data-era-continuous on surface/invisible-box.
39
+ */
40
+ export const wallIsIsotropic = {
41
+ id: 'layout/wall-is-isotropic',
42
+ name: 'A tier row walls its content evenly on every side',
43
+ description: 'In a fixed-height centred flex row, the horizontal padding must equal the vertical gap that align-items:center leaves above and below the tallest child. Comparing only within an axis passes a bar that pads 4px at the sides and 1px top and bottom.',
44
+ category: 'layout',
45
+ severity: 'warn',
46
+ selector: '*',
47
+ check(el) {
48
+ if (el.dataset?.eraAnisotropic !== undefined)
49
+ return null;
50
+ const s = getComputedStyle(el);
51
+ if (!isHorizontalFlex(s))
52
+ return null;
53
+ // Centred rows only: any other alignment means the vertical position is
54
+ // not a wall, it is a deliberate stretch/baseline/start.
55
+ if (s.alignItems !== 'center')
56
+ return null;
57
+ if (s.flexWrap === 'wrap')
58
+ return null;
59
+ // A WALL ONLY EXISTS WHERE CONTENT IS PACKED AGAINST ONE. justify-center
60
+ // splits the leftover width evenly on both sides, so the horizontal gap is
61
+ // (width − content)/2 and has nothing to do with padding — an icon button
62
+ // is exactly this: px-0, aspect-square, glyph centred, and its real
63
+ // horizontal gap equals its vertical one by construction. Reading
64
+ // padding-left there reports 0 against a true gap of 5 and flags every
65
+ // centred control in the library.
66
+ if (!/^(flex-)?start$|^normal$/.test(s.justifyContent))
67
+ return null;
68
+ const host = el;
69
+ const kids = flexChildren(host);
70
+ if (!kids.length)
71
+ return null;
72
+ const padTop = parseFloat(s.paddingTop) || 0;
73
+ const padBottom = parseFloat(s.paddingBottom) || 0;
74
+ const padLeft = parseFloat(s.paddingLeft) || 0;
75
+ const padRight = parseFloat(s.paddingRight) || 0;
76
+ // An asymmetric row is even-container-padding's / wall-matches-gap's
77
+ // business; this rule is about the two axes disagreeing.
78
+ if (Math.abs(padLeft - padRight) > TOL)
79
+ return null;
80
+ // THE ROW MUST CLAIM A WALL. A flush transparent row — a checkbox beside
81
+ // its label, a radio and its text — pads nothing and paints nothing: it is
82
+ // content in a line, not a container with edges, and its left edge is the
83
+ // text's edge. Asking it to inset by the concentric gap would push the
84
+ // label off the alignment it shares with the prose around it. A row claims
85
+ // a wall by padding horizontally or by painting a box.
86
+ if (padLeft <= TOL && !paintsChrome(host))
87
+ return null;
88
+ const contentH = host.clientHeight - padTop - padBottom;
89
+ if (contentH <= 0)
90
+ return null;
91
+ // The tallest child is what the wall is measured against — a short child
92
+ // beside a tall one has slack of its own, which is not the container's.
93
+ const tallest = Math.max(...kids.map((k) => k.getBoundingClientRect().height));
94
+ if (tallest <= 0)
95
+ return null;
96
+ // ONLY ROWS THAT HOLD A CONTROL BOX. The rule is about a bar walling the
97
+ // controls inside it, and it needs the child's BOX to mean something.
98
+ //
99
+ // Two things are deliberately out of scope, and both were measured before
100
+ // being excluded rather than assumed:
101
+ //
102
+ // An ICON+TEXT row (file-upload's drop label, Step's summary) walls by
103
+ // the TEXT inset, not by the icon's concentric one — that is Button's
104
+ // own documented convention, `px-inset-control` with the gap widened to
105
+ // match when an icon is present. Measuring those against the icon
106
+ // reports 6-vs-5 on rows that are following the house rule.
107
+ //
108
+ // A TEXT FIELD (date/time field and their ranges) walls by
109
+ // --era-field-px, which density.css names and floors on purpose: it is
110
+ // the room where typed text starts, and it is not the segment box's
111
+ // concentric gap. Measuring those reports 8-vs-1 on a deliberate choice.
112
+ //
113
+ // What is left is the case the law is actually about — chrome holding
114
+ // controls — and it is where both shipped bugs were.
115
+ const CONTROL = 'button, a[href], input, select, textarea, [role="button"], [role="switch"],' +
116
+ ' [role="checkbox"], [role="radio"], [role="combobox"], [role="tab"], [role="menuitem"]';
117
+ const tallestKid = kids.find((k) => Math.abs(k.getBoundingClientRect().height - tallest) <= TOL);
118
+ if (!tallestKid)
119
+ return null;
120
+ if (!tallestKid.matches(CONTROL) && !tallestKid.querySelector(CONTROL))
121
+ return null;
122
+ // A control that is not on a tier is off-tier-height's problem, not this
123
+ // rule's — measuring a wall against a box of the wrong size compounds one
124
+ // defect into two reports.
125
+ const onTier = tiers(host).some((t) => Math.abs(t.px - tallest) <= TOL);
126
+ if (!onTier)
127
+ return null;
128
+ const vertical = (contentH - tallest) / 2 + padTop;
129
+ // Flush content (a child filling its row) has no vertical wall to match,
130
+ // and a stretched child would report a spurious zero.
131
+ if (vertical < TOL)
132
+ return null;
133
+ if (Math.abs(padLeft - vertical) <= TOL)
134
+ return null;
135
+ const issue = {
136
+ auditId: 'layout/wall-is-isotropic',
137
+ element: el,
138
+ message: `wall is ${padLeft.toFixed(1)}px at the sides but ${vertical.toFixed(1)}px above and below — the even-gap law is on ALL sides. Pad by the concentric inset for the pairing this row actually holds ((container − child)/2), not by a text inset or a neighbouring tier's token.`,
139
+ details: {
140
+ horizontal: padLeft,
141
+ vertical: +vertical.toFixed(2),
142
+ delta: +(padLeft - vertical).toFixed(2)
143
+ }
144
+ };
145
+ return issue;
146
+ }
147
+ };