Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/tiny-element-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"macos-vision": patch
---

fix: no invented colours for elements too small to sample

A 1×1 pt element — the screen-reader anchors real pages are full of, "Skip to
content" and the like — has no interior left once the edge inset is applied, so
its "fill" was sampled from outside it and came back solid black. Seven such
nodes in a 439-node Safari window all reported `bg: #000000`, which reads to a
consumer as a black element that is not there.

Colour sampling now requires a box with a real interior, and the inset can no
longer exceed the box. Elements that are thin in one dimension only — a 5 pt
scrollbar or split handle — keep their colours.
8 changes: 6 additions & 2 deletions src/native/ax-helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ final class Pixels {
let pw = Int(b[2] * scale), ph = Int(b[3] * scale)
let x0 = max(0, px), y0 = max(0, py)
let x1 = min(w - 1, px + pw), y1 = min(h - 1, py + ph)
if x1 - x0 < 2 || y1 - y0 < 2 { return nil }
// Below this there is no interior left once the edge inset is applied,
// and the "fill" ends up sampled from outside the element: 1x1pt
// screen-reader anchors were coming back solid black.
if x1 - x0 < 8 || y1 - y0 < 8 { return nil }
return (x0, y0, x1, y1)
}

Expand All @@ -290,7 +293,8 @@ final class Pixels {
/// it is an estimate, not a measured CSS value.
func style(_ b: Box) -> Style? {
guard let (x0, y0, x1, y1) = toPixels(b) else { return nil }
let inset = max(2, min((x1 - x0) / 4, (y1 - y0) / 4))
// Never let the inset eat the whole box.
let inset = max(1, min((x1 - x0) / 4, (y1 - y0) / 4, min(x1 - x0, y1 - y0) / 2 - 1))
let fill = dominant(x0 + inset, y0 + inset, x1 - inset, y1 - inset)
let edge = dominant(x0, y0, x1, min(y0 + 1, y1))
func near(_ a: (Int, Int, Int), _ c: (Int, Int, Int)) -> Bool {
Expand Down
24 changes: 23 additions & 1 deletion test/ax.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { axTree, listWindows } from '../src/index.js';
import { axTree, captureScreen, listWindows } from '../src/index.js';

// Which app to walk is decided at run time. Hard-coding one made this suite pass
// locally and fail on CI, where no Finder window exists — and worse, it passed
Expand Down Expand Up @@ -119,6 +119,28 @@ describe('axTree()', () => {
T
);

it(
'does not invent colours for elements too small to sample',
withApp(async (target) => {
// A 1x1pt screen-reader anchor has no interior once the edge inset is
// applied, and was coming back solid black — a colour read from outside it.
const shot = await captureScreen({ app: target });
const { nodes } = await axTree({
app: target,
maxElements: 400,
colors: { path: shot.path, frame: shot.frame },
});
for (const n of nodes) {
if (n.box[2] < 3 || n.box[3] < 3) expect(n.style).toBeUndefined();
}
// and the fix must not have suppressed everything
if (nodes.length > 20) {
expect(nodes.filter((n) => n.style).length).toBeGreaterThan(nodes.length / 2);
}
}),
T
);

it('rejects a call with neither app nor pid', async () => {
await expect(axTree({})).rejects.toThrow(/app or pid/);
});
Expand Down
Loading