diff --git a/.changeset/tiny-element-colors.md b/.changeset/tiny-element-colors.md new file mode 100644 index 0000000..6a5c553 --- /dev/null +++ b/.changeset/tiny-element-colors.md @@ -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. diff --git a/src/native/ax-helper.swift b/src/native/ax-helper.swift index 8351c92..94dcd81 100644 --- a/src/native/ax-helper.swift +++ b/src/native/ax-helper.swift @@ -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) } @@ -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 { diff --git a/test/ax.test.ts b/test/ax.test.ts index e25b239..31b7839 100644 --- a/test/ax.test.ts +++ b/test/ax.test.ts @@ -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 @@ -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/); });