@signal9/era-ui 2.21.0 → 2.23.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/dist/ai/artifact/artifact-actions.svelte +3 -1
- package/dist/ai/artifact/artifact-content.svelte +3 -1
- package/dist/ai/artifact/artifact-header.svelte +5 -1
- package/dist/ai/artifact/artifact.svelte +5 -1
- package/dist/ai/chain-of-thought/chain-of-thought-step.svelte +5 -1
- package/dist/ai/chain-of-thought/chain-of-thought.svelte +1 -1
- package/dist/ai/confirmation/confirmation-args.svelte +7 -7
- package/dist/ai/conversation/conversation-content.svelte +4 -1
- package/dist/ai/conversations/conversations-group.svelte +1 -1
- package/dist/ai/conversations/conversations.svelte.js +5 -1
- package/dist/ai/error-panel/error-panel.svelte +1 -1
- package/dist/ai/inline-citation/inline-citation.svelte +22 -5
- package/dist/ai/loader.svelte +4 -1
- package/dist/ai/message/message-usage.svelte +4 -2
- package/dist/ai/open-in-chat/open-in-chat.svelte +16 -4
- package/dist/ai/plan/plan-header.svelte +1 -1
- package/dist/ai/project.js +10 -2
- package/dist/ai/prompt-input/prompt-input-attachments.svelte +1 -1
- package/dist/ai/prompt-input/prompt-input.svelte +2 -6
- package/dist/ai/run-status/run-status.svelte +5 -2
- package/dist/ai/runtime-feed/runtime-feed-item.svelte +1 -1
- package/dist/ai/runtime-feed/runtime-feed-root.svelte +1 -3
- package/dist/ai/structured-value/structured-value.svelte +8 -7
- package/dist/ai/suggestion/suggestions.svelte +4 -1
- package/dist/ai/task/task-content.svelte +4 -1
- package/dist/ai/tool/tool-chip.svelte +1 -1
- package/dist/ai/tool/tool-exec.svelte +1 -3
- package/dist/ai/tool/tool.svelte +1 -4
- package/dist/ai/web-preview/web-preview.svelte +1 -1
- package/dist/ai/workflow/node.svelte +3 -1
- package/dist/dev/audit/audits/_helpers.d.ts +19 -0
- package/dist/dev/audit/audits/_helpers.js +68 -0
- package/dist/dev/audit/audits/collapsed-text-trim.d.ts +2 -0
- package/dist/dev/audit/audits/collapsed-text-trim.js +52 -0
- package/dist/dev/audit/audits/flat-chrome.d.ts +2 -0
- package/dist/dev/audit/audits/flat-chrome.js +46 -0
- package/dist/dev/audit/audits/icon-flush.d.ts +2 -0
- package/dist/dev/audit/audits/icon-flush.js +51 -0
- package/dist/dev/audit/audits/index.d.ts +7 -1
- package/dist/dev/audit/audits/index.js +20 -1
- package/dist/dev/audit/audits/off-axis-motion.d.ts +2 -0
- package/dist/dev/audit/audits/off-axis-motion.js +60 -0
- package/dist/dev/audit/audits/off-tier-height.d.ts +2 -0
- package/dist/dev/audit/audits/off-tier-height.js +47 -0
- package/dist/dev/audit/audits/single-glyph-square.d.ts +2 -0
- package/dist/dev/audit/audits/single-glyph-square.js +50 -0
- package/dist/dev/axis-signature.d.ts +17 -0
- package/dist/dev/axis-signature.js +87 -0
- package/dist/dev/index.d.ts +1 -0
- package/dist/dev/index.js +1 -0
- package/dist/era-ui.css +1 -1
- package/dist/os/window.svelte +1 -1
- package/dist/styles/typography.css +9 -1
- package/dist/ui/button/button.svelte +10 -0
- package/dist/ui/card/card.svelte +3 -1
- package/dist/ui/command/command-input.svelte +1 -1
- package/dist/ui/command/command-item.svelte +1 -1
- package/dist/ui/command/command-link-item.svelte +1 -1
- package/dist/ui/command-bar/command-bar.svelte +2 -2
- package/dist/ui/copy-button/copy-button.svelte +3 -1
- package/dist/ui/input/input.svelte +1 -1
- package/dist/ui/kv/kv.svelte +1 -1
- package/dist/ui/pane/pane-close.svelte +9 -1
- package/dist/ui/select/select-trigger.svelte +1 -1
- package/dist/ui/step/step-summary.svelte +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +9 -9
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The chopped-text bug.
|
|
3
|
+
*
|
|
4
|
+
* `era-text-trim` centres the ink by trimming the line box down to the x-height
|
|
5
|
+
* band (text-box: trim-both ex alphabetic). That is exactly right on a control
|
|
6
|
+
* with a FIXED height — the tier holds the box open and the trim only re-centres
|
|
7
|
+
* what is inside it.
|
|
8
|
+
*
|
|
9
|
+
* Put the same utility on an AUTO-height element that also clips its overflow
|
|
10
|
+
* (`truncate`, `overflow-hidden`, an <input>) and the box collapses to the
|
|
11
|
+
* ~7.5px x-height band. The clip then slices every ascender, cap and descender
|
|
12
|
+
* off the glyphs, and the text renders as a chopped middle stripe.
|
|
13
|
+
*
|
|
14
|
+
* The tell is unmistakable in the geometry: a box shorter than its own font-size
|
|
15
|
+
* that is still clipping. No static check can see it — whether the box collapses
|
|
16
|
+
* depends on whether some OTHER class set a height.
|
|
17
|
+
*/
|
|
18
|
+
export const collapsedTextTrim = {
|
|
19
|
+
id: 'typography/collapsed-text-trim',
|
|
20
|
+
name: 'Trimmed text box has not collapsed',
|
|
21
|
+
description: 'era-text-trim on an auto-height element that clips overflow collapses the box to the x-height band, slicing the tops and tails off the glyphs.',
|
|
22
|
+
category: 'layout',
|
|
23
|
+
severity: 'error',
|
|
24
|
+
selector: '*',
|
|
25
|
+
check(el) {
|
|
26
|
+
const s = getComputedStyle(el);
|
|
27
|
+
// Is the trim active on this element? Chromium exposes the longhand.
|
|
28
|
+
const trim = s.getPropertyValue('text-box-trim').trim() || s.getPropertyValue('text-box').trim();
|
|
29
|
+
if (!trim || trim === 'none' || trim === 'normal')
|
|
30
|
+
return null;
|
|
31
|
+
// Is it clipping?
|
|
32
|
+
const clips = /hidden|clip|auto|scroll/.test(s.overflowX + s.overflowY);
|
|
33
|
+
if (!clips)
|
|
34
|
+
return null;
|
|
35
|
+
// Does it hold any text of its own?
|
|
36
|
+
if (!el.textContent?.trim())
|
|
37
|
+
return null;
|
|
38
|
+
const h = el.getBoundingClientRect().height;
|
|
39
|
+
const fontSize = parseFloat(s.fontSize) || 14;
|
|
40
|
+
// A healthy fixed-height control is at least as tall as its own type; a
|
|
41
|
+
// collapsed trim band lands near the x-height, roughly half of it.
|
|
42
|
+
if (h >= fontSize)
|
|
43
|
+
return null;
|
|
44
|
+
const issue = {
|
|
45
|
+
auditId: 'typography/collapsed-text-trim',
|
|
46
|
+
element: el,
|
|
47
|
+
message: `text box collapsed to ${h.toFixed(1)}px (font-size ${fontSize}px) while clipping — era-text-trim on an auto-height clipping element chops the glyphs; move the trim to the fixed-height row`,
|
|
48
|
+
details: { height: h, fontSize, trim, overflow: s.overflowX + '/' + s.overflowY }
|
|
49
|
+
};
|
|
50
|
+
return issue;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Chrome that forgot to reference a surface shadow token.
|
|
3
|
+
*
|
|
4
|
+
* The surface system is entirely token-driven: --era-shadow and its siblings are
|
|
5
|
+
* what expand into bevel's chisel, glass's frosted rim, cartoon's ink-and-offset,
|
|
6
|
+
* and `none` on flat. So an element that paints a raised or floating surface with
|
|
7
|
+
* a bare `bg-*` and NO shadow token looks perfectly correct on flat — and renders
|
|
8
|
+
* as an undifferentiated grey rectangle on the other three.
|
|
9
|
+
*
|
|
10
|
+
* No token-usage check can catch this, because `bg-2` is a perfectly legal token.
|
|
11
|
+
* The bug is the ABSENCE of a second one, and absence is only visible once you
|
|
12
|
+
* render the thing on a surface that has depth. That is what happened to the
|
|
13
|
+
* command-bar dropdown: it used bg-2 with no shadow, correct on flat, borderless
|
|
14
|
+
* on bevel/glass/cartoon.
|
|
15
|
+
*
|
|
16
|
+
* Run this on a NON-FLAT surface (bevel is the harshest): the surface promises a
|
|
17
|
+
* shadow, so a floating panel that resolves to `box-shadow: none` is broken.
|
|
18
|
+
*/
|
|
19
|
+
export const flatChrome = {
|
|
20
|
+
id: 'surface/flat-chrome',
|
|
21
|
+
name: 'Floating chrome carries a surface shadow',
|
|
22
|
+
description: 'A floating panel (dropdown, popover, menu, dialog) must reference a surface shadow token, or it renders as a flat rectangle on every surface except flat.',
|
|
23
|
+
category: 'tokens',
|
|
24
|
+
severity: 'warn',
|
|
25
|
+
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
|
+
check(el) {
|
|
27
|
+
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.
|
|
30
|
+
if (surface === 'flat')
|
|
31
|
+
return null;
|
|
32
|
+
const s = getComputedStyle(el);
|
|
33
|
+
if (s.boxShadow !== 'none')
|
|
34
|
+
return null;
|
|
35
|
+
// A panel with no fill isn't chrome — it's a positioning wrapper.
|
|
36
|
+
if (s.backgroundColor === 'transparent' || /,\s*0\)$/.test(s.backgroundColor))
|
|
37
|
+
return null;
|
|
38
|
+
const issue = {
|
|
39
|
+
auditId: 'surface/flat-chrome',
|
|
40
|
+
element: el,
|
|
41
|
+
message: `floating panel has box-shadow: none on the "${surface}" surface — it references no --era-shadow token and will render flat everywhere but flat`,
|
|
42
|
+
details: { surface, background: s.backgroundColor }
|
|
43
|
+
};
|
|
44
|
+
return issue;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TOL } from './_helpers.js';
|
|
2
|
+
import { BOUNDED_TIER_MAX, getGaps } from '../../measure.js';
|
|
3
|
+
/*
|
|
4
|
+
* The zero-gap bug, caught at the source.
|
|
5
|
+
*
|
|
6
|
+
* This is the copy button's original defect made mechanical: it shipped as
|
|
7
|
+
* `size="icon"` (an h-xs button) holding an `size-(--era-h-xs)` glyph, so
|
|
8
|
+
* (14 − 14) / 2 = 0 — the icon filled its own button edge to edge and read as a
|
|
9
|
+
* glyph jammed against its border. Every individual class was legal; the tier was
|
|
10
|
+
* legal; only the NESTING was wrong, which is why nothing but a rendered
|
|
11
|
+
* measurement could see it.
|
|
12
|
+
*
|
|
13
|
+
* The same shape recurs whenever an icon is sized at the tier of the control that
|
|
14
|
+
* holds it, so the check is simply: an icon inside a bounded control must have
|
|
15
|
+
* some breathing room.
|
|
16
|
+
*/
|
|
17
|
+
export const iconFlush = {
|
|
18
|
+
id: 'layout/icon-flush',
|
|
19
|
+
name: 'Icon flush against its control',
|
|
20
|
+
description: 'An icon inside a bounded control must leave a gap on every side. A zero gap means the glyph was sized at its container’s own tier and fills it edge to edge.',
|
|
21
|
+
category: 'layout',
|
|
22
|
+
severity: 'error',
|
|
23
|
+
selector: 'svg',
|
|
24
|
+
check(icon) {
|
|
25
|
+
const container = icon.parentElement;
|
|
26
|
+
if (!container)
|
|
27
|
+
return null;
|
|
28
|
+
const box = container.getBoundingClientRect();
|
|
29
|
+
// Bounded controls only — an icon sitting flush in an unbounded content
|
|
30
|
+
// area is a layout choice, not a tier error.
|
|
31
|
+
if (box.height === 0 || box.height > BOUNDED_TIER_MAX)
|
|
32
|
+
return null;
|
|
33
|
+
// The control has to actually be a control: a bare <span> wrapper around an
|
|
34
|
+
// icon legitimately hugs it (that is how an icon gets centred inside a
|
|
35
|
+
// button in the first place), so require chrome — a radius.
|
|
36
|
+
if (parseFloat(getComputedStyle(container).borderTopLeftRadius) <= 0)
|
|
37
|
+
return null;
|
|
38
|
+
const g = getGaps(icon, container);
|
|
39
|
+
const flushY = Math.abs(g.top) <= TOL && Math.abs(g.bottom) <= TOL;
|
|
40
|
+
const flushX = Math.abs(g.left) <= TOL && Math.abs(g.right) <= TOL;
|
|
41
|
+
if (!flushY && !flushX)
|
|
42
|
+
return null;
|
|
43
|
+
const issue = {
|
|
44
|
+
auditId: 'layout/icon-flush',
|
|
45
|
+
element: icon,
|
|
46
|
+
message: `icon fills its control (gaps T${g.top.toFixed(1)} R${g.right.toFixed(1)} B${g.bottom.toFixed(1)} L${g.left.toFixed(1)}) — size it a tier below its container`,
|
|
47
|
+
details: { ...g, containerHeight: box.height }
|
|
48
|
+
};
|
|
49
|
+
return issue;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { collapsedTextTrim } from './collapsed-text-trim.js';
|
|
1
2
|
import { concentricInset } from './concentric-inset.js';
|
|
3
|
+
import { flatChrome } from './flat-chrome.js';
|
|
4
|
+
import { iconFlush } from './icon-flush.js';
|
|
5
|
+
import { offAxisMotion } from './off-axis-motion.js';
|
|
6
|
+
import { offTierHeight } from './off-tier-height.js';
|
|
2
7
|
import { radiusConcentricity } from './radius-concentricity.js';
|
|
8
|
+
import { singleGlyphSquare } from './single-glyph-square.js';
|
|
3
9
|
import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
|
|
4
10
|
import { wallMatchesGap } from './wall-matches-gap.js';
|
|
5
|
-
export { concentricInset, radiusConcentricity, uniformSiblingGaps, wallMatchesGap };
|
|
11
|
+
export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { registry } from '../registry.js';
|
|
2
|
+
import { collapsedTextTrim } from './collapsed-text-trim.js';
|
|
2
3
|
import { concentricInset } from './concentric-inset.js';
|
|
4
|
+
import { flatChrome } from './flat-chrome.js';
|
|
5
|
+
import { iconFlush } from './icon-flush.js';
|
|
6
|
+
import { offAxisMotion } from './off-axis-motion.js';
|
|
7
|
+
import { offTierHeight } from './off-tier-height.js';
|
|
3
8
|
import { radiusConcentricity } from './radius-concentricity.js';
|
|
9
|
+
import { singleGlyphSquare } from './single-glyph-square.js';
|
|
4
10
|
import { uniformSiblingGaps } from './uniform-sibling-gaps.js';
|
|
5
11
|
import { wallMatchesGap } from './wall-matches-gap.js';
|
|
6
|
-
export { concentricInset, radiusConcentricity, uniformSiblingGaps, wallMatchesGap };
|
|
12
|
+
export { collapsedTextTrim, concentricInset, flatChrome, iconFlush, offAxisMotion, offTierHeight, radiusConcentricity, singleGlyphSquare, uniformSiblingGaps, wallMatchesGap };
|
|
7
13
|
// Auto-register built-ins. Consumers can still unregister or add their own.
|
|
14
|
+
//
|
|
15
|
+
// Two of these only tell the truth on a particular axis setting, because the bug
|
|
16
|
+
// they hunt is invisible on the default one:
|
|
17
|
+
// - motion/off-axis-timing → run at data-motion="instant" (at "normal" a
|
|
18
|
+
// hardcoded duration-150 coincidentally matches the token and hides).
|
|
19
|
+
// - surface/flat-chrome → run on a non-flat surface (on flat the shadow
|
|
20
|
+
// tokens legitimately resolve to `none`, so there is nothing to assert).
|
|
21
|
+
registry.register(collapsedTextTrim);
|
|
8
22
|
registry.register(concentricInset);
|
|
23
|
+
registry.register(flatChrome);
|
|
24
|
+
registry.register(iconFlush);
|
|
25
|
+
registry.register(offAxisMotion);
|
|
26
|
+
registry.register(offTierHeight);
|
|
9
27
|
registry.register(radiusConcentricity);
|
|
28
|
+
registry.register(singleGlyphSquare);
|
|
10
29
|
registry.register(uniformSiblingGaps);
|
|
11
30
|
registry.register(wallMatchesGap);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { eraDurationMs, timesMs } from './_helpers.js';
|
|
2
|
+
/*
|
|
3
|
+
* Motion must come off the motion axis, not out of thin air.
|
|
4
|
+
*
|
|
5
|
+
* --era-duration is the single knob behind data-motion (instant 0s / normal 150ms
|
|
6
|
+
* / extra 260ms). A component that hardcodes its timing — `duration-150`, a raw
|
|
7
|
+
* `transition: … 200ms`, or a Tailwind built-in like animate-pulse (which expands
|
|
8
|
+
* to a fixed 2s cubic-bezier that never reads the token) — looks fine at `normal`
|
|
9
|
+
* and is WRONG everywhere else: it keeps moving at `instant`, where the user (or
|
|
10
|
+
* prefers-reduced-motion) has asked for no motion at all.
|
|
11
|
+
*
|
|
12
|
+
* That last case is the point of running this audit at data-motion="instant": with
|
|
13
|
+
* the axis at 0s, ANY surviving duration is by definition off-axis. At `normal` a
|
|
14
|
+
* hardcoded `duration-150` coincidentally equals the token and hides. So:
|
|
15
|
+
*
|
|
16
|
+
* set data-motion="instant" → run this audit → every hit is a real violation.
|
|
17
|
+
*
|
|
18
|
+
* The one sanctioned exception is a spinner: an indeterminate progress indicator
|
|
19
|
+
* is allowed to keep spinning at every motion level, because a frozen spinner
|
|
20
|
+
* reads as a hung app.
|
|
21
|
+
*/
|
|
22
|
+
const SPINNER = /spin/i;
|
|
23
|
+
export const offAxisMotion = {
|
|
24
|
+
id: 'motion/off-axis-timing',
|
|
25
|
+
name: 'Timing comes from the motion axis',
|
|
26
|
+
description: 'Transitions and animations must derive their duration from --era-duration. Run with data-motion="instant": anything still animating there is hardcoded and cannot be turned off.',
|
|
27
|
+
category: 'tokens',
|
|
28
|
+
severity: 'warn',
|
|
29
|
+
selector: '*',
|
|
30
|
+
check(el) {
|
|
31
|
+
const s = getComputedStyle(el);
|
|
32
|
+
const expected = eraDurationMs(el);
|
|
33
|
+
const animating = timesMs(s.animationDuration).filter((d) => d > 0);
|
|
34
|
+
const transitioning = timesMs(s.transitionDuration).filter((d) => d > 0);
|
|
35
|
+
if (animating.length === 0 && transitioning.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
// A spinner is allowed to run at every motion level.
|
|
38
|
+
if (animating.length > 0 && SPINNER.test(s.animationName))
|
|
39
|
+
return null;
|
|
40
|
+
const offAxis = (d) => {
|
|
41
|
+
if (expected === 0)
|
|
42
|
+
return true; // instant: any motion at all is off-axis
|
|
43
|
+
// Components legitimately scale the token — calc(var(--era-duration) * N).
|
|
44
|
+
const ratio = d / expected;
|
|
45
|
+
return Math.abs(ratio - Math.round(ratio)) > 0.02 || ratio < 0.98;
|
|
46
|
+
};
|
|
47
|
+
const bad = [...animating, ...transitioning].filter(offAxis);
|
|
48
|
+
if (bad.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const issue = {
|
|
51
|
+
auditId: 'motion/off-axis-timing',
|
|
52
|
+
element: el,
|
|
53
|
+
message: expected === 0
|
|
54
|
+
? `still animating at motion="instant" (${bad.map((d) => `${d}ms`).join(', ')}) — hardcoded timing, ignores --era-duration`
|
|
55
|
+
: `timing ${bad.map((d) => `${d}ms`).join(', ')} is not derived from --era-duration (${expected}ms)`,
|
|
56
|
+
details: { expected, animating, transitioning, animationName: s.animationName }
|
|
57
|
+
};
|
|
58
|
+
return issue;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TOL, tiers } from './_helpers.js';
|
|
2
|
+
/*
|
|
3
|
+
* Every chrome-bearing control must land on a tier.
|
|
4
|
+
*
|
|
5
|
+
* The tier ladder (xs / xxs / sm / md / lg) is the whole basis of the even-gap and
|
|
6
|
+
* concentricity laws: those laws are stated in terms of (h_outer − h_inner)/2, so
|
|
7
|
+
* a control that sits BETWEEN tiers can never nest concentrically in anything —
|
|
8
|
+
* it silently poisons every gap around it. That is exactly how the date segments
|
|
9
|
+
* broke: 21px tall, which is no tier at all, so their 1.5px gap could not match any
|
|
10
|
+
* radius step. Putting them on the sm tier (22px) is what let the identity close.
|
|
11
|
+
*
|
|
12
|
+
* A grep cannot see this. `h-[21px]` would be caught by the hardcoded-px rule, but
|
|
13
|
+
* these segments had NO height class at all — 21px was whatever the line box
|
|
14
|
+
* happened to produce. Only the rendered box tells you.
|
|
15
|
+
*/
|
|
16
|
+
export const offTierHeight = {
|
|
17
|
+
id: 'layout/off-tier-height',
|
|
18
|
+
name: 'Control lands on a tier',
|
|
19
|
+
description: 'A control that paints chrome must be exactly one of the five tier heights. A between-tier control cannot nest concentrically in anything.',
|
|
20
|
+
category: 'layout',
|
|
21
|
+
severity: 'warn',
|
|
22
|
+
selector: 'button, input, [role="switch"], [role="checkbox"], [role="radio"], [data-segment]',
|
|
23
|
+
check(el) {
|
|
24
|
+
const s = getComputedStyle(el);
|
|
25
|
+
// Chromeless controls (Button's `link` variant is tinted text, no box) have
|
|
26
|
+
// no tier to be on — their height is their line box, by design.
|
|
27
|
+
if (parseFloat(s.borderTopLeftRadius) <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
// Multi-line controls (a grown textarea) aren't tier objects either.
|
|
30
|
+
if (s.whiteSpace === 'pre-wrap' || el.tagName === 'TEXTAREA')
|
|
31
|
+
return null;
|
|
32
|
+
const h = el.getBoundingClientRect().height;
|
|
33
|
+
if (h === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const ladder = tiers(el);
|
|
36
|
+
const nearest = ladder.reduce((a, b) => (Math.abs(b.px - h) < Math.abs(a.px - h) ? b : a));
|
|
37
|
+
if (Math.abs(nearest.px - h) <= TOL)
|
|
38
|
+
return null;
|
|
39
|
+
const issue = {
|
|
40
|
+
auditId: 'layout/off-tier-height',
|
|
41
|
+
element: el,
|
|
42
|
+
message: `height ${h.toFixed(1)}px is between tiers (nearest ${nearest.name} = ${nearest.px}px) — give it an h-(…) tier`,
|
|
43
|
+
details: { height: h, nearest: nearest.name, nearestPx: nearest.px, ladder }
|
|
44
|
+
};
|
|
45
|
+
return issue;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { TOL, paintsChrome } from './_helpers.js';
|
|
2
|
+
import { BOUNDED_TIER_MAX } from '../../measure.js';
|
|
3
|
+
/*
|
|
4
|
+
* A one-character pill must be a square, not a sliver.
|
|
5
|
+
*
|
|
6
|
+
* Rule 5: every element that can hold a single glyph carries a min-width equal to
|
|
7
|
+
* its tier height, so one character renders as a perfect square (or circle, once
|
|
8
|
+
* rounded) instead of a skinny vertical pill. Icon buttons take min-w-(--era-h-md);
|
|
9
|
+
* Badge, Chip and the KV value cell take min-w-(--era-h-xxs).
|
|
10
|
+
*
|
|
11
|
+
* Drop the min-width and nothing looks wrong until content happens to BE one
|
|
12
|
+
* character — a badge showing "1", a count that ticks from 10 down to 9 — and the
|
|
13
|
+
* pill visibly pinches. That makes it a data-dependent bug: the class string is
|
|
14
|
+
* identical in both cases, so only the rendered box can tell you, and only when
|
|
15
|
+
* the short content is actually on screen.
|
|
16
|
+
*
|
|
17
|
+
* Chrome-bearing elements only: a bare one-character text span (the "/" literal in
|
|
18
|
+
* a date field) has no pill to pinch.
|
|
19
|
+
*/
|
|
20
|
+
export const singleGlyphSquare = {
|
|
21
|
+
id: 'layout/single-glyph-square',
|
|
22
|
+
name: 'Single-glyph pill stays square',
|
|
23
|
+
description: 'An element holding one character must be at least as wide as it is tall (min-w = its tier height), or it renders as a skinny pill instead of a square.',
|
|
24
|
+
category: 'layout',
|
|
25
|
+
severity: 'warn',
|
|
26
|
+
selector: '*',
|
|
27
|
+
check(el) {
|
|
28
|
+
const text = el.textContent?.trim() ?? '';
|
|
29
|
+
if (text.length !== 1)
|
|
30
|
+
return null;
|
|
31
|
+
// Only leaf-ish elements — a wrapper whose single child holds the glyph is
|
|
32
|
+
// not the thing that needs the min-width.
|
|
33
|
+
if (el.childElementCount > 0)
|
|
34
|
+
return null;
|
|
35
|
+
if (!paintsChrome(el))
|
|
36
|
+
return null;
|
|
37
|
+
const r = el.getBoundingClientRect();
|
|
38
|
+
if (r.height === 0 || r.height > BOUNDED_TIER_MAX)
|
|
39
|
+
return null;
|
|
40
|
+
if (r.width >= r.height - TOL)
|
|
41
|
+
return null;
|
|
42
|
+
const issue = {
|
|
43
|
+
auditId: 'layout/single-glyph-square',
|
|
44
|
+
element: el,
|
|
45
|
+
message: `one-glyph pill is ${r.width.toFixed(1)}×${r.height.toFixed(1)} — add min-w-(--era-h-…) matching its tier so it squares up`,
|
|
46
|
+
details: { width: r.width, height: r.height, text }
|
|
47
|
+
};
|
|
48
|
+
return issue;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** What one component looks like, on the axes, at one setting. */
|
|
2
|
+
export interface AxisSignature {
|
|
3
|
+
slug: string;
|
|
4
|
+
/** Unique non-zero transition/animation durations in the subtree, in ms. */
|
|
5
|
+
durations: number[];
|
|
6
|
+
/** Names of running keyframe animations (a spinner is allowed to keep spinning). */
|
|
7
|
+
animations: string[];
|
|
8
|
+
/** Unique painted box-shadows — the surface system's whole output. */
|
|
9
|
+
shadows: string[];
|
|
10
|
+
/** The font actually resolved for the component's text. */
|
|
11
|
+
fontFamily: string;
|
|
12
|
+
/** Elements whose text is being cut off — the font-swap failure mode. */
|
|
13
|
+
clipped: string[];
|
|
14
|
+
/** Interactive controls, for the hover-state sweep the harness drives. */
|
|
15
|
+
controls: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function captureSignatures(root?: ParentNode): AxisSignature[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Axis signatures — the fingerprint a component leaves at one axis setting.
|
|
3
|
+
*
|
|
4
|
+
* Every audit in ./audit answers a question about a SINGLE render: is this gap
|
|
5
|
+
* even, is this radius concentric, does this panel have a shadow. But the most
|
|
6
|
+
* important promises era makes are about the axes themselves, and they are
|
|
7
|
+
* comparative:
|
|
8
|
+
*
|
|
9
|
+
* MOTION does this component actually respond to instant / normal / extra —
|
|
10
|
+
* freezing at one, moving at the next, and moving MORE at the third?
|
|
11
|
+
* SURFACE does its chrome actually change between flat / glass / bevel /
|
|
12
|
+
* cartoon, or does it render the same rectangle on all four?
|
|
13
|
+
* FONT does it survive mono / sans / serif without clipping its own text?
|
|
14
|
+
*
|
|
15
|
+
* None of those can be answered by looking at one DOM. A component that hardcodes
|
|
16
|
+
* `duration-150` looks perfectly correct at data-motion="normal" — it is only
|
|
17
|
+
* wrong RELATIVE to the other two levels, where it fails to change. So the check
|
|
18
|
+
* has to be: render at each level, fingerprint what should have moved, and diff.
|
|
19
|
+
*
|
|
20
|
+
* This module captures the fingerprint. The comparison lives in the harness that
|
|
21
|
+
* drives the renders (tests/visual/axis-conformance.spec.ts), because only it can
|
|
22
|
+
* visit more than one axis at a time.
|
|
23
|
+
*/
|
|
24
|
+
const num = (v) => v
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((s) => s.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((s) => (s.endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000))
|
|
29
|
+
.filter((n) => !Number.isNaN(n) && n > 0);
|
|
30
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
31
|
+
/**
|
|
32
|
+
* Is this element's text being cut off by its own box?
|
|
33
|
+
*
|
|
34
|
+
* The font axis is the one that breaks silently: a serif or sans face is WIDER
|
|
35
|
+
* than the mono default at the same size, so a label that fits in mono can
|
|
36
|
+
* overflow its control in serif — and because the control clips, the text simply
|
|
37
|
+
* disappears rather than reflowing. Nothing in a single-font render can predict
|
|
38
|
+
* that; you have to swap the font and look.
|
|
39
|
+
*/
|
|
40
|
+
function isClipped(el) {
|
|
41
|
+
const s = getComputedStyle(el);
|
|
42
|
+
if (!/hidden|clip/.test(s.overflowX))
|
|
43
|
+
return false;
|
|
44
|
+
if (!el.textContent?.trim())
|
|
45
|
+
return false;
|
|
46
|
+
// A visually-hidden element (sr-only) is a 1px box with its overflow clipped —
|
|
47
|
+
// that IS the technique, not a defect. It reports a wildly overflowing
|
|
48
|
+
// scrollWidth by construction (the calendar's screen-reader heading measured
|
|
49
|
+
// 144px of text in a 1px box), so exclude the degenerate boxes.
|
|
50
|
+
if (el.clientWidth <= 1 || el.clientHeight <= 1)
|
|
51
|
+
return false;
|
|
52
|
+
// 1px of slack: sub-pixel text metrics routinely round a hair over.
|
|
53
|
+
return el.scrollWidth > el.clientWidth + 1;
|
|
54
|
+
}
|
|
55
|
+
export function captureSignatures(root = document) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const host of root.querySelectorAll('[data-specimen]')) {
|
|
58
|
+
const slug = host.getAttribute('data-specimen') ?? '';
|
|
59
|
+
const durations = [];
|
|
60
|
+
const animations = [];
|
|
61
|
+
const shadows = [];
|
|
62
|
+
const clipped = [];
|
|
63
|
+
let controls = 0;
|
|
64
|
+
for (const el of host.querySelectorAll('*')) {
|
|
65
|
+
const s = getComputedStyle(el);
|
|
66
|
+
durations.push(...num(s.transitionDuration), ...num(s.animationDuration));
|
|
67
|
+
if (s.animationName && s.animationName !== 'none')
|
|
68
|
+
animations.push(s.animationName);
|
|
69
|
+
if (s.boxShadow && s.boxShadow !== 'none')
|
|
70
|
+
shadows.push(s.boxShadow);
|
|
71
|
+
if (isClipped(el))
|
|
72
|
+
clipped.push(`${el.tagName.toLowerCase()}"${el.textContent?.trim().slice(0, 24)}"`);
|
|
73
|
+
if (el.matches('button, input, [role="switch"], [role="checkbox"], a[href]'))
|
|
74
|
+
controls++;
|
|
75
|
+
}
|
|
76
|
+
out.push({
|
|
77
|
+
slug,
|
|
78
|
+
durations: uniq(durations).sort((a, b) => a - b),
|
|
79
|
+
animations: uniq(animations).sort(),
|
|
80
|
+
shadows: uniq(shadows).sort(),
|
|
81
|
+
fontFamily: getComputedStyle(host).fontFamily,
|
|
82
|
+
clipped: uniq(clipped),
|
|
83
|
+
controls
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
package/dist/dev/index.d.ts
CHANGED
package/dist/dev/index.js
CHANGED