Skip to content

Commit c21bfd9

Browse files
committed
fix(components,e2e): stop long titles overhanging their card, de-flake the themed axe scan
Three cards overhung with real content while the demo fixtures fit. The angel number hero put its text column beside the numeral as a flex item with min-width auto, so the live title for 1111, Spiritual Awakening, Manifestation, and Alignment, pushed the hero 5px past the card on a phone. It surfaced on a WordPress page, not here, because the captured fixture title is shorter. Choghadiya was the same class one level down: a bare 1fr track keeps a min-content floor, so a long muhurta name widened the tile and dragged the header and the grid out with it. minmax(0, 1fr) plus a wrapping name column fixes the cause; the header was only where it showed. The layout gate now also runs with the title-ish fields inflated, because fixtures are whatever a sample call happened to return and a field that is short in the capture can be long in production. That is what caught the choghadiya case. The themed axe scan was failing about one run in three on WebKit and passing on retry, which read as noise and was not. data-theme is set synchronously but the token cascade reaches the shadow trees a frame later, so axe sampled dark text on a still-light tile and reported contrast 1.04. setTheme now polls the exact invariant axe is about to measure, on the element it measures it on. 78 e2e green across chromium, firefox and webkit with no retries.
1 parent 359013c commit c21bfd9

5 files changed

Lines changed: 167 additions & 3 deletions

File tree

packages/ui/src/components/angel-number-card.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export class RoxyAngelNumberCard extends RoxyDataElement<GetAngelNumberResponse>
2828
align-items: center;
2929
gap: var(--roxy-space-md, 1rem);
3030
}
31+
/* The text column beside the numeral must be allowed to WRAP. As a flex
32+
* item it keeps min-width: auto, so a long title pushed the whole hero
33+
* past the card edge instead of breaking: the live response for 1111 is
34+
* "Spiritual Awakening, Manifestation, and Alignment", which overhung by
35+
* 5px on a phone while the shorter demo title fit and hid it. */
36+
.hero > div {
37+
min-width: 0;
38+
}
3139
.numeral {
3240
font-size: 3rem;
3341
line-height: 1;

packages/ui/src/components/angel-number-lookup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export class RoxyAngelNumberLookup extends RoxyDataElement<AnalyzeNumberSequence
3434
align-items: center;
3535
gap: var(--roxy-space-md, 1rem);
3636
}
37+
/* The text column beside the numeral must be allowed to WRAP. As a flex
38+
* item it keeps min-width: auto, so a long title pushed the whole hero
39+
* past the card edge instead of breaking: the live response for 1111 is
40+
* "Spiritual Awakening, Manifestation, and Alignment", which overhung by
41+
* 5px on a phone while the shorter demo title fit and hid it. */
42+
.hero > div {
43+
min-width: 0;
44+
}
3745
.numeral {
3846
font-size: 3rem;
3947
line-height: 1;

packages/ui/src/components/choghadiya-grid.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ export class RoxyChoghadiyaGrid extends RoxyDataElement<GetChoghadiyaResponse> {
6565
}
6666
.cho-tile {
6767
display: grid;
68-
grid-template-columns: 1fr auto;
68+
/* minmax(0, 1fr), never a bare 1fr: a 1fr track keeps an automatic
69+
* min-content floor, so a long muhurta name widened the tile past the
70+
* card instead of wrapping, and pushed the time column out with it. */
71+
grid-template-columns: minmax(0, 1fr) auto;
6972
align-items: center;
7073
gap: 0.25em 0.75em;
7174
padding: 0.55em 0.85em;
@@ -105,6 +108,8 @@ export class RoxyChoghadiyaGrid extends RoxyDataElement<GetChoghadiyaResponse> {
105108
font-size: var(--roxy-text-base, 1rem);
106109
font-weight: var(--roxy-weight-bold, 600);
107110
grid-column: 1;
111+
min-width: 0;
112+
overflow-wrap: anywhere;
108113
}
109114
.tile-time {
110115
font-size: var(--roxy-text-xs, 0.75rem);

packages/ui/tests/e2e/forms.spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,62 @@ async function scan(page: Page): Promise<void> {
5858
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
5959
}
6060

61+
/**
62+
* Switch theme and wait until the components have actually repainted in it.
63+
*
64+
* @remarks
65+
* `applyTheme` sets `data-theme` on `<html>` synchronously, so a bare click looks
66+
* settled, but WebKit can propagate the token cascade into the shadow trees a frame
67+
* later. axe then samples dark-theme text (`#fafafa`) against a background still
68+
* resolving as `#ffffff`, reports contrast 1.04 on every sign tile, and the run
69+
* fails about one time in three. Poll the value axe actually reads rather than
70+
* sleeping on a guess.
71+
*
72+
* `containerId` is the subtree the caller is about to scan. The two suites mount
73+
* different hosts, and polling one that is not on the page never settles.
74+
*
75+
* The probe asserts the invariant axe is about to measure, on the element it
76+
* measures it on: a tile label and the surface behind it must sit on OPPOSITE
77+
* sides of mid luminance. Watching the light-DOM host instead is not enough,
78+
* because its inherited `color` flips a frame before the shadow tiles repaint
79+
* their background, which is the half-applied state that produced `#f2e4df` text
80+
* on a still-light `#fbf6f3` tile under the practitioner preset.
81+
*/
82+
async function setTheme(
83+
page: Page,
84+
theme: 'light' | 'dark',
85+
containerId: string,
86+
): Promise<void> {
87+
await page.locator(`#theme-${theme}`).click();
88+
await page.waitForFunction(
89+
([t, id]) => {
90+
if (document.documentElement.dataset.theme !== t) return false;
91+
const form = document
92+
.getElementById(id)
93+
?.querySelector('roxy-endpoint-form');
94+
const root = (form as { shadowRoot?: ShadowRoot | null } | null)
95+
?.shadowRoot;
96+
const label = root?.querySelector('.tile-label');
97+
const tile = label?.closest('.tile');
98+
if (!label || !tile) return false;
99+
100+
const lum = (c: string): number | null => {
101+
const n = c
102+
.match(/[\d.]+/g)
103+
?.slice(0, 3)
104+
.map(Number);
105+
return n && n.length === 3 ? (n[0]! + n[1]! + n[2]!) / 3 : null;
106+
};
107+
const fg = lum(getComputedStyle(label).color);
108+
const bg = lum(getComputedStyle(tile).backgroundColor);
109+
if (fg === null || bg === null) return false;
110+
// Settled means readable: light text on a dark tile, or the reverse.
111+
return Math.abs(fg - bg) > 60 && (t === 'dark' ? fg > bg : fg < bg);
112+
},
113+
[theme, containerId],
114+
);
115+
}
116+
61117
test.describe('self-fetch input a11y', () => {
62118
test('the tile picker, toggle, location, and group cards render their ARIA roles', async ({
63119
page,
@@ -93,7 +149,7 @@ test.describe('self-fetch input a11y', () => {
93149

94150
test('passes axe on dark theme', async ({ page }) => {
95151
await mountForms(page);
96-
await page.locator('#theme-dark').click();
152+
await setTheme(page, 'dark', 'roxy-e2e-forms');
97153
await scan(page);
98154
});
99155
});
@@ -154,7 +210,7 @@ test.describe('practitioner theme preset', () => {
154210
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
155211
};
156212
await scanThemed();
157-
await page.locator('#theme-dark').click();
213+
await setTheme(page, 'dark', 'roxy-practitioner');
158214
await scanThemed();
159215
});
160216
});

packages/ui/tests/e2e/layout.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,90 @@ for (const vp of WIDTHS) {
9696
expect(issues, `${vp.label}: ${issues.join('\n')}`).toEqual([]);
9797
});
9898
}
99+
100+
/**
101+
* The same overflow check, but against LONG content rather than the demo fixtures.
102+
*
103+
* @remarks
104+
* The fixtures are whatever a sample call happened to return, so a field can be short in the capture and long in production. `roxy-angel-number-card` shipped a hero that overhung its card by 5px for number 1111, whose live title is "Spiritual Awakening, Manifestation, and Alignment", while the shorter captured title fit and the gate stayed green. It only surfaced on a real WordPress page.
105+
*
106+
* Rewriting every fixture is not the fix. Inflating the title-ish fields in place covers the whole library in one pass and keeps the fixtures honest about what the API actually returned.
107+
*/
108+
test('no section overflows when its text fields are long', async ({ page }) => {
109+
await page.setViewportSize({ width: 375, height: 900 });
110+
await page.goto('/');
111+
await page.waitForLoadState('networkidle');
112+
await page.waitForTimeout(800);
113+
114+
const issues = await page.evaluate(async () => {
115+
const LONG =
116+
'Spiritual Awakening, Manifestation, and Alignment With Higher Purpose';
117+
const TEXTY = /^(title|name|phase|label|heading)$/i;
118+
const bloat = (v: unknown, depth = 0): unknown => {
119+
if (depth > 3 || v === null || typeof v !== 'object') return v;
120+
if (Array.isArray(v)) return v.map((x) => bloat(x, depth + 1));
121+
const out: Record<string, unknown> = {};
122+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
123+
out[k] =
124+
typeof val === 'string' && TEXTY.test(k) && val.length < 30
125+
? LONG
126+
: bloat(val, depth + 1);
127+
}
128+
return out;
129+
};
130+
131+
type Host = HTMLElement & {
132+
shadowRoot?: ShadowRoot | null;
133+
data?: unknown;
134+
};
135+
const hosts = ([...document.querySelectorAll('*')] as Host[]).filter(
136+
(e) => e.tagName.startsWith('ROXY-') && e.shadowRoot && e.data,
137+
);
138+
for (const h of hosts) {
139+
try {
140+
h.data = bloat(structuredClone(h.data));
141+
} catch {
142+
// A fixture holding something structuredClone cannot copy stays as-is.
143+
}
144+
}
145+
await new Promise((r) => setTimeout(r, 1200));
146+
147+
const found: string[] = [];
148+
for (const h of hosts) {
149+
const hostRect = h.getBoundingClientRect();
150+
if (hostRect.width === 0) continue;
151+
const walk = (root: ParentNode) => {
152+
for (const el of root.querySelectorAll('*')) {
153+
const e = el as HTMLElement & { shadowRoot?: ShadowRoot | null };
154+
if (e.shadowRoot) walk(e.shadowRoot);
155+
const r = e.getBoundingClientRect();
156+
if (r.width === 0) continue;
157+
if (r.right <= hostRect.right + 2) continue;
158+
let p: HTMLElement | null = e.parentElement;
159+
let scrollable = false;
160+
while (p && p !== (root as unknown as HTMLElement)) {
161+
const ox = getComputedStyle(p).overflowX;
162+
if (ox === 'auto' || ox === 'scroll') {
163+
scrollable = true;
164+
break;
165+
}
166+
p = p.parentElement;
167+
}
168+
if (!scrollable) {
169+
found.push(
170+
`${h.tagName.toLowerCase()}: ${e.tagName.toLowerCase()}.${e.className || '?'} overflows by ${Math.round(r.right - hostRect.right)}px`,
171+
);
172+
break;
173+
}
174+
}
175+
};
176+
walk(h.shadowRoot as ShadowRoot);
177+
}
178+
return found;
179+
});
180+
181+
// SVG chart labels are excluded: a chart cell is sized for a planet name, and
182+
// a 68 character string in that field is not a case the API can produce.
183+
const real = issues.filter((i) => !i.includes(': text.'));
184+
expect(real, real.join('\n')).toEqual([]);
185+
});

0 commit comments

Comments
 (0)