@signal9/era-ui 14.1.1 → 14.2.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.
@@ -15,6 +15,12 @@
15
15
  *
16
16
  * Run this on a NON-FLAT surface (bevel is the harshest): the surface promises a
17
17
  * shadow, so a floating panel that resolves to `box-shadow: none` is broken.
18
+ *
19
+ * FLAT IS NOT UNAUDITED, though this audit skips it. "Flat's tokens resolve to
20
+ * none, so there is nothing to assert" was true of THIS rule and false of the
21
+ * surface — it left the default surface uncovered, and two invisible-control
22
+ * bugs shipped in the gap. surface/invisible-box carries the assertion that does
23
+ * hold there: a box must differ from what it sits on, by tone or by an edge.
18
24
  */
19
25
  export const flatChrome = {
20
26
  id: 'surface/flat-chrome',
@@ -25,8 +31,8 @@ export const flatChrome = {
25
31
  selector: '[role="menu"], [role="listbox"], [role="dialog"], [data-dialog-content], [data-popover-content], [data-select-content], [data-dropdown-menu-content], [data-combobox-content], [data-menubar-content], [data-context-menu-content]',
26
32
  check(el) {
27
33
  const surface = el.closest('[data-surface]')?.getAttribute('data-surface') ?? 'flat';
28
- // On flat the tokens legitimately resolve to `none` there is nothing to
29
- // assert. The whole point of the rule only exists on a surface with depth.
34
+ // On flat the tokens legitimately resolve to `none`, so THIS rule has
35
+ // nothing to assert see surface/invisible-box for the one that does.
30
36
  if (surface === 'flat')
31
37
  return null;
32
38
  const s = getComputedStyle(el);
@@ -4,6 +4,7 @@ import { concentricInset } from './concentric-inset.js';
4
4
  import { cornersAxis } from './corners-axis.js';
5
5
  import { evenContainerPadding } from './even-container-padding.js';
6
6
  import { flatChrome } from './flat-chrome.js';
7
+ import { invisibleBox } from './invisible-box.js';
7
8
  import { anonymousText } from './anonymous-text.js';
8
9
  import { iconFlush } from './icon-flush.js';
9
10
  import { offAxisMotion } from './off-axis-motion.js';
@@ -15,4 +16,4 @@ import { restingGap } from './resting-gap.js';
15
16
  import { singleGlyphSquare } from './single-glyph-square.js';
16
17
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
17
18
  import { wallMatchesGap } from './wall-matches-gap.js';
18
- export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
19
+ export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, invisibleBox, iconFlush, anonymousText, offAxisMotion, offTierHeight, pillHoldsItsInk, proseLeading, radiusConcentricity, restingGap, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
@@ -5,6 +5,7 @@ import { concentricInset } from './concentric-inset.js';
5
5
  import { cornersAxis } from './corners-axis.js';
6
6
  import { evenContainerPadding } from './even-container-padding.js';
7
7
  import { flatChrome } from './flat-chrome.js';
8
+ import { invisibleBox } from './invisible-box.js';
8
9
  import { anonymousText } from './anonymous-text.js';
9
10
  import { iconFlush } from './icon-flush.js';
10
11
  import { offAxisMotion } from './off-axis-motion.js';
@@ -16,7 +17,7 @@ import { restingGap } from './resting-gap.js';
16
17
  import { singleGlyphSquare } from './single-glyph-square.js';
17
18
  import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
18
19
  import { wallMatchesGap } from './wall-matches-gap.js';
19
- export { clippedGlyphs, collapsedTextTrim, concentricInset, cornersAxis, evenContainerPadding, flatChrome, 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, wallMatchesGap };
20
21
  // Auto-register built-ins. Consumers can still unregister or add their own.
21
22
  //
22
23
  // Two of these only tell the truth on a particular axis setting, because the bug
@@ -31,6 +32,7 @@ registry.register(concentricInset);
31
32
  registry.register(cornersAxis);
32
33
  registry.register(evenContainerPadding);
33
34
  registry.register(flatChrome);
35
+ registry.register(invisibleBox);
34
36
  registry.register(iconFlush);
35
37
  registry.register(anonymousText);
36
38
  registry.register(offAxisMotion);
@@ -0,0 +1,2 @@
1
+ import type { Audit } from '../types.js';
2
+ export declare const invisibleBox: Audit;
@@ -0,0 +1,92 @@
1
+ /*
2
+ * A box you cannot see: same fill as what it sits on, and no edge.
3
+ *
4
+ * THE COMPLEMENT OF surface/flat-chrome, and the surface that audit could not
5
+ * cover. flat-chrome asks "does this floating panel reference a shadow token?"
6
+ * and skips flat entirely, on the reasoning that flat's tokens legitimately
7
+ * resolve to `none` so there is nothing to assert. That reasoning is what left
8
+ * flat — the DEFAULT surface — unaudited, and two bugs shipped inside the gap:
9
+ * Checkbox filled `bg-elevated` on a `bg-elevated` Card with an inert
10
+ * `shadow-well`, and every floating panel did the same with an inert
11
+ * `shadow-lg`. Both were invisible; neither could be seen by any check era had.
12
+ *
13
+ * The assertion flat needs is not "has a shadow". It is the more basic one:
14
+ * A BOX THAT PAINTS A FILL MUST BE DISTINGUISHABLE FROM WHAT IT PAINTS OVER.
15
+ * Flat satisfies that with tone or a hairline rather than depth, which is a
16
+ * legitimate answer — but "no tone difference AND no edge" is not.
17
+ *
18
+ * A contrast checker does not catch this. Those compare text to its background
19
+ * and would pass every one of these cleanly: the text is perfectly legible, it
20
+ * is the CONTAINER that has vanished, taking the boundary between a control and
21
+ * the page with it. For a floating panel that is worse than invisible, because
22
+ * the panel's text then interleaves with the content underneath.
23
+ *
24
+ * Why it runs on every surface, not just flat: the law is general, and a surface
25
+ * that splits its tiers passes it for free. Making it flat-only would encode the
26
+ * same assumption that produced the blind spot.
27
+ *
28
+ * WHAT IT DELIBERATELY IGNORES. Anything transparent (a positioning wrapper, a
29
+ * layout div) is not painting a box and owes nothing. So is an element whose
30
+ * fill genuinely matches because it is a continuation of its parent rather than
31
+ * a thing on top of it — the caller marks those with data-era-continuous, which
32
+ * is a claim a reviewer can check rather than a silence.
33
+ */
34
+ /** Fills that mean "I am not painting a box". */
35
+ function isTransparent(color) {
36
+ return color === 'transparent' || /,\s*0\)\s*$/.test(color) || color === 'rgba(0, 0, 0, 0)';
37
+ }
38
+ /** The nearest ancestor that actually paints, which is what this box sits on. */
39
+ function paintedBackdrop(el) {
40
+ for (let p = el.parentElement; p; p = p.parentElement) {
41
+ const color = getComputedStyle(p).backgroundColor;
42
+ if (!isTransparent(color))
43
+ return { el: p, color };
44
+ }
45
+ return null;
46
+ }
47
+ /** Any edge at all: a border, a ring or shadow, or an outline. */
48
+ function hasEdge(s) {
49
+ if (s.boxShadow && s.boxShadow !== 'none')
50
+ return true;
51
+ if (s.outlineStyle && s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0)
52
+ return true;
53
+ // Written out rather than built from a side name: the Tailwind lint reads
54
+ // `border${side}Width` as a concatenated CLASS, which it is not, and silencing
55
+ // that rule to keep the loop is a worse trade than four explicit reads.
56
+ const sides = [
57
+ [s.borderTopWidth, s.borderTopStyle, s.borderTopColor],
58
+ [s.borderRightWidth, s.borderRightStyle, s.borderRightColor],
59
+ [s.borderBottomWidth, s.borderBottomStyle, s.borderBottomColor],
60
+ [s.borderLeftWidth, s.borderLeftStyle, s.borderLeftColor]
61
+ ];
62
+ return sides.some(([width, style, color]) => parseFloat(width) > 0 && style !== 'none' && !isTransparent(color));
63
+ }
64
+ export const invisibleBox = {
65
+ id: 'surface/invisible-box',
66
+ name: 'A filled box is distinguishable from its backdrop',
67
+ description: 'An element that paints a background must differ from what it sits on, by tone or by an edge. Identical fill with no border, shadow or outline is an invisible container — a contrast checker passes it because the TEXT is fine and only the box has vanished.',
68
+ category: 'tokens',
69
+ severity: 'warn',
70
+ // Chrome that claims to be a surface of its own. A bare layout div is not in
71
+ // scope; it has no fill to be invisible with.
72
+ selector: '[role="menu"], [role="listbox"], [role="dialog"], [role="checkbox"], [role="radio"], [data-dialog-content], [data-popover-content], [data-select-content], [data-dropdown-menu-content], [data-combobox-content], [data-menubar-content], [data-context-menu-content], [data-tooltip-content], [data-card-root], [data-checkbox-root], [data-switch-root]',
73
+ check(el) {
74
+ if (el.dataset?.eraContinuous !== undefined)
75
+ return null;
76
+ const s = getComputedStyle(el);
77
+ if (isTransparent(s.backgroundColor))
78
+ return null;
79
+ if (hasEdge(s))
80
+ return null;
81
+ const backdrop = paintedBackdrop(el);
82
+ if (!backdrop || backdrop.color !== s.backgroundColor)
83
+ return null;
84
+ const issue = {
85
+ auditId: 'surface/invisible-box',
86
+ element: el,
87
+ message: `fill is identical to its backdrop (${s.backgroundColor}) and it has no border, shadow or outline — the box is invisible. Give it a tone of its own or an edge; on a surface whose shadows resolve to none, an edge means a hairline.`,
88
+ details: { fill: s.backgroundColor, backdrop: backdrop.color }
89
+ };
90
+ return issue;
91
+ }
92
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "14.1.1",
3
+ "version": "14.2.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",