-
Notifications
You must be signed in to change notification settings - Fork 102
feat(components): mine layout deviations from visual repetition #408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
wibus-wee
wants to merge
2
commits into
geometry-geometric-row-discovery
from
feat/capture-mobile-viewports
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # `components/src/lib/geometry-discovery` — heuristic layout discovery | ||
|
|
||
| `CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Package | ||
| [AGENTS.md](../../../AGENTS.md) and [src/lib/AGENTS.md](../AGENTS.md) apply. | ||
|
|
||
| The authored path — `geometry-contracts.json`, its compiled contracts and the ratchet in | ||
| [tests/e2e](../../../tests/e2e/AGENTS.md) — only finds what a reviewer already wrote down, | ||
| and names its members by DOM shape. This directory is the other half: expectations are | ||
| MINED from what the product repeatedly renders, and nobody writes the number down. | ||
|
|
||
| Not yet wired to the capture run or the gate. It is a report, and it stays one until its | ||
| findings have been triaged once: a recall-first pass that blocks CI has exactly one natural | ||
| remedy, raising its thresholds, which destroys the recall it exists for. | ||
|
|
||
| ## Grouping is visual. Never structural. | ||
|
|
||
| Atoms are grouped by what RENDERS alike — the geometry-derived primitive kind, folded where | ||
| the difference is not painted (`link`/`button`, `numeric-text`/`text`), and quantised height, | ||
| never content-sized width. Never key a group on row family, role, accessible name, or DOM | ||
| ancestry. | ||
|
|
||
| The reason is not purity. A layout defect almost always comes from two code paths rendering | ||
| one visual thing differently — the sidebar's 26px tree indent slot and the mobile screen's | ||
| 32px one are separate constants in separate files. So the defect CORRELATES with the | ||
| structural difference, and a structural key files the two paths into different groups and | ||
| never compares them: the more real the bug, the more reliably it is hidden. The reader | ||
| perceives a column because pixels line up, not because elements share a tag. | ||
|
|
||
| `VisualAtom.id` exists to name a finding across runs and must stay out of grouping. The | ||
| moment identity decides who is compared with whom, that blindness is back. | ||
|
|
||
| ## Levels grow to an ANCHOR, not to a neighbour | ||
|
|
||
| A coordinate joins a level by distance to the level's anchor. Single linkage would let a | ||
| run of intermediate values walk one level into the next and merge two indentation depths | ||
| into one expectation — the merged level then reads as internally perfect and the deviation | ||
| disappears. Same failure the geometric row band avoids on Y. | ||
|
|
||
| ## Recall is the bias, and ranking is not filtering | ||
|
|
||
| Nothing is dropped for looking weak. Candidates carry a `score` and are sorted; no | ||
| threshold removes one. A legitimate indent ladder therefore comes back as deviations too, | ||
| because without being told which level was intended it has to — it ranks low since `score` | ||
| falls as a level's own support rises, so a value two boxes share outranks one forty share. | ||
|
|
||
| A missed misalignment is invisible forever; a false one costs a triage glance. Any tie | ||
| breaks towards reporting more. Series orientation is measured rather than declared, and | ||
| only the axis perpendicular to the run carries expectations worth mining. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| AGENTS.md |
311 changes: 311 additions & 0 deletions
311
packages/components/src/lib/geometry-discovery/visual-repetition.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| /** | ||
| * Heuristic discovery of layout deviations from visual repetition alone. | ||
| * | ||
| * The authored path (`geometry-contracts.json`) can only find what someone | ||
| * already wrote down, and it names its members by DOM shape — role, accessible | ||
| * name, row family. That is the wrong ground truth twice over. A layout bug | ||
| * almost always comes from two code paths rendering the same visual thing | ||
| * differently, so the defect CORRELATES with the DOM difference: grouping by | ||
| * DOM shape files the two paths into separate families and never compares | ||
| * them. And a reader perceives a column of avatars as a column because the | ||
| * pixels line up, not because the elements share a tag. | ||
| * | ||
| * So nothing here reads structure. Atoms are grouped by what they look like, | ||
| * a repeating series is whatever renders as a repeating series, and the | ||
| * expected coordinate is mined from what the majority of that series actually | ||
| * does. Nobody writes down that the indent step is 26px; it is counted. | ||
| * | ||
| * The bias is recall. A missed misalignment is invisible forever, while a | ||
| * false one costs a triage glance, so nothing is dropped for looking weak — | ||
| * candidates are ranked, not filtered, and every tie is broken towards | ||
| * reporting more. | ||
| */ | ||
|
|
||
| /** | ||
| * A rendered box. `id` exists so a finding can be named across runs and never | ||
| * takes part in grouping: the moment identity decides who is compared with | ||
| * whom, the DOM blindness described above is back. | ||
| */ | ||
| export type VisualAtom = Readonly<{ | ||
| id: string; | ||
| /** Geometry-derived primitive kind, never a semantic contract name. */ | ||
| kind: string; | ||
| xStart: number; | ||
| xEnd: number; | ||
| yStart: number; | ||
| yEnd: number; | ||
| }>; | ||
|
|
||
| export type VisualDeviationMeasure = 'start' | 'end' | 'center' | 'pitch'; | ||
|
|
||
| export type VisualDeviation = Readonly<{ | ||
| atomId: string; | ||
| /** The visual signature whose series this atom deviates inside. */ | ||
| signature: string; | ||
| axis: 'x' | 'y'; | ||
| measure: VisualDeviationMeasure; | ||
| value: number; | ||
| /** Median of the best-supported level in the same series. */ | ||
| expected: number; | ||
| delta: number; | ||
| /** How many series members share this atom's value. */ | ||
| peerSupport: number; | ||
| /** How many share the level it deviates from. */ | ||
| dominantSupport: number; | ||
| seriesSize: number; | ||
| /** Higher is more suspicious. Ranking only; never a pass/fail threshold. */ | ||
| score: number; | ||
| }>; | ||
|
|
||
| export type VisualRepetitionOptions = Readonly<{ | ||
| /** | ||
| * Shortest run that can carry an expectation at all. Two boxes agreeing is | ||
| * a coincidence; three is the weakest thing that can be called usual. | ||
| */ | ||
| minimumSeriesLength?: number; | ||
| /** | ||
| * How far apart two coordinates may be and still count as the same level. | ||
| * Sits at measurement noise (1/devicePixelRatio of the coarsest capture), | ||
| * not at a design tolerance — a real indent step is an order of magnitude | ||
| * above it. | ||
| */ | ||
| levelTolerance?: number; | ||
| /** Heights within this distance describe the same kind of box. */ | ||
| heightTolerance?: number; | ||
| /** | ||
| * A gap this many times the series median ends the series. Purely visual | ||
| * locality: it separates two lists that happen to render alike, without | ||
| * severing a list that a date header interrupts. | ||
| */ | ||
| seriesBreakRatio?: number; | ||
| }>; | ||
|
|
||
| const DEFAULTS = { | ||
| minimumSeriesLength: 3, | ||
| levelTolerance: 1, | ||
| heightTolerance: 1, | ||
| seriesBreakRatio: 3, | ||
| } as const; | ||
|
|
||
| function median(values: readonly number[]): number { | ||
| const sorted = [...values].sort((left, right) => left - right); | ||
| const middle = Math.floor(sorted.length / 2); | ||
| return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; | ||
| } | ||
|
|
||
| /** | ||
| * `link` versus `button` is a tag difference and `numeric-text` versus `text` | ||
| * is a content difference; neither is visible. Folding them widens each group, | ||
| * which is the direction that finds more. | ||
| */ | ||
| function normalizeKind(kind: string): string { | ||
| if (kind === 'link') return 'button'; | ||
| if (kind === 'numeric-text') return 'text'; | ||
| return kind; | ||
| } | ||
|
|
||
| /** | ||
| * Width is left out on purpose. A row's label is as wide as its text, so | ||
| * keying on width would split one visual series into one group per string | ||
| * length and leave nothing to compare. | ||
| */ | ||
| function visualSignature(atom: VisualAtom, heightTolerance: number): string { | ||
| const height = atom.yEnd - atom.yStart; | ||
| const bucket = heightTolerance > 0 ? Math.round(height / heightTolerance) : height; | ||
| return `${normalizeKind(atom.kind)}|h${bucket}`; | ||
| } | ||
|
|
||
| type Level = { readonly values: number[]; readonly atoms: VisualAtom[]; anchor: number }; | ||
|
|
||
| /** | ||
| * Levels grow by distance to the level's anchor, never to its nearest member. | ||
| * Single linkage would let a chain of intermediate coordinates walk one level | ||
| * into the next and quietly merge two indentation depths into one expectation | ||
| * — the merged level then looks internally perfect and the deviation vanishes. | ||
| */ | ||
| function buildLevels( | ||
| entries: readonly { readonly atom: VisualAtom; readonly value: number }[], | ||
| tolerance: number | ||
| ): Level[] { | ||
| const levels: Level[] = []; | ||
| for (const entry of [...entries].sort((left, right) => left.value - right.value)) { | ||
| const existing = levels.find((level) => Math.abs(entry.value - level.anchor) <= tolerance); | ||
| if (existing) { | ||
| existing.values.push(entry.value); | ||
| existing.atoms.push(entry.atom); | ||
| continue; | ||
| } | ||
| levels.push({ values: [entry.value], atoms: [entry.atom], anchor: entry.value }); | ||
| } | ||
| return levels; | ||
| } | ||
|
|
||
| /** | ||
| * Every member of every non-dominant level is reported. In a legitimate indent | ||
| * ladder that means the whole indented half comes back as deviations — which | ||
| * is the accepted cost of not knowing in advance which of the two levels was | ||
| * intended. They rank low because they have each other: score falls as a | ||
| * level's own support rises, so a value only two boxes share outranks one | ||
| * forty boxes share, and the stray middle row sorts above the ladder. | ||
| */ | ||
| function scoreLevels( | ||
| levels: readonly Level[], | ||
| seriesSize: number, | ||
| signature: string, | ||
| axis: 'x' | 'y', | ||
| measure: VisualDeviationMeasure, | ||
| levelTolerance: number | ||
| ): VisualDeviation[] { | ||
| if (levels.length < 2) return []; | ||
| const dominant = levels.reduce((best, level) => | ||
| level.atoms.length > best.atoms.length ? level : best | ||
| ); | ||
| const dominantSupport = dominant.atoms.length; | ||
| const expected = median(dominant.values); | ||
| const deviations: VisualDeviation[] = []; | ||
| for (const level of levels) { | ||
| if (level === dominant) continue; | ||
| const peerSupport = level.atoms.length; | ||
| for (const [index, atom] of level.atoms.entries()) { | ||
| const value = level.values[index]!; | ||
| const delta = Math.abs(value - expected); | ||
| if (delta <= levelTolerance) continue; | ||
| deviations.push({ | ||
| atomId: atom.id, | ||
| signature, | ||
| axis, | ||
| measure, | ||
| value, | ||
| expected, | ||
| delta, | ||
| peerSupport, | ||
| dominantSupport, | ||
| seriesSize, | ||
| score: (delta * dominantSupport) / peerSupport, | ||
| }); | ||
| } | ||
| } | ||
| return deviations; | ||
| } | ||
|
|
||
| /** | ||
| * Splits one signature group into runs that read as a single series. Members | ||
| * are ordered along the series axis and cut where the gap jumps far past the | ||
| * run's own median gap. | ||
| */ | ||
| function splitIntoSeries( | ||
| atoms: readonly VisualAtom[], | ||
| axis: 'x' | 'y', | ||
| breakRatio: number, | ||
| minimumLength: number | ||
| ): VisualAtom[][] { | ||
| const center = (atom: VisualAtom) => | ||
| axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2; | ||
| const ordered = [...atoms].sort((left, right) => center(left) - center(right)); | ||
| const gaps: number[] = []; | ||
| for (let index = 1; index < ordered.length; index += 1) { | ||
| gaps.push(center(ordered[index]!) - center(ordered[index - 1]!)); | ||
| } | ||
| if (gaps.length === 0) return []; | ||
| const typicalGap = median(gaps); | ||
| const series: VisualAtom[][] = []; | ||
| let current: VisualAtom[] = [ordered[0]!]; | ||
| for (let index = 1; index < ordered.length; index += 1) { | ||
| const gap = gaps[index - 1]!; | ||
| if (typicalGap > 0 && gap > typicalGap * breakRatio) { | ||
| series.push(current); | ||
| current = []; | ||
| } | ||
| current.push(ordered[index]!); | ||
| } | ||
| series.push(current); | ||
| return series.filter((run) => run.length >= minimumLength); | ||
| } | ||
|
|
||
| /** | ||
| * Orientation is measured, not declared: whichever axis the boxes spread along | ||
| * is the series axis, and the expectations worth mining are the ones | ||
| * perpendicular to it. Mining the series axis itself would only rediscover | ||
| * that a list advances down the page. | ||
| */ | ||
| function seriesAxis(atoms: readonly VisualAtom[]): 'x' | 'y' { | ||
| const xs = atoms.map((atom) => (atom.xStart + atom.xEnd) / 2); | ||
| const ys = atoms.map((atom) => (atom.yStart + atom.yEnd) / 2); | ||
| const spread = (values: readonly number[]) => Math.max(...values) - Math.min(...values); | ||
| return spread(ys) >= spread(xs) ? 'y' : 'x'; | ||
| } | ||
|
|
||
| export function mineVisualDeviations( | ||
| atoms: readonly VisualAtom[], | ||
| options: VisualRepetitionOptions = {} | ||
| ): readonly VisualDeviation[] { | ||
| const minimumSeriesLength = options.minimumSeriesLength ?? DEFAULTS.minimumSeriesLength; | ||
| const levelTolerance = options.levelTolerance ?? DEFAULTS.levelTolerance; | ||
| const heightTolerance = options.heightTolerance ?? DEFAULTS.heightTolerance; | ||
| const seriesBreakRatio = options.seriesBreakRatio ?? DEFAULTS.seriesBreakRatio; | ||
|
|
||
| const groups = new Map<string, VisualAtom[]>(); | ||
| for (const atom of atoms) { | ||
| const signature = visualSignature(atom, heightTolerance); | ||
| const group = groups.get(signature); | ||
| if (group) group.push(atom); | ||
| else groups.set(signature, [atom]); | ||
| } | ||
|
|
||
| const deviations: VisualDeviation[] = []; | ||
| for (const [signature, group] of groups) { | ||
| if (group.length < minimumSeriesLength) continue; | ||
| const axis = seriesAxis(group); | ||
| for (const run of splitIntoSeries(group, axis, seriesBreakRatio, minimumSeriesLength)) { | ||
| const edges = | ||
| axis === 'y' | ||
| ? ([ | ||
| ['x', 'start', (atom: VisualAtom) => atom.xStart], | ||
| ['x', 'end', (atom: VisualAtom) => atom.xEnd], | ||
| ['x', 'center', (atom: VisualAtom) => (atom.xStart + atom.xEnd) / 2], | ||
| ] as const) | ||
| : ([ | ||
| ['y', 'start', (atom: VisualAtom) => atom.yStart], | ||
| ['y', 'end', (atom: VisualAtom) => atom.yEnd], | ||
| ['y', 'center', (atom: VisualAtom) => (atom.yStart + atom.yEnd) / 2], | ||
| ] as const); | ||
|
|
||
| for (const [edgeAxis, measure, read] of edges) { | ||
| const entries = run.map((atom) => ({ atom, value: read(atom) })); | ||
| deviations.push( | ||
| ...scoreLevels( | ||
| buildLevels(entries, levelTolerance), | ||
| run.length, | ||
| signature, | ||
| edgeAxis, | ||
| measure, | ||
| levelTolerance | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| // Irregular spacing is the same kind of defect seen along the series | ||
| // axis, and the series is already ordered, so it costs nothing to mine. | ||
| const along = (atom: VisualAtom) => | ||
| axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2; | ||
| const pitchEntries = run.slice(1).map((atom, index) => ({ | ||
| atom, | ||
| value: along(atom) - along(run[index]!), | ||
| })); | ||
| if (pitchEntries.length >= minimumSeriesLength) { | ||
| deviations.push( | ||
| ...scoreLevels( | ||
| buildLevels(pitchEntries, levelTolerance), | ||
| run.length, | ||
| signature, | ||
| axis, | ||
| 'pitch', | ||
| levelTolerance | ||
| ) | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return deviations.sort((left, right) => right.score - left.score); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { writeFile } from 'node:fs/promises'; | ||
|
|
||
| import { test } from '@playwright/test'; | ||
|
|
||
| import type { GeometryObservationCache } from '../../src/lib/geometry-constraint-system'; | ||
| import { | ||
| GEOMETRY_REPRESENTATIVE_CAPTURE, | ||
| runGeometryCapturePlan, | ||
| } from './support/geometry-capture-plan'; | ||
|
|
||
| test('dump one real capture', async ({ browser }) => { | ||
| test.setTimeout(600_000); | ||
| const observationCache: GeometryObservationCache = new Map(); | ||
| const blockedRequests: string[] = []; | ||
| const capture = await runGeometryCapturePlan(browser, [GEOMETRY_REPRESENTATIVE_CAPTURE], { | ||
| observationCache, | ||
| blockedRequests, | ||
| }); | ||
| await writeFile( | ||
| '/tmp/geom-run/capture-one.json', | ||
| `${JSON.stringify(capture, null, 2)}\n`, | ||
| 'utf8' | ||
| ); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When one signature occurs in multiple side-by-side vertical lists,
seriesAxis(group)measures the entire page-wide group before those lists are separated; if their horizontal separation exceeds their vertical span, it classifies them as one horizontal series.splitIntoSeriesthen orders by X (often producing zero gaps for same-column atoms), emits false Y/pitch deviations for an otherwise regular grid, and never checks either list's X alignment. Partition local series before selecting an orientation for each one.AGENTS.md reference: packages/components/src/lib/geometry-discovery/AGENTS.md:L46-L48
Useful? React with 👍 / 👎.