Skip to content

Commit d2fa2bd

Browse files
committed
feat(agent): add Tool, ToolApproval, ToolGroup, Reasoning and Activity
Tool renders every ToolTraceStatus with an icon and a word, and its input and output come through a render prop. ToolApproval asks a person to approve once, approve always or deny, and renders a settled decision from the approval record itself. ToolGroup shows calls that ran at the same time, Reasoning shows thinking with its duration, and Activity folds both into one summary line showing the latest ten steps. The stories axe sweep now measures text contrast itself wherever axe cannot: it hit-tests each text box to find the ground actually under it, composites colour and opacity layers, reads gradient and fill colours, and reports anything it cannot resolve as unmeasured rather than passing it. Browser fixtures pin the cases that must never pass.
1 parent fb4e75f commit d2fa2bd

23 files changed

Lines changed: 4322 additions & 3 deletions

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ bun run check
1212
Contributors sign the [CLA](CLA.md) on their first PR; the CLA bot explains how.
1313

1414
`bun run test:e2e` checks the built package: every `exports` target exists and the
15-
root entry loads under Node with every optional peer blocked, then packs the
15+
root entry loads under Node with every optional peer blocked, and unit-tests the
16+
contrast maths the stories sweep uses (`e2e/contrast.ts`), then packs the
1617
package into a Vite host and runs the Playwright specs in `e2e/` against it. Each
1718
scenario is a route in `e2e/fixtures.tsx`. The same run builds the Storybook stories
1819
and fails on any serious or critical axe violation in any story. Run `bun run build` first, and

‎e2e/contrast-dom.ts‎

Lines changed: 438 additions & 0 deletions
Large diffs are not rendered by default.

‎e2e/contrast-fixtures.spec.ts‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import AxeBuilder from "@axe-core/playwright";
2+
import { expect, test, type Page } from "@playwright/test";
3+
4+
import { contrastSamples } from "./contrast-dom.js";
5+
import { judgeContrast, type ContrastVerdict } from "./contrast.js";
6+
7+
// The stories sweep's contrast fallback on hand-built pages: each fixture is
8+
// text axe leaves "incomplete", and the fallback must not vouch for any of it
9+
// that renders below its floor.
10+
11+
const DARK =
12+
"data:image/svg+xml;utf8," +
13+
encodeURIComponent(
14+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#111"/></svg>',
15+
);
16+
17+
const page = (body: string) =>
18+
`<!doctype html><html><body style="margin:0;font:14px/20px sans-serif;background:#fff">${body}</body></html>`;
19+
20+
// Lines 2-3 overflow a white card onto black: #555 on #000 is ~2.8:1.
21+
const SPILL = `<div style="background:#000;padding:40px"><div style="background:#fff;height:22px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Spills onto black<br>Also on black</p></div></div>`;
22+
23+
// The same markup where the sampler has to scroll to it first.
24+
const placed = (markup: string) =>
25+
[
26+
["in view", page(markup)],
27+
[
28+
"below the fold",
29+
page(
30+
`<div style="height:1500px"></div>${markup}<div style="height:400px"></div>`,
31+
),
32+
],
33+
[
34+
"inside nested scrollers",
35+
page(
36+
`<div style="height:300px;overflow:auto"><div style="height:500px"></div><div style="height:200px;overflow:auto"><div style="height:600px"></div>${markup}</div><div style="height:400px"></div></div>`,
37+
),
38+
],
39+
] as const;
40+
41+
// A chip with its own white ground, rotated, whose later lines spill onto
42+
// black: client rects bound the turned chip from outside.
43+
const chip = (degrees: number, align: string) =>
44+
page(
45+
`<div style="background:#000;padding:200px 80px"><p id="t" style="margin:0;background:#fff;height:22px;width:500px;text-align:${align};color:#555;transform:rotate(${degrees}deg)">On the chip<br>Spills onto black<br>Also on black</p></div>`,
46+
);
47+
48+
/** The fallback's verdict on #t, and whether axe left #t incomplete. */
49+
async function verdictFor(
50+
tab: Page,
51+
html: string,
52+
): Promise<{ incomplete: boolean; verdict: ContrastVerdict }> {
53+
await tab.setContent(html);
54+
const { incomplete } = await new AxeBuilder({ page: tab })
55+
.withRules(["color-contrast"])
56+
.analyze();
57+
const [sample = null] = await contrastSamples(tab, ["#t"], []);
58+
return {
59+
incomplete: incomplete
60+
.flatMap((result) => result.nodes)
61+
.some((node) => node.target[0] === "#t"),
62+
verdict: judgeContrast(sample),
63+
};
64+
}
65+
66+
const NOT_VOUCHED = [
67+
...placed(SPILL).map(
68+
([where, html]) =>
69+
[
70+
`text overflowing its card onto another surface, ${where}`,
71+
html,
72+
] as const,
73+
),
74+
["a rotated chip's text spilling off it (10deg, left)", chip(10, "left")],
75+
["a rotated chip's text spilling off it (-10deg, right)", chip(-10, "right")],
76+
// The glyphs are painted by the #ddd-#eee gradient, not by color.
77+
[
78+
"gradient text through -webkit-text-fill-color",
79+
page(
80+
`<p id="t" style="color:#000;-webkit-text-fill-color:transparent;background-image:linear-gradient(90deg,#ddd,#eee);background-clip:text;-webkit-background-clip:text;display:inline-block">Generating 2 images</p>`,
81+
),
82+
],
83+
[
84+
"a pointer-events:none wash over an image",
85+
page(
86+
`<div style="position:relative;width:300px;height:60px"><img src="${DARK}" style="position:absolute;inset:0;width:100%;height:100%"><div style="position:absolute;inset:0;background:rgba(255,255,255,.5);pointer-events:none"></div><span id="t" style="position:relative;color:#767676">Caption</span></div>`,
87+
),
88+
],
89+
[
90+
"an ancestor ::before painting an image",
91+
page(
92+
`<style>.c{position:relative;width:300px;padding:10px}.c::before{content:"";position:absolute;inset:0;background:url('${DARK}');pointer-events:none}</style><div class="c"><span id="t" style="position:relative;color:#767676">Caption</span></div>`,
93+
),
94+
],
95+
] as const;
96+
97+
for (const [name, html] of NOT_VOUCHED)
98+
test(`the contrast fallback does not pass ${name}`, async ({ page: tab }) => {
99+
const { incomplete, verdict } = await verdictFor(tab, html);
100+
expect(incomplete).toBe(true);
101+
expect(verdict).not.toMatchObject({ kind: "measured", pass: true });
102+
});
103+
104+
// Controls: the same shapes with legible text still measure, so the
105+
// fixtures above fail for the reason they name.
106+
const VOUCHED = [
107+
[
108+
"text that stays on its card",
109+
page(
110+
`<div style="background:#000;padding:40px"><div style="background:#fff;height:62px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Still on the card<br>Also on the card</p></div></div>`,
111+
),
112+
],
113+
[
114+
"dark gradient text through -webkit-text-fill-color",
115+
page(
116+
`<p id="t" style="color:#fff;-webkit-text-fill-color:transparent;background-image:linear-gradient(90deg,#333,#000);background-clip:text;-webkit-background-clip:text;display:inline-block">Generating 2 images</p>`,
117+
),
118+
],
119+
[
120+
"text under an empty stretched button",
121+
page(
122+
`<div style="position:relative;width:200px;background:#fff"><span id="t" style="color:#555">quarter-plan.pdf</span><button aria-label="Open" style="position:absolute;inset:0;background:none;border:0"></button></div>`,
123+
),
124+
],
125+
] as const;
126+
127+
for (const [name, html] of VOUCHED)
128+
test(`the contrast fallback measures ${name}`, async ({ page: tab }) => {
129+
const { verdict } = await verdictFor(tab, html);
130+
expect(verdict).toMatchObject({ kind: "measured", pass: true });
131+
});
132+
133+
// The verdict does not depend on where the text starts: every geometry read
134+
// happens where the sampler scrolled it to.
135+
const SAME_ANYWHERE = [
136+
["text overflowing its card", SPILL],
137+
[
138+
"text on its own faint wash",
139+
`<div style="background:#fff;padding:20px"><span id="t" style="color:#767676;background:rgba(0,0,0,.02)">Caption on a wash</span></div>`,
140+
],
141+
[
142+
"text that stays on its card",
143+
`<div style="background:#000;padding:40px"><div style="background:#fff;height:62px;width:320px"><p id="t" style="margin:0;color:#555">On the card<br>Still on the card<br>Also on the card</p></div></div>`,
144+
],
145+
] as const;
146+
147+
for (const [name, markup] of SAME_ANYWHERE)
148+
test(`the contrast fallback judges ${name} the same wherever it starts`, async ({
149+
page: tab,
150+
}) => {
151+
const verdicts: ContrastVerdict[] = [];
152+
for (const [, html] of placed(markup))
153+
verdicts.push((await verdictFor(tab, html)).verdict);
154+
const [inView] = verdicts;
155+
expect(inView?.kind).toBe("measured");
156+
for (const verdict of verdicts) expect(verdict).toEqual(inView);
157+
});

0 commit comments

Comments
 (0)